Files
docs/website/CLILOCS.md
wtclaude bbd69a8e2e docs(link): clilocs come over the bridge now, and UOFiddler's first job is gone
Phase 2 of the Asset Bridge is built, so the documentation stops telling an
operator to install a GUI tool.

`v8.md` gains §9.1 and §9.2 — what the port cost, what it measured, and where the
base table comes from now. The measurement worth keeping: **67,496 rows in
290 ms**, which is exactly what UOFiddler's own `Ultima.dll` produced from this
same client through the converter this phase deletes. An independent
implementation agreeing to the row is the strongest check available that a format
decoder is correct, and it is not something a subtly-wrong one produces.

§17 records the four shapes the org lead settled before any of it was written.
Two departed from the recommendation: **the bridge always wins** (no source
setting — there is no version of that question an operator benefits from
answering) and **import is admin-triggered** (boot does not call the shard at
all).

`CLILOCS.md` is rewritten around that: where the table comes from, what arrives
and in how many pieces, the refusals — including the two the file pipeline had no
equivalent of (a client patched mid-import, and the base's exemption from the
vanished-source rule, which exists so an upgraded install is not asked to approve
a change the upgrade itself made).

`UOFIDDLER.md` loses Part 1 entirely rather than having it rewritten. What is
left is creature art, which phase 5 takes, after which the page goes away. `v3.md`
§8.6 keeps its reasoning with a note saying what superseded it, because the
argument for why the manual step existed is still the argument for why this was
worth building.

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

17 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 → Shard → Import is the button. 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, alongside the atlas under Admin → Shard:

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.