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
18 KiB
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 (the table itself, Protocol 3.0) and
docs/link/v8.md §9 (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. 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:
"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 ofCliloc.enuplus the shard'sEXTRACTOR_VERSION. Asha256ofnullwithhashing: truemeans 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_VERSIONbump counts as drift, and so does anEXTRACTOR_VERSIONbump 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.
enrichCharProfileattachesclilocNameto each item. A player-givennamealways wins — "Bob's lucky axe" must not be relabelled "hatchet" — and the client re-states that precedence. - Reward titles.
titles.rewardResolvedis a parallel array with the numeric entries turned into words (nullwhere 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_nameso 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.