diff --git a/link/v3.md b/link/v3.md index 7f108e6..362718d 100644 --- a/link/v3.md +++ b/link/v3.md @@ -762,7 +762,25 @@ shapes are the plain binary layout and a `numbertext` export; the site and writes the plain form. A shard that never converts is fully supported — names render as ids, exactly as before. -Three traps found by building it, all recorded in `CLILOCS.md`: +**Shards edit items and add new ones**, and those carry ids no stock client table has — so this reads +a **set** of sources, not one file, hash-gated together and re-read on every boot exactly as §6 reads +the ServUO tree: a base (the converted client table) plus every overlay under `custom/`, later +winning. Adding one custom item therefore never means re-exporting a 5 MB client file. Measured on +the live shard for scale: its script tree references **16,434** cliloc ids and only **37** are absent +from stock — tens against a 67k base, which is why an overlay and not a second table. `custom/` is the +one convention here that is ours rather than the shard's, because **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. + +That set also brings back a hazard a single file did not have, and §8.6 answers it the way §6 does. A +corrupt source fails the parse loudly, but a source that has **vanished** parses perfectly and imports +a table quietly missing everything it contributed — an unmounted volume is indistinguishable from a +deliberate deletion. So it is **staged, not applied** (`status: 'needsReview'`), reported by both the +import and `status()`, and accepted with `{approve:true}`. It is a flag rather than §6's +approve/reject pair because the atlas stores a pending decision *so that approving re-parses*; here +nothing is stored, so re-reading at approval time is automatic. + +Five traps found by building it, all recorded in `CLILOCS.md`: - **`StringList.SaveStringList` RE-COMPRESSES on save.** It looks exactly like the export path and is not; its output is byte-identical to its compressed input, because its purpose is round-tripping a @@ -773,6 +791,12 @@ Three traps found by building it, all recorded in `CLILOCS.md`: entries vanished, and the import still looked successful. - **`Number('')` is `0`, not `NaN`.** A line starting with a separator imports as a bogus cliloc 0 unless the empty field is rejected explicitly. +- **Tidying punctuation unconditionally corrupts real names.** Stripping leftover brackets is right + after a placeholder is removed (`[~1_stuff~]` → nothing) and wrong otherwise: a shard's custom + `"Runic Gateway Sigil (v2)"` rendered as `"(v2"`. Same shape as the `%` rule. **Found only by + running a shard-style overlay through it** — every stock-table fixture passed. +- **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. Blank entries are dropped at import (123,490 parsed → **67,496** stored), which also makes the binary and text paths converge on identical content. diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index ba13de7..3172b92 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -501,10 +501,19 @@ marketplace listing — but with no table to resolve it against, the character s Import-owned and all-or-nothing in one transaction, same contract as the atlas — including **`DELETE`, not `TRUNCATE`**, for the same reason. -**Sourced from a file the operator converts once from their own UO client**, at a path from the -`cliloc_client_path` setting falling back to `UO_CLIENT_PATH`. Nothing client-derived is committed: -UO's strings are EA's, exactly as the creature sprites are. A shard with nothing configured is fully -supported — names render as ids. Full design and operator guide: [`CLILOCS.md`](CLILOCS.md). +**Sourced from files the operator supplies**, at a path from the `cliloc_client_path` setting falling +back to `UO_CLIENT_PATH`. Nothing client-derived is committed: UO's strings are EA's, exactly as the +creature sprites are. A shard with nothing configured is fully supported — names render as ids. Full +design and operator guide: [`CLILOCS.md`](CLILOCS.md). + +**It reads a SET of sources, not one file**, because shards edit items and add new ones and those +carry cliloc ids no stock client table has. A base (the converted client table) plus every overlay +under `custom/` are re-read on every boot and hash-gated **together**, exactly as the atlas re-reads +`Regions.xml` + `Locations/*.xml` + `Spawns/*.xml` + `ChampionSpawns.xml`. Later sources win, so an +overlay both adds ids and overrides stock ones, and adding one custom item never means re-exporting a +5 MB client file. Scale, measured on the live shard: its script tree references 16,434 cliloc ids and +only 37 are absent from stock — tens of entries against a 67k base, which is why this is an overlay +and not a second table. The conversion step is not avoidable: **every current client ships its cliloc files compressed** (first DWORD's high byte `0x8E`), and ServUO's own bundled `Ultima.StringList` cannot read that @@ -519,11 +528,15 @@ Three decisions worth stating: table is empty strings for ids the client reserves and never uses; a row that resolves to no name is indistinguishable from no row at all, and dropping them makes the binary and text imports converge on identical content. -- **No staged-approval flow, unlike the atlas.** The atlas escalates facet loss because a half-copied - tree and a real map change are indistinguishable from inside the process. A cliloc file is one file - with one hash, and a partial copy makes the parser fail on a truncated record — the ambiguity the - atlas must escalate is one this parser simply detects, so it refuses the import and leaves the - previous table serving. +- **Two refusals, one of them the atlas's.** A corrupt source fails the parse on a truncated record, + so it is caught outright and leaves the previous table serving. But a source that has **vanished** + parses perfectly and imports a table quietly missing everything it contributed — an unmounted volume + and a deliberate deletion are indistinguishable from here, which is precisely the ambiguity the + atlas stages a facet removal for. So it is escalated: `status: 'needsReview'`, nothing applied, + `missingSources` reported by both the import and `status()`, and an admin accepts it with + `{approve:true}`. That is a flag rather than the atlas's approve/reject pair because the atlas + stores a pending decision so that approving **re-parses** the tree; here nothing is stored, so + re-reading at approval time is automatic. **Resolution is server-side and there is no public route.** The table is never served *as* a table: 67k rows would dwarf any page using them, and the Android client consumes the same already-resolved @@ -786,9 +799,9 @@ file a route sits in — that is the property the route manifest freezes. | POST | `/shard/atlas/import` | re-import without restarting; `{force}` ignores the hash gate. **An unreadable tree answers 200 with `status:"unavailable"`, not 500** — `refresh()` reports outcomes rather than throwing (the boot path must never be blocked by a bad tree) and that contract is preserved at the API. | | POST | `/shard/atlas/approve` · `/shard/atlas/reject` | answer a refresh staged because it would REMOVE a facet. Approving **re-parses** the tree, so what lands matches it at approval time; rejecting is remembered against those source hashes so it does not re-prompt every restart. 404 when nothing is staged. | | PUT | `/shard/atlas/path` | point the atlas at a different tree (persisted as `spawn_atlas_servuo_path`, which wins over `SERVUO_PATH`). Blank clears it. Deliberately **does not import** — moving the mount and reloading the world are separate decisions — and returns fresh status so the panel can offer the import next. | -| GET | `/shard/clilocs` | cliloc-table status (`adminOnly`): the configured path, the file actually resolved (the path may be a directory), readability, drift against what is loaded, and the entry count. `configured:false` is a supported state — item names then render as ids. No public counterpart: the table is never served *as* a table. | -| POST | `/shard/clilocs/import` | reload after a client patch; `{force}` ignores the hash gate. **A missing file — or the likely mistake of pointing at the client's own COMPRESSED `Cliloc.enu` — answers 200 with `status:"unavailable"` and a `code`, not 500.** `COMPRESSED` is called out by name: a 500 would say only "something broke", and the operator needs to be told which file to convert. | -| PUT | `/shard/clilocs/path` | point the site at a different cliloc file or directory (persisted as `cliloc_client_path`, which wins over `UO_CLIENT_PATH`). Blank clears it. Deliberately **does not import**, same reasoning as the atlas path. | +| GET | `/shard/clilocs` | cliloc-table status (`adminOnly`): every source found now (base first, then `custom/` overlays in merge order), what each contributed at the last import, readability, drift across the set, the entry count, and `missingSources`. `configured:false` is a supported state — item names then render as ids. No public counterpart: the table is never served *as* a table. | +| POST | `/shard/clilocs/import` | reload after a client patch or an overlay edit; `{force}` ignores the hash gate, `{approve}` accepts a **vanished** source (refused by default — see the table notes above). **A missing path — or the likely mistake of pointing at the client's own COMPRESSED `Cliloc.enu` — answers 200 with `status:"unavailable"` and a `code`, not 500.** `COMPRESSED` is called out by name: a 500 would say only "something broke", and the operator needs to be told which file to convert. | +| PUT | `/shard/clilocs/path` | point the site at a different cliloc base file or directory (persisted as `cliloc_client_path`, which wins over `UO_CLIENT_PATH`). Overlays are read from `custom/` beside it either way. Blank clears it. Deliberately **does not import**, same reasoning as the atlas path. | Every admin write logs to `activity_log`. diff --git a/website/CLILOCS.md b/website/CLILOCS.md index 3e97f43..e0747f5 100644 --- a/website/CLILOCS.md +++ b/website/CLILOCS.md @@ -80,21 +80,74 @@ dotnet run -- "/Ultima.dll" "/Cliloc.enu" /srv/uo-data/cli 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: + +``` +/ + 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 converted file, the setting winning over the -environment: +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 file itself or a directory to search**, because both are -natural answers to "where is it". 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`, `cliloc.plain.enu`, `cliloc.enu.plain`, -`clilocs.txt`, `cliloc.enu`. +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 @@ -120,22 +173,44 @@ Identical in shape to the spawn atlas, and for the same reasons: - **A `PARSER_VERSION` bump also counts as drift**, so a corrected parse reaches an install whose client never patches. -### Why there is no staged-approval flow +### Two ways a refresh is refused -The atlas stages a refresh that would *remove a facet*, because a half-copied -tree and a real map change are indistinguishable from inside the process. A -cliloc file is one file with one hash, and its realistic corruption — a partial -copy — makes the parser fail on a truncated record instead of yielding a -plausible-but-short table. **The ambiguity the atlas has to escalate to a human is -one this parser can simply detect**, so it refuses the import and leaves the -previous table serving. Verified: a file truncated to half its length reports +**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 67,496 rows already loaded are untouched. +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 @@ -182,11 +257,15 @@ not the packet. So a name carrying them is reduced to what is actually knowable. | `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)` | -The trailing `%` in row two is dropped **because a placeholder was removed** — it -is the unit belonging to the number we never had. Row four shows why that -condition matters: stripping `%` unconditionally would corrupt a string that -legitimately ends in one. +**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 @@ -205,8 +284,8 @@ All admin-only, alongside the atlas under Admin → Shard: | Route | Purpose | |---|---| -| `GET /api/v1/admin/shard/clilocs` | Path, resolved file, readability, drift, entry count | -| `POST /api/v1/admin/shard/clilocs/import` | Reload after a client patch; `{ "force": true }` reimports an unchanged file | +| `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