Files
docs/website/CLILOCS.md
wtclaude ee0c146d7a 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>
2026-07-29 06:46:43 -05:00

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.StringList implements only the plain layout, so on a modern client VendorSearch.StringList is null and VendorSearch.GetItemName returns item.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_VERSION bump 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. 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 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.