Files
docs/website/CLILOCS.md
wtclaude fc2d4872f6 docs(link): the panel, and the two deletions it could see and no test could (Phase 8)
v8.md section 12.2 records the admin surface as built: one page, Admin -> Client
Files, over the creature catalogue, item and land art, and the cliloc table --
one page because they come off one client install and change on one event. The
four decisions are written down, including the one that kept the phase to a
single repo: section 14's "activity view" is the last import's own summary
rather than a filtered feed, which would have needed a core activity filter, a
`ctx.activity.list` helper and a MODULE_API bump to re-read rows the panel can
keep itself.

The part worth reading twice is what the walk found. Two of the three defects
were DELETIONS, both predate phase 8, and neither was reachable by a test that
did not already suspect them:

  * the body import diffing its manifest against every family's rows, so 313
    item pictures were staged for deletion under a sentence that was wrong about
    what had happened;
  * an approved vanish unlinking the sprite and keeping the row, so the key came
    back for review forever and the count never moved.

The rule they share is now stated: after phase 5, `shard_assets` is three
collections in one table, and every read of it that feeds a decision has to say
which one it means. Two of the three that did not were deletions.

CLILOCS.md and SPAWN_ATLAS.md now name the button that exists (Admin -> Client
Files) instead of the one they promised, and modules/uo/API.md records that a
`needsReview` reply carries each vanished key's picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 08:10:49 -05:00

350 lines
18 KiB
Markdown

# Cliloc table (item and title names)
**Status:** Complete. The base table now arrives **over the bridge** — protocol 8, phase 2.
**Design:** [`docs/link/v3.md` §8.6](../link/v3.md) (the table itself, Protocol 3.0) and
[`docs/link/v8.md` §9](../link/v8.md) (the Asset Bridge, which retired the manual conversion).
A "cliloc" is UO's localization table: an integer id mapped to a display string.
**Items on the wire carry a `LabelNumber`, not a name.** The bridge has always
sent that number — `char.profile.equipment` has a `cliloc` field, reward titles
arrive as a cliloc number in string form, and every marketplace listing carries
one — but the site had no table to look it up in, so a character sheet could only
render `id 1023721` where the game renders **"quarter staff"**.
The number was never the missing piece. The table was.
## Where the table comes from
**The shard reads its own client.** A ServUO server cannot boot without a UO
client — `Config/DataPath.cfg` resolves into `Core.DataDirectories` at run time —
so the file this table is made of is already sitting on the shard host. Since
protocol 8 the plugin decompresses it and serves it over the bridge, and the site
imports it like any other shard read:
```
ServUO shard ──`cliloc.table`──▶ uo-link sidecar ──`GET /cliloc`──▶ website
reads Cliloc.enu, forwards, keeps merges overlays,
decompresses, pages nothing replaces the table
```
**Why that was worth building.** Every current UO client ships its cliloc files
compressed: all eight `Cliloc.*` files in a modern client (`chs`, `cht`, `deu`,
`enu`, `esp`, `fra`, `jpn`, `kor`) begin with a DWORD whose high byte is `0x8E`
the "Mythic" container. The plain layout is what those files looked like *before*
that change, and **ServUO's own bundled `Ultima.StringList` cannot read the new
one either**, which is why `VendorSearch.GetItemName` is inert on a modern shard
and the in-game Vendor Search gump has the same gap.
So until protocol 8 an operator had to install UOFiddler, build a converter
against its `Ultima.dll`, run it over their client and copy a 5 MB file to the web
host — every time they patched. The decompressor now lives in the overlay
(`overlay/Scripts/Custom/Bridge/BridgeCliloc.cs`, ported from UOFiddler, which is
Beerware and therefore clean to bring into a GPL tree), so **none of that is a
step any more**.
Two things are unchanged and remain the point:
- **Nothing client-derived is committed.** UO's strings are EA's. The repo ships
no string table for the same reason it ships no artwork and no map snapshot —
see [`SPAWN_ATLAS.md`](SPAWN_ATLAS.md). The extraction happens on the
operator's own host, from their own files, for their own shard.
- **A shard with no table is fully supported.** Names render as ids, exactly as
they did before the table existed.
### What arrives, and in how many pieces
The sidecar's reply timeout is 10 s and its inbound line cap is 1 MiB, so the
table is **paged**: the shard cuts at a 512 KiB byte budget and hands back a
cursor, and the site walks it until a page says `more: false`. Measured on a stock
English client: **67,496 rows in about eleven pages**, decoded on the shard in
**290 ms**.
The rows are `{ n, f, t }` — number, flag, text. **Blanks never leave the shard**:
roughly 56,000 of a stock table's 123,490 entries are empty strings the client
reserves and never uses, the site drops them at import anyway, and sending them
would double the transfer for data that is discarded on arrival.
Only `cut: "end"` means the table finished. A short page can equally mean the byte
budget was spent, and importing a table that stopped early is the one failure that
is invisible downstream — some items named, some not, which is exactly what *no
table* looks like.
### The file pipeline is deprecated, not removed
An install with **no uo-link configured** can still be pointed at a converted file
and works exactly as it did. That path exists for shards with no bridge and for
development without a running ServUO, it accepts the same two formats it always
did (plain binary, or `number<TAB|,|;>text` delimited text), and passing an
explicit path to a refresh still selects it as a one-off. Nothing new should be
built on it.
## 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>/
custom/
01-uomysticmoon.tsv ← overlays: shard additions and overrides
02-events.tsv
```
The base is no longer a file in that directory — it comes from the shard — so on
a bridged install the configured path selects **only** where `custom/` is read
from. (Without a shard link, a converted base file sitting beside `custom/` is
still found, which is the deprecated pipeline above.)
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 the admin panel's Import button. **Adding one item
never means re-reading the client table** — though on the bridge that is now
cheap enough not to matter much.
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": "cliloc.enu", "kind": "shard", "entries": 67496, "added": 67496, "overrode": 0 },
{ "label": "custom/uomysticmoon.tsv", "kind": "custom", "entries": 2, "added": 1, "overrode": 1 }
]
```
`kind` says where a source came from: `shard` over the bridge, `base` a converted
file on disk, `custom` an overlay.
**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.
## Choosing the source
Nothing to configure: **the shard wins whenever uo-link is configured and
enabled.** There is no mode setting, because there is no version of this question
an operator benefits from answering — a shard that can serve its own client table
is strictly better than a file somebody converted by hand months ago.
The two escape hatches, both deliberate:
| | |
|---|---|
| No uo-link configured | The file pipeline, exactly as before |
| An explicit `path` passed to a refresh | A one-off "import from this file", which the shard never overrules |
`cliloc_client_path` (setting, admin-editable) and `UO_CLIENT_PATH` (env
deploy-time default) still name a path, and the setting still wins over the
environment. What that path *means* narrowed: on a bridged install it is where
`custom/` overlays live. Without a link it is also searched for a base file,
case-insensitively (the client writes `Cliloc.enu` on Windows; the site usually
runs on Linux), in order: `clilocs.tsv`, `clilocs.csv`, `clilocs.plain`,
`cliloc.plain`, `cliloc.plain.enu`, `cliloc.enu.plain`, `clilocs.txt`,
`cliloc.enu` — and pointing it straight at an unconverted client directory still
answers by name rather than failing obscurely:
```
status: unavailable
code: COMPRESSED
reason: This is a compressed (Mythic-format) cliloc file, which the site cannot
read. Convert it to the plain format first — see docs/website/CLILOCS.md.
```
## Refresh contract
- **It never blocks startup**, and on the bridge it never *touches* startup: boot
imports nothing when the shard is the source. A sidecar round trip in the boot
sequence would be spent answering "no" on every restart but the one after a
client patch — and patching a client is an operator action, so importing is an
operator action. **Admin → Client Files → Update** is the button (the cliloc
section of it; the page covers everything that comes off the UO client, because
a client patch changes all of it at once). Whatever table is loaded keeps
serving until then.
- **Still hash-gated**, so pressing Import when nothing changed costs one small
call. The gate is the shard's own `assets.sources`: size, mtime and content
hash of `Cliloc.enu` plus the shard's `EXTRACTOR_VERSION`. A `sha256` of `null`
with `hashing: true` means the shard has not computed it yet (it hashes off the
request path, because the art and animation files it also reports are 343 MB)
— that means *ask again*, never *changed*, and the comparison falls back to
(size, mtime) meanwhile.
- **Without a link** the file path is unchanged: boot hashes the local file and
skips the parse when it matches. Measured on a stock table: **14 ms** for the
no-op, **663 ms** for a full parse and replace.
- **A `PARSER_VERSION` bump counts as drift**, and so does an `EXTRACTOR_VERSION`
bump on the shard — same argument at the other end of the wire: a corrected
reader must reach an install whose client never patches.
### Two ways a refresh is refused
**A corrupt file** — the realistic failure for any single source — makes the
parser fail on a truncated record rather than yield a plausible-but-short table,
so it is caught outright. Verified: a file truncated to half its length reports
```
code: TRUNCATED
reason: Truncated record header at byte 2486759 (74909 entries read)
```
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.
**An OVERLAY 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 overlay(s) are missing;
the existing table is unchanged
missingSources: ["custom/uomysticmoon.tsv"]
```
**The base is deliberately exempt from that question**, and that is an upgrade
detail worth stating: an install that used the converted-file pipeline carries its
base file's label in the stored fingerprint, and on the bridge that label is
*supposed* to disappear. Counting it as a vanished source would make the first
import after the upgrade demand an approval for a change the upgrade itself made.
**A client patched mid-import** is refused outright rather than staged, because
there is nothing to decide: every page echoes the source file's size and mtime, and
if they move between pages then half of what arrived came from a file that no
longer exists and nothing later can tell which half.
```
status: unavailable
code: SOURCE_CHANGED
reason: The shard's cliloc file changed while it was being read; nothing was imported
```
`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
| | |
|---|---|
| Entries in a stock `Cliloc.enu` | **123,490** |
| Of those, empty strings | **55,994** (ids the client reserves and never uses) |
| Stored in `shard_clilocs` | **67,496** |
Blank entries are dropped, and since protocol 8 they are dropped **on the shard**,
before they reach the wire. A row resolving to no name is indistinguishable from
no row at all to every caller, so sending 56,000 of them would double the
transfer for data discarded on arrival. The site still filters at import, because
an overlay file can carry one and because the file pipeline still exists.
That the shard's own decoder lands on **exactly 67,496** is also the strongest
check there is that the ported decompressor is correct: the number was measured
first through UOFiddler's `Ultima.dll` against this same client, by an entirely
different implementation.
`text` is `TEXT`, not `VARCHAR`: the long property descriptions reach 12 KB, and
silently truncating them would be worse than storing them. The index that matters
for marketplace search is on the denormalized `shard_vendor_items.display_name`,
not here.
## How names are applied
**Resolution happens server-side.** The table is never served *as* a table and
there is no public route for it. Two reasons: 67k rows would dwarf any page that
used them, and the Android client consumes the same JSON and would otherwise need
its own copy.
`resolveMany()` takes a batch of ids and returns a `Map` holding only those that
resolved to something displayable, so "no such id" and "id with no usable name"
collapse into one branch at the call site. It never throws — a cliloc lookup is
decoration on someone's character sheet, and a database blip must not fail the
sheet. A capped in-process cache fronts it; measured cold **4.2 ms**, warm
**0.015 ms**.
### `displayText()`
Cliloc strings interpolate arguments the client pulls from an item's property
list — `~1_val~`, `~2_NAME~`. **We never have those**: the bridge sends the id,
not the packet. So a name carrying them is reduced to what is actually knowable.
| Raw | Displayed |
|---|---|
| `quarter staff` | `quarter staff` |
| `cold damage ~1_val~%` | `cold damage` |
| `[~1_stuff~]` | *(nothing — the whole string was the argument)* |
| `50%` | `50%` |
| `Runic Gateway Sigil (v2)` | `Runic Gateway Sigil (v2)` |
**Punctuation is only tidied when a placeholder was actually removed.** The
trailing `%` in row two is the unit belonging to the number we never had, and the
brackets in row three only ever wrapped the argument — but a string with no
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
- **Character sheet equipment.** `enrichCharProfile` attaches `clilocName` to each
item. A player-given `name` always wins — "Bob's lucky axe" must not be
relabelled "hatchet" — and the client re-states that precedence.
- **Reward titles.** `titles.rewardResolved` is a parallel array with the numeric
entries turned into words (`null` where nothing resolved). The sheet used to
*skip* numeric reward titles entirely, having no way to render them.
- **Marketplace listings** (Protocol 3.0 §8) denormalize the resolved name into
`shard_vendor_items.display_name` so search can index it.
## Admin surface
All admin-only, and driven from **Admin → Client Files** — one page over the
cliloc table, the creature catalogue and item art, since all three are read from
the same client install (docs/link/v8.md §12.2):
| Route | Purpose |
|---|---|
| `GET /api/v1/admin/shard/clilocs` | Which source is in use (`bridge` / `file`), the shard's client-file fingerprint, what each source contributed at the last import, drift, entry count, `missingSources` |
| `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 overlay |
| `PUT /api/v1/admin/shard/clilocs/path` | Set the overlay path (and, with no shard link, the base file's); blank clears it |
**Import is now the only thing that refreshes the table on a bridged install**,
since boot no longer asks the shard. It is the button an operator presses after
patching their client.
A refresh **result is not an exception**, and protocol 8 widened the set of things
that covers: a shard that is down, an asset plane the operator has switched off
(`Bridge.AssetsEnabled`), a client with no cliloc file, a client patched halfway
through the import, plus everything the file pipeline could already report. Each
answers `200` with `status: "unavailable"` and a reason naming what to fix. A
`500` would say only "something broke". Setting the path deliberately does **not**
import as a side effect — the response carries the refreshed status so the panel
can offer that as the next step.
The sidecar's own statuses are worth knowing when reading a log: **425** is the
shard saying it is busy with another asset request (flow control, and the ordinary
answer mid-import — the site retries), **403** the asset plane switched off, **404**
a client with no such file, **422** a file it has and cannot decode.