docs(link): cliloc overlays for shard-added and shard-edited items

Shards edit items and add new ones, carrying cliloc ids no stock client table
has. The cliloc table now reads a SET of sources rather than one file — a base
plus every operator-maintained overlay under `custom/`, hash-gated together and
re-read on every boot, exactly as §6 reads the ServUO tree. Matching docs for
website #115.

website/CLILOCS.md gains a "Shard-added and shard-edited items" section: the
directory layout, merge precedence, the per-source breakdown an operator uses
to confirm an overlay took effect, and why `custom/` is a convention we chose
rather than one discovered from the shard — ServUO has no server-side notion of
a custom cliloc, they live in the patched client a shard distributes and
nothing in the tree declares them. Scale recorded: the live shard's script tree
references 16,434 cliloc ids and only 37 are absent from stock, which is why
this is an overlay and not a second table.

"Why there is no staged-approval flow" is replaced by "Two ways a refresh is
refused", because the set brings back the hazard a single file did not have. A
corrupt source fails the parse loudly; a source that has VANISHED parses
perfectly and imports a table quietly missing everything it contributed. That
is the same ambiguity §6 stages a facet removal for, so it is staged here too
(`needsReview`, `{approve:true}` to accept) — as a flag rather than §6's
approve/reject pair, because the atlas stores a pending decision SO THAT
approving re-parses, and here nothing is stored.

Two more traps recorded in §8.6 (now five), both found by running a shard-style
overlay rather than another stock-table fixture:

- Tidying punctuation unconditionally corrupts real names — a custom
  "Runic Gateway Sigil (v2)" rendered as "(v2". Stripping leftover brackets is
  right after a placeholder is removed and wrong otherwise, the same condition
  the `%` rule already had.
- Source labels must be forward-slashed and root-relative or the same directory
  fingerprints differently on Windows and Linux and every boot looks like a
  change — the identical bug §6 records.

BACKEND_DESIGN.md: the source set, the two refusals, and the updated admin
route notes (`approve`, `missingSources`, overlays found beside a file path).

Documentation only. Every claim was written after the behaviour was observed:
the counts come from the live shard's script tree, and the refusal/approve flow
and per-source breakdown are copied from real runs in website #115.

PROJECT_TREE.md files are deliberately untouched — CI-generated by the
sync-project-tree workflow.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-29 06:46:43 -05:00
parent e3aabf9e3e
commit ee0c146d7a
3 changed files with 151 additions and 35 deletions

View File

@@ -762,7 +762,25 @@ shapes are the plain binary layout and a `number<TAB|,|;>text` export; the site
and writes the plain form. A shard that never converts is fully supported — names render as ids, and writes the plain form. A shard that never converts is fully supported — names render as ids,
exactly as before. exactly as before.
Three traps found by building it, all recorded in `CLILOCS.md`: **Shards edit items and add new ones**, and those carry ids no stock client table has — so this reads
a **set** of sources, not one file, hash-gated together and re-read on every boot exactly as §6 reads
the ServUO tree: a base (the converted client table) plus every overlay under `custom/`, later
winning. Adding one custom item therefore never means re-exporting a 5 MB client file. Measured on
the live shard for scale: its script tree references **16,434** cliloc ids and only **37** are absent
from stock — tens against a 67k base, which is why an overlay and not a second table. `custom/` is the
one convention here that is ours rather than the shard's, because **ServUO has no server-side notion
of a custom cliloc**: they live in the patched client a shard distributes, and nothing in the tree
declares them.
That set also brings back a hazard a single file did not have, and §8.6 answers it the way §6 does. A
corrupt source fails the parse loudly, but a source that has **vanished** parses perfectly and imports
a table quietly missing everything it contributed — an unmounted volume is indistinguishable from a
deliberate deletion. So it is **staged, not applied** (`status: 'needsReview'`), reported by both the
import and `status()`, and accepted with `{approve:true}`. It is a flag rather than §6's
approve/reject pair because the atlas stores a pending decision *so that approving re-parses*; here
nothing is stored, so re-reading at approval time is automatic.
Five traps found by building it, all recorded in `CLILOCS.md`:
- **`StringList.SaveStringList` RE-COMPRESSES on save.** It looks exactly like the export path and is - **`StringList.SaveStringList` RE-COMPRESSES on save.** It looks exactly like the export path and is
not; its output is byte-identical to its compressed input, because its purpose is round-tripping a not; its output is byte-identical to its compressed input, because its purpose is round-tripping a
@@ -773,6 +791,12 @@ Three traps found by building it, all recorded in `CLILOCS.md`:
entries vanished, and the import still looked successful. entries vanished, and the import still looked successful.
- **`Number('')` is `0`, not `NaN`.** A line starting with a separator imports as a bogus cliloc 0 - **`Number('')` is `0`, not `NaN`.** A line starting with a separator imports as a bogus cliloc 0
unless the empty field is rejected explicitly. unless the empty field is rejected explicitly.
- **Tidying punctuation unconditionally corrupts real names.** Stripping leftover brackets is right
after a placeholder is removed (`[~1_stuff~]` → nothing) and wrong otherwise: a shard's custom
`"Runic Gateway Sigil (v2)"` rendered as `"(v2"`. Same shape as the `%` rule. **Found only by
running a shard-style overlay through it** — every stock-table fixture passed.
- **Source labels must be forward-slashed and root-relative**, or the same directory fingerprints
differently on Windows and Linux and every boot looks like a change. The identical bug §6 records.
Blank entries are dropped at import (123,490 parsed → **67,496** stored), which also makes the binary Blank entries are dropped at import (123,490 parsed → **67,496** stored), which also makes the binary
and text paths converge on identical content. and text paths converge on identical content.

View File

@@ -501,10 +501,19 @@ marketplace listing — but with no table to resolve it against, the character s
Import-owned and all-or-nothing in one transaction, same contract as the atlas — including **`DELETE`, Import-owned and all-or-nothing in one transaction, same contract as the atlas — including **`DELETE`,
not `TRUNCATE`**, for the same reason. not `TRUNCATE`**, for the same reason.
**Sourced from a file the operator converts once from their own UO client**, at a path from the **Sourced from files the operator supplies**, at a path from the `cliloc_client_path` setting falling
`cliloc_client_path` setting falling back to `UO_CLIENT_PATH`. Nothing client-derived is committed: back to `UO_CLIENT_PATH`. Nothing client-derived is committed: UO's strings are EA's, exactly as the
UO's strings are EA's, exactly as the creature sprites are. A shard with nothing configured is fully creature sprites are. A shard with nothing configured is fully supported — names render as ids. Full
supported — names render as ids. Full design and operator guide: [`CLILOCS.md`](CLILOCS.md). design and operator guide: [`CLILOCS.md`](CLILOCS.md).
**It reads a SET of sources, not one file**, because shards edit items and add new ones and those
carry cliloc ids no stock client table has. A base (the converted client table) plus every overlay
under `custom/` are re-read on every boot and hash-gated **together**, exactly as the atlas re-reads
`Regions.xml` + `Locations/*.xml` + `Spawns/*.xml` + `ChampionSpawns.xml`. Later sources win, so an
overlay both adds ids and overrides stock ones, and adding one custom item never means re-exporting a
5 MB client file. Scale, measured on the live shard: its script tree references 16,434 cliloc ids and
only 37 are absent from stock — tens of entries against a 67k base, which is why this is an overlay
and not a second table.
The conversion step is not avoidable: **every current client ships its cliloc files compressed** The conversion step is not avoidable: **every current client ships its cliloc files compressed**
(first DWORD's high byte `0x8E`), and ServUO's own bundled `Ultima.StringList` cannot read that (first DWORD's high byte `0x8E`), and ServUO's own bundled `Ultima.StringList` cannot read that
@@ -519,11 +528,15 @@ Three decisions worth stating:
table is empty strings for ids the client reserves and never uses; a row that resolves to no name is table is empty strings for ids the client reserves and never uses; a row that resolves to no name is
indistinguishable from no row at all, and dropping them makes the binary and text imports converge indistinguishable from no row at all, and dropping them makes the binary and text imports converge
on identical content. on identical content.
- **No staged-approval flow, unlike the atlas.** The atlas escalates facet loss because a half-copied - **Two refusals, one of them the atlas's.** A corrupt source fails the parse on a truncated record,
tree and a real map change are indistinguishable from inside the process. A cliloc file is one file so it is caught outright and leaves the previous table serving. But a source that has **vanished**
with one hash, and a partial copy makes the parser fail on a truncated record — the ambiguity the parses perfectly and imports a table quietly missing everything it contributed — an unmounted volume
atlas must escalate is one this parser simply detects, so it refuses the import and leaves the and a deliberate deletion are indistinguishable from here, which is precisely the ambiguity the
previous table serving. atlas stages a facet removal for. So it is escalated: `status: 'needsReview'`, nothing applied,
`missingSources` reported by both the import and `status()`, and an admin accepts it with
`{approve:true}`. That is a flag rather than the atlas's approve/reject pair because the atlas
stores a pending decision so that approving **re-parses** the tree; here nothing is stored, so
re-reading at approval time is automatic.
**Resolution is server-side and there is no public route.** The table is never served *as* a table: **Resolution is server-side and there is no public route.** The table is never served *as* a table:
67k rows would dwarf any page using them, and the Android client consumes the same already-resolved 67k rows would dwarf any page using them, and the Android client consumes the same already-resolved
@@ -786,9 +799,9 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/shard/atlas/import` | re-import without restarting; `{force}` ignores the hash gate. **An unreadable tree answers 200 with `status:"unavailable"`, not 500**`refresh()` reports outcomes rather than throwing (the boot path must never be blocked by a bad tree) and that contract is preserved at the API. | | POST | `/shard/atlas/import` | re-import without restarting; `{force}` ignores the hash gate. **An unreadable tree answers 200 with `status:"unavailable"`, not 500**`refresh()` reports outcomes rather than throwing (the boot path must never be blocked by a bad tree) and that contract is preserved at the API. |
| POST | `/shard/atlas/approve` · `/shard/atlas/reject` | answer a refresh staged because it would REMOVE a facet. Approving **re-parses** the tree, so what lands matches it at approval time; rejecting is remembered against those source hashes so it does not re-prompt every restart. 404 when nothing is staged. | | POST | `/shard/atlas/approve` · `/shard/atlas/reject` | answer a refresh staged because it would REMOVE a facet. Approving **re-parses** the tree, so what lands matches it at approval time; rejecting is remembered against those source hashes so it does not re-prompt every restart. 404 when nothing is staged. |
| PUT | `/shard/atlas/path` | point the atlas at a different tree (persisted as `spawn_atlas_servuo_path`, which wins over `SERVUO_PATH`). Blank clears it. Deliberately **does not import** — moving the mount and reloading the world are separate decisions — and returns fresh status so the panel can offer the import next. | | PUT | `/shard/atlas/path` | point the atlas at a different tree (persisted as `spawn_atlas_servuo_path`, which wins over `SERVUO_PATH`). Blank clears it. Deliberately **does not import** — moving the mount and reloading the world are separate decisions — and returns fresh status so the panel can offer the import next. |
| GET | `/shard/clilocs` | cliloc-table status (`adminOnly`): the configured path, the file actually resolved (the path may be a directory), readability, drift against what is loaded, and the entry count. `configured:false` is a supported state — item names then render as ids. No public counterpart: the table is never served *as* a table. | | GET | `/shard/clilocs` | cliloc-table status (`adminOnly`): every source found now (base first, then `custom/` overlays in merge order), what each contributed at the last import, readability, drift across the set, the entry count, and `missingSources`. `configured:false` is a supported state — item names then render as ids. No public counterpart: the table is never served *as* a table. |
| POST | `/shard/clilocs/import` | reload after a client patch; `{force}` ignores the hash gate. **A missing file — or the likely mistake of pointing at the client's own COMPRESSED `Cliloc.enu` — answers 200 with `status:"unavailable"` and a `code`, not 500.** `COMPRESSED` is called out by name: a 500 would say only "something broke", and the operator needs to be told which file to convert. | | POST | `/shard/clilocs/import` | reload after a client patch or an overlay edit; `{force}` ignores the hash gate, `{approve}` accepts a **vanished** source (refused by default — see the table notes above). **A missing path — or the likely mistake of pointing at the client's own COMPRESSED `Cliloc.enu` — answers 200 with `status:"unavailable"` and a `code`, not 500.** `COMPRESSED` is called out by name: a 500 would say only "something broke", and the operator needs to be told which file to convert. |
| PUT | `/shard/clilocs/path` | point the site at a different cliloc file or directory (persisted as `cliloc_client_path`, which wins over `UO_CLIENT_PATH`). Blank clears it. Deliberately **does not import**, same reasoning as the atlas path. | | PUT | `/shard/clilocs/path` | point the site at a different cliloc base file or directory (persisted as `cliloc_client_path`, which wins over `UO_CLIENT_PATH`). Overlays are read from `custom/` beside it either way. Blank clears it. Deliberately **does not import**, same reasoning as the atlas path. |
Every admin write logs to `activity_log`. Every admin write logs to `activity_log`.

View File

@@ -80,21 +80,74 @@ dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/cli
A UOFiddler GUI export works equally well — anything producing one of the two A UOFiddler GUI export works equally well — anything producing one of the two
shapes above is fine. shapes above is fine.
## Shard-added and shard-edited items
**Shards edit items and add new ones**, and those carry cliloc ids no stock
client table has. The table is therefore built from a **set** of sources, all
re-read on every boot and hash-gated together — the same shape as the spawn
atlas, which reads `Regions.xml` + `Locations/*.xml` + `Spawns/*.xml` +
`ChampionSpawns.xml` and merges them:
```
<cliloc path>/
clilocs.plain ← base: the converted client table
custom/
01-uomysticmoon.tsv ← overlays: shard additions and overrides
02-events.tsv
```
Overlays use the same delimited-text format, are read in **sorted order**, and
**later sources win** — so an overlay both *adds* ids the client never had and
*overrides* stock ones the shard has re-purposed. Any `.tsv`, `.csv`, `.txt`,
`.enu` or `.plain` file in `custom/` is picked up; anything else (a `README.md`,
say) is ignored.
Adding, editing or removing any overlay counts as drift, so a new custom item
needs only a file edit and a restart — or the admin panel's Import button.
**Adding one item never means re-exporting a 5 MB client file.**
The import result reports what each source contributed, which is how you confirm
an overlay took effect — `overrode: 0` on a file meant to re-label stock items
says it did not:
```json
"sources": [
{ "label": "clilocs.plain", "kind": "base", "entries": 123490, "added": 123490, "overrode": 0 },
{ "label": "custom/uomysticmoon.tsv", "kind": "custom", "entries": 2, "added": 1, "overrode": 1 }
]
```
**Why a convention rather than discovery.** Everywhere else this pipeline follows
the shard's own files, but **ServUO has no server-side notion of a custom
cliloc** — they live in the patched client a shard distributes to its players,
and nothing in the tree declares them. There is nothing to discover, so `custom/`
is the one thing here that is our convention rather than the shard's. (An
operator who *does* patch their client cliloc needs no overlay at all: convert
the patched file and their edits are simply in the base.)
Measured on the live shard for scale: its script tree references **16,434** cliloc
ids and only **37** are absent from the stock client table — tens of entries
against a 67k base, which is what makes an overlay the right shape rather than a
second full table.
## Configuring the path ## Configuring the path
Two ways to point at the converted file, the setting winning over the Two ways to point at the sources, the setting winning over the environment:
environment:
| Source | Notes | | Source | Notes |
|---|---| |---|---|
| `cliloc_client_path` setting | Admin-editable (Admin → Shard); takes effect on the next refresh without a redeploy | | `cliloc_client_path` setting | Admin-editable (Admin → Shard); takes effect on the next refresh without a redeploy |
| `UO_CLIENT_PATH` env var | The deploy-time default, since the path usually describes a mount the deployment sets up | | `UO_CLIENT_PATH` env var | The deploy-time default, since the path usually describes a mount the deployment sets up |
The value may be **the file itself or a directory to search**, because both are The value may be **the base file itself or a directory to search**, because both
natural answers to "where is it". A directory is searched case-insensitively (the are natural answers to "where is it". Overlays are read from a `custom/`
client writes `Cliloc.enu` on Windows; the site usually runs on Linux) for, in directory beside the base **either way** — pointing at a file does not forfeit
order: `clilocs.tsv`, `clilocs.csv`, `cliloc.plain.enu`, `cliloc.enu.plain`, them.
`clilocs.txt`, `cliloc.enu`.
A directory is searched case-insensitively (the client writes `Cliloc.enu` on
Windows; the site usually runs on Linux) for, in order: `clilocs.tsv`,
`clilocs.csv`, `clilocs.plain`, `cliloc.plain`, `cliloc.plain.enu`,
`cliloc.enu.plain`, `clilocs.txt`, `cliloc.enu`.
That ordering puts explicitly-converted names first on purpose. Pointing the That ordering puts explicitly-converted names first on purpose. Pointing the
setting straight at an unconverted client directory finds `cliloc.enu`, which is setting straight at an unconverted client directory finds `cliloc.enu`, which is
@@ -120,22 +173,44 @@ Identical in shape to the spawn atlas, and for the same reasons:
- **A `PARSER_VERSION` bump also counts as drift**, so a corrected parse reaches - **A `PARSER_VERSION` bump also counts as drift**, so a corrected parse reaches
an install whose client never patches. an install whose client never patches.
### Why there is no staged-approval flow ### Two ways a refresh is refused
The atlas stages a refresh that would *remove a facet*, because a half-copied **A corrupt file** — the realistic failure for any single source — makes the
tree and a real map change are indistinguishable from inside the process. A parser fail on a truncated record rather than yield a plausible-but-short table,
cliloc file is one file with one hash, and its realistic corruption — a partial so it is caught outright. Verified: a file truncated to half its length reports
copy — makes the parser fail on a truncated record instead of yielding a
plausible-but-short table. **The ambiguity the atlas has to escalate to a human is
one this parser can simply detect**, so it refuses the import and leaves the
previous table serving. Verified: a file truncated to half its length reports
``` ```
code: TRUNCATED code: TRUNCATED
reason: Truncated record header at byte 2486759 (74909 entries read) reason: Truncated record header at byte 2486759 (74909 entries read)
``` ```
and the 67,496 rows already loaded are untouched. and the rows already loaded are untouched. A malformed overlay names the file it
came from (`custom/broken.tsv: No cliloc entries found…`), because "which of my
six overlay files is broken" is otherwise a guessing game.
**A source that has VANISHED** is the hazard a single file did not have. It
parses perfectly and imports a table quietly missing everything that file
contributed — and an unmounted volume looks exactly like a deliberate deletion
from here. This is the same ambiguity the atlas stages a facet removal for, so it
is escalated rather than applied:
```
status: needsReview
reason: 1 previously-loaded cliloc source(s) are missing;
the existing table is unchanged
missingSources: ["custom/uomysticmoon.tsv"]
```
`status()` reports `missingSources` too, so the panel can show it before anyone
clicks Import. An admin accepts it by re-running the import with
`{ "approve": true }`.
**Why that is a flag and not the atlas's approve/reject pair.** The atlas stores
a pending decision in its own table so that approving *re-parses the tree*, which
is what keeps a multi-megabyte blob out of the database and makes the applied
result match the tree at approval time. Here nothing is stored, so re-reading at
approval time is automatic — the decision is a single boolean on the import an
admin was already going to run.
## What gets stored ## What gets stored
@@ -182,11 +257,15 @@ not the packet. So a name carrying them is reduced to what is actually knowable.
| `cold damage ~1_val~%` | `cold damage` | | `cold damage ~1_val~%` | `cold damage` |
| `[~1_stuff~]` | *(nothing — the whole string was the argument)* | | `[~1_stuff~]` | *(nothing — the whole string was the argument)* |
| `50%` | `50%` | | `50%` | `50%` |
| `Runic Gateway Sigil (v2)` | `Runic Gateway Sigil (v2)` |
The trailing `%` in row two is dropped **because a placeholder was removed** — it **Punctuation is only tidied when a placeholder was actually removed.** The
is the unit belonging to the number we never had. Row four shows why that trailing `%` in row two is the unit belonging to the number we never had, and the
condition matters: stripping `%` unconditionally would corrupt a string that brackets in row three only ever wrapped the argument — but a string with no
legitimately ends in one. placeholder has no such debris, and trimming it anyway corrupts real names. Rows
four and five are the ones that caught it: a shard's custom
`"Runic Gateway Sigil (v2)"` rendered as `"(v2"` while the bracket trim was
unconditional.
### Consumers ### Consumers
@@ -205,8 +284,8 @@ All admin-only, alongside the atlas under Admin → Shard:
| Route | Purpose | | Route | Purpose |
|---|---| |---|---|
| `GET /api/v1/admin/shard/clilocs` | Path, resolved file, readability, drift, entry count | | `GET /api/v1/admin/shard/clilocs` | Sources found, what each contributed at the last import, readability, drift, entry count, `missingSources` |
| `POST /api/v1/admin/shard/clilocs/import` | Reload after a client patch; `{ "force": true }` reimports an unchanged file | | `POST /api/v1/admin/shard/clilocs/import` | Reload after a client patch or an overlay edit; `{ "force": true }` reimports an unchanged set, `{ "approve": true }` accepts a vanished source |
| `PUT /api/v1/admin/shard/clilocs/path` | Set the path; blank disables resolution | | `PUT /api/v1/admin/shard/clilocs/path` | Set the path; blank disables resolution |
A refresh **result is not an exception**: a missing file, or the likely mistake of A refresh **result is not an exception**: a missing file, or the likely mistake of