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>
14 KiB
Cliloc table (item and title names)
Status: Complete on edge — website #115, docs #70.
Design: docs/link/v3.md §8.6 — Protocol 3.0, the dependency Part B/3 was sequenced behind.
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.
Why the operator has to convert the file
This is the awkward part, and it is not avoidable:
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"
compressed container. The plain layout this site parses is what those files
looked like before that change.
Decompressing it means an inverse-BWT coder with a frequency header — a few hundred lines of bit-level work whose failure mode is plausible-looking garbage rather than an error. The site has no business carrying that at runtime.
Two facts make the alternatives worse, not better:
- ServUO cannot read it either. Its bundled
Ultima.StringListimplements only the plain layout, so on a modern clientVendorSearch.StringListis null andVendorSearch.GetItemNamereturnsitem.Name— usually nothing. The shard cannot supply names on our behalf; the in-game Vendor Search gump has the same gap. - Nothing client-derived may be 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.
So the conversion happens once, on the operator's machine, against their own client, and the site reads the result from a path it is given. A shard that never does this is in a fully supported state: names render as ids, exactly as they did before the table existed.
Converting
Either format below is accepted; the site sniffs which one it was handed.
| Format | Fidelity | Notes |
|---|---|---|
| Plain binary (recommended) | Exact | 6-byte header, then {int32 number, byte flag, uint16 length, UTF-8} records |
| Delimited text | Loses leading/trailing whitespace | number<TAB|,|;>text per line; a header row, blank lines and # comments are ignored |
The whitespace caveat is real but cosmetic: ~1,300 of the 123,490 entries in a
stock Cliloc.enu are label prefixes like "max = " whose trailing space is
meaningful when the client concatenates a value onto them. Nothing on this site
concatenates, and every consumer passes through displayText(), which trims.
Using the bundled tool
server/tools/cliloc-export/ is a small .NET console app that drives
UOFiddler's Ultima.dll — the
decompressor that already exists and is already maintained — and writes the plain
format. It loads that DLL reflectively so it compiles against any SDK, and it
writes the records by hand because UOFiddler's own SaveStringList re-compresses
on save (its purpose is round-tripping a file back into the client, so its output
is byte-identical to its input — a trap worth knowing about).
cd website/server/tools/cliloc-export
dotnet build -c Release
# binary (recommended)
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.plain
# or tab-delimited
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv
A UOFiddler GUI export works equally well — anything producing one of the two 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:
"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
Two ways to point at the sources, the setting winning over the environment:
| Source | Notes |
|---|---|
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 |
The value may be the base file itself or a directory to search, because both
are natural answers to "where is it". Overlays are read from a custom/
directory beside the base either way — pointing at a file does not forfeit
them.
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
setting straight at an unconverted client directory finds cliloc.enu, which is
compressed — and the site says so 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
Identical in shape to the spawn atlas, and for the same reasons:
- It never blocks startup. No path, an unreadable file, a wrong-format file, a database error — all caught and logged. The site comes up either way.
- Hash-gated. The boot path hashes the file and skips the parse entirely when it matches what is loaded, which is every restart that did not follow a client patch. Measured on a stock table: 14 ms for the no-op, 663 ms for a full parse and replace.
- A
PARSER_VERSIONbump also counts as drift, so a corrected parse reaches 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.
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
Parsed from a stock Cliloc.enu |
123,490 entries |
| Of those, empty strings | 55,994 (ids the client reserves and never uses) |
Stored in shard_clilocs |
67,496 |
Blank entries are dropped at import. A row resolving to no name is indistinguishable from no row at all to every caller, and dropping them makes the binary and text imports converge on identical content — the binary format carries the blanks explicitly and a text export may or may not, depending on the tool. Verified: both formats import to the same 67,496 rows with the same keys.
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, alongside the atlas under Admin → Shard:
| Route | Purpose |
|---|---|
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 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 |
A refresh result is not an exception: a missing file, or the likely mistake of
pointing at the client's own compressed Cliloc.enu, answers 200 with
status: "unavailable" and a reason. A 500 would say only "something broke";
the operator needs to be told which file to convert. 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.