Files
docs/website/CLILOCS.md
wtclaude afcdb373ec docs(website): an operator runbook for extracting from your own UO client
CLILOCS.md and SPAWN_ATLAS.md each explain WHY the operator has to supply
something out of their own client, but neither says how. UOFIDDLER.md is the
missing procedure: where to get UOFiddler, which two files in the zip matter,
which runtime it needs, where Cliloc.enu actually lives, the conversion, how to
point the site at the result, and how to confirm it took.

Verified end to end on a stock Windows box: UOFiddler 4.22.2 (Ultima.dll is
net10.0), .NET SDK 9.0.312 building the net8.0 converter, RollForward carrying
it onto runtime 10.0.8, and the site's own parser reading the output back.

Corrects one claim while doing it. CLILOCS.md said a UOFiddler GUI export
"works equally well"; it does not. Its Cliloc tab writes `Number;Text;Flag` --
three columns, flag LAST -- and parseClilocText splits on the first separator
only, so the flag is absorbed into the name and every item renders as
`quarter staff;0`. The parser already handles `number,flag,text` with the flag
in the middle, but a trailing `;0` is indistinguishable from a name that
genuinely ends that way, so this stays a documented `sed` on the operator's
side rather than a heuristic that would corrupt real names.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 03:34:29 -05:00

310 lines
15 KiB
Markdown

# Cliloc table (item and title names)
**Status:** Complete on `edge` — website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70).
**Design:** [`docs/link/v3.md` §8.6](../link/v3.md) — 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`](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
> **Step-by-step operator instructions — where to get UOFiddler, where your
> client files are, and how to verify the import — are in
> [`UOFIDDLER.md`](UOFIDDLER.md).** This section covers the formats and the
> reasoning behind them.
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](https://github.com/polserver/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).
```bash
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 too, but **not unmodified**: its Cliloc tab writes
`Number;Text;Flag` — three columns, the flag *last* — and the parser reads
`number<separator>text`, so the trailing field is absorbed into the name and
every item renders as `quarter staff;0`. Stripping it is one `sed`, given in
[`UOFIDDLER.md`](UOFIDDLER.md) §Route B.
The parser already tolerates `number,flag,text`, with the flag in the *middle*.
It is not extended to cover the trailing form because a final `;0` is
indistinguishable from a name that genuinely ends that way — a heuristic there
would corrupt real names to save the operator one command.
## 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:
```json
"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.