feat(shard): resolve cliloc names for items and reward titles #115
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/cliloc-table"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Protocol 3.0 §8.6 (
docs/link/v3.md) — the dependency order 5 was sequenced behind, landing ahead of the marketplace so/site/marketships with real item names. Docs in docs #70.Items on the wire carry a
LabelNumber, not a name. The bridge has always sent it —char.profile.equipment.cliloc, reward titles as a cliloc number in string form, and one per marketplace listing — but the site had no table to resolve it against, so a character sheet could only renderid 1023721where the game renders "quarter staff". The number was never the missing piece; the table was.⚠️ §8.6's recommendation was not implementable, and the reason is worth a reviewer's attention
v3.md recommended
scripts/buildClilocs.jsreading the client'sCliloc.enuinto a committeddb/data/clilocs.json. Both halves turned out to be wrong.Every current client ships its cliloc files COMPRESSED. All eight
Cliloc.*files in the client on this machine open with a DWORD whose high byte is0x8E— the "Mythic" container. The plain layout (02 00 00 00 01 00, then{int32 number, byte flag, uint16 length, UTF-8}) is what those files looked like before that change, and parsing one as the other does not fail cleanly: it yields 19,508 "records" with only 1,722 distinct ids, ids ranging into the negatives, one 62 KB "string", and a truncation somewhere in the middle.Two facts closed off the alternatives:
Ultima.StringListimplements only the plain layout, so on a modern clientVendorSearch.StringListis null andVendorSearch.GetItemNamereturnsitem.Name. The shard could not supply names on our behalf — the in-game Vendor Search gump has the same gap. (This also retires an open cost in §8.2: the mandatory "never callGetItemNamein the sweep" costs us nothing we could otherwise have had.)⇒ the operator converts once, from their own client, and the site reads the result — the §6 spawn-atlas pattern: parse on boot from a configured path, hash-gated, output gitignored. A shard that never converts is fully supported; names render as ids exactly as before.
What landed
utils/clilocParse.js— pure parsers, fs-free so the suite runs in CI where there is no client. Accepts the plain binary layout and delimited text, sniffed by header rather than extension (operators name these things whatever they like).displayText()drops the~1_val~arguments the bridge never sends — it sends the id, never the property packet.utils/clilocSource.js— the fs layer.hashSourcereportscompressedso the admin panel can flag an unconverted file without parsing 5 MB on every status poll; without it, pointing at a client directory reports a perfectly readable file with pending drift — "ready to import" — and the operator only learns otherwise when the import fails. (I shipped that bug first and caught it on the live server; the check is four bytes of a buffer already in hand.)model/shardClilocs— refresh/status/lookup. All-or-nothing replace:DELETE, notTRUNCATE(TRUNCATE is DDL in MariaDB and implicitly commits). Batched server-side resolution behind a capped cache;resolveManynever throws, because a cliloc lookup is decoration on someone's character sheet.tools/cliloc-export/— a small .NET console app driving UOFiddler'sUltima.dll. Loads it reflectively (UOFiddler is net10.0; a project reference fails at compile time with CS1705) and writes the plain records by hand, becauseStringList.SaveStringListlooks exactly like the export path and re-compresses on save — its output is byte-identical to its input.Two parser bugs found by building it, both now covered by tests
1005008<TAB>; trimming eats the trailing separator, leaving a bare number that then looks like a header row.Number('')is0, notNaN, so a line starting with a separator imported as a bogus cliloc 0. Caught by a test I wrote for a different reason.How it was tested
Verified against the real 123,490-entry client table and the live MariaDB, not just unit tests.
Import and lookup
"max = "— whichdisplayTexttrims anyway; noted in CLILOCS.md as the reason the binary form is recommended.)TRUNCATED,"Truncated record header at byte 2486759 (74909 entries read)"— and the 67,496 rows already loaded are untouched. This is the guarantee that replaces the atlas's approval flow.1023721 → "quarter staff",1015012 → "Greater Heal",1060404 → "cold damage"(placeholder and its orphan%stripped),1049644 → null(the string was nothing but arguments).Boot, on a real server start — both paths confirmed in the log, neither blocking startup:
All three admin routes over HTTP with a real session:
401unauthenticated; status reportingdrift/count; import returningunchangedthenimportedunderforce;PUT path→ the unconverted client directory correctly reportingcode: "COMPRESSED",drift: nullrather than "ready to import".Suites —
629 server tests pass(22 new inclilocParse.test.js). Client builds clean.swagger-output.json,routes.manifest.jsonandroutes.guards.jsonregenerated and committed.Not covered by an automated test: the character sheet renders resolved names in presentational React with no DOM test harness in this repo (the client suite covers pure-logic modules only). It builds clean, but was not rendered against a live linked-player profile — that needs a logged-in player with a linked game account and a shard answering a profile RPC.
Checklist
AI-assisted contributions (required)
Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with aCo-Authored-Bytrailer.License
Update — shard-added and shard-edited items (second commit)
Review feedback: the table has to be updatable the way the spawn atlas is, because shards edit items and add new ones — those carry cliloc ids no stock client table has.
Reading exactly one converted file meant re-exporting 5 MB every time an operator added one item. That is friction enough that the table goes stale, which is the exact failure Part C was redesigned to avoid.
So it now mirrors
spawnAtlasSource.readSources(): a base (the converted client table) plus every overlay undercustom/, all re-read on every boot and hash-gated as a set. Later sources win, so an overlay both adds ids and overrides stock ones. Adding, editing or removing any overlay counts as drift.custom/is the one convention here that is ours rather than the shard's, deliberately. 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, so there is nothing to discover. (An operator who does patch their client cliloc needs no overlay: convert the patched file and the edits are in the base.)Scale, measured on the live shard: its script tree references 16,434 cliloc ids and only 37 are absent from stock — tens against a 67k base, which is why this is an overlay and not a second table.
The set brings back the atlas's ambiguity, and gets its answer
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. That is precisely what the atlas stages a facet removal for, so it is staged here too:
status: "needsReview", nothing applied,missingSourcesreported by both the import andstatus(), accepted with{"approve": true}.It 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. (This replaces the "why there is no staged-approval flow" reasoning in the original description — that argument held for a single file and does not survive multiple sources.)
Import and status also report a per-source breakdown, which is how an operator confirms an overlay took effect —
overrode: 0on a file meant to re-label stock items says it did not.Two bugs this surfaced
Both found by running a shard-style overlay rather than another stock-table fixture:
displayTexttidied punctuation unconditionally, so 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.CANDIDATE_NAMESomittedclilocs.plain— the exact filename CLILOCS.md and the export tool's README tell operators to write. Pointing at the directory they were told to create failed withNO_FILE.How the update was tested
Full walk against the live MariaDB and a real server boot: base-only import → overlay adding one id and overriding another (breakdown correct:
added: 1, overrode: 1) → unchanged set as a no-op → edited overlay re-importing and withdrawing its override → vanished overlay refused with the table intact →statusreportingmissingSources→approveapplying it → a file-path configuration still finding overlays beside it. TheneedsReviewwarning was also confirmed on a real boot log.Through the running server, all three resolve correctly:
646 server tests pass (16 new in
clilocSource.test.jsdriving real temp directories — which file wins, what a listing yields, what happens when one vanishes are exactly the behaviours a mock would define away; 3 new inclilocParse.test.js). Artifacts regenerated.Protocol 3.0 §8.6 (docs/link/v3.md), the dependency order 5 was sequenced behind. Items on the wire carry a LabelNumber, not a name — the bridge has always sent it (char.profile.equipment.cliloc, reward titles as a cliloc number in string form, and one per marketplace listing) but the site had no table to resolve it against, 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. Sourced from a file the operator converts once from their own 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, as they did before. The conversion step is not avoidable, and that is the substantive finding here: every current client ships its cliloc files COMPRESSED (first DWORD's high byte 0x8E, the Mythic container), and ServUO's own bundled Ultima.StringList cannot read that either — so VendorSearch.GetItemName is already inert on such a shard and the plugin could not supply names instead. v3.md's original "read the client's Cliloc.enu" recommendation was therefore not implementable as written, and its committed db/data/clilocs.json artifact also predates the Part C corrections (no committed derived snapshots, nothing EA-derived shipped). Replaced with the spawn-atlas pattern: parse on boot from an operator-configured path, hash-gated, output gitignored. - utils/clilocParse.js — pure parsers, fs-free so the suite runs in CI. Accepts the plain binary layout and delimited text, sniffed by header rather than extension. Rejects a compressed file BY NAME: without that check the plain parser reads it as ~19k records of negative ids and 60 KB "strings" before dying mid-file, and the resulting error names the wrong problem. displayText() drops the ~1_val~ arguments the bridge never sends. - utils/clilocSource.js — the fs layer. hashSource reports `compressed` so the admin panel can flag an unconverted file WITHOUT parsing 5 MB per poll; otherwise pointing at a client directory reports a healthy file with pending drift ("ready to import") and the operator only finds out on failure. - model/shardClilocs — refresh/status/lookup. All-or-nothing replace (DELETE, not TRUNCATE — TRUNCATE is DDL in MariaDB and implicitly commits). Batched server-side resolution behind a capped cache; never throws, because a cliloc lookup is decoration on a character sheet. - Deliberately 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, whereas a partial cliloc copy makes the parser fail on a truncated record. The ambiguity the atlas must escalate is one this parser simply detects. - 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 resolved JSON. Two parser bugs found by building it, both now covered by tests: trimming a text line before splitting ate the trailing separator on empty-text entries and silently dropped 55,994 of 123,490 while still reporting success; and Number('') is 0, not NaN, so a line starting with a separator imported as a bogus cliloc 0. Verified against the real client table (123,490 entries) and the live MariaDB: import 663 ms, hash-gated boot no-op 14 ms, cold resolve 4.2 ms / warm 0.015 ms. Binary and TSV imports converge on the same 67,496 rows with identical keys (blank entries — half the table — are dropped at import). A file truncated to half its length is refused with TRUNCATED and leaves the previous table serving. Boot logs verified for both the import and the compressed-file warning; neither blocks startup. All three admin routes exercised over HTTP with a real session. 629 server tests pass; client builds clean; swagger, routes.manifest.json and routes.guards.json regenerated. Not covered by an automated test: the character sheet renders resolved names in presentational React with no DOM test harness in this repo, and was not rendered against a live linked-player profile — that needs a logged-in player with a linked game account and a shard answering a profile RPC. Co-Authored-By: Claude <noreply@anthropic.com>Shards edit items and add new ones, and those carry cliloc ids no stock client table has. Reading exactly one converted file meant an operator had to re-export 5 MB every time they added one item — friction enough that the table would simply go stale, which is the failure the spawn atlas was redesigned to avoid in the first place. So this mirrors spawnAtlasSource.readSources(): a BASE (the converted client table) plus every operator-maintained overlay under `custom/`, all re-read on every boot and hash-gated as a SET. Later sources win, so an overlay both adds ids the client never had and overrides stock ones the shard re-purposed. Adding, editing or removing any overlay counts as drift. `custom/` is the one convention here that is ours rather than the shard's, and deliberately so: 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. There is nothing to discover. (An operator who does patch their client cliloc needs no overlay: convert the patched file and the edits are in the base.) Scale, measured on the live shard: its script tree references 16,434 cliloc ids and only 37 are absent from stock — tens against a 67k base, which is why this is an overlay and not a second table. The set brings back a hazard a single file did not have, and it gets the atlas's answer. 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 (`needsReview`), reported by both the import and status(), and accepted 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; here nothing is stored, so re-reading at approval time is automatic. Also reports a per-source breakdown (entries/added/overrode) on import and in status, which is how an operator confirms an overlay took effect — "overrode: 0" on a file meant to re-label stock items says it did not. Two bugs this surfaced, both found by running a shard-style overlay rather than by another stock-table fixture: - displayText tidied punctuation unconditionally, so 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. - CANDIDATE_NAMES did not include `clilocs.plain`, which is the exact filename CLILOCS.md and the export tool's README tell operators to write. Pointing at the directory they were told to create failed with NO_FILE. Verified end to end against the live MariaDB and a real server boot: base-only import, overlay adding one id and overriding another (per-source breakdown correct), unchanged set as a no-op, an edited overlay re-importing and withdrawing its override, a vanished overlay refused with the table intact, status reporting missingSources, approve applying it, and a file-path configuration still finding overlays beside it. All three resolve correctly through the running server: shard-added, overridden and stock. 646 server tests pass (16 new in clilocSource.test.js, 3 new in clilocParse.test.js); swagger, routes.manifest.json and routes.guards.json regenerated. Co-Authored-By: Claude <noreply@anthropic.com>