27 Commits

Author SHA1 Message Date
e1cefa5be2 Merge pull request 'docs(link): the hue belongs where the files are, and the cache poisons it (Phase 5)' (#240) from docs/asset-bridge-p5 into main
Reviewed-on: #240
2026-09-11 11:26:22 +00:00
a4b63d87d2 docs(link): the hue belongs where the files are, and the cache poisons it (Phase 5)
§11.1 is new and carries what phase 5 measured: 49,152 addressable static ids
(not the 81,884 `artidx.mul` declares -- `FileIndex` sizes its table from its
length ARGUMENT), 39,189 with art, 4,244 land tiles, 9,963 + 12,140 empty index
slots, and the whole set at 81 MB decoding in 34 s. That last number reopens the
bulk question and the answer is still no: 108 MB of base64 through a 512 KB
single-slot channel to store 43,433 pictures a shard displays a few hundred of.

Two traps, both §4.5's failure mode -- a confident, plausible, wrong picture:

- `Art.GetStatic` hands back the SAME cached Bitmap and `Hue.ApplyTo` repaints in
  place, so hueing edits the library's own copy: the plain key comes back hued
  from then on, and the next hue stacks. `Files.CacheData` off process-wide fixes
  it and also stops a game server retaining 74 MB of Bitmap. Copying instead does
  not solve the retention, and `new Bitmap(src)` throws on ARGB1555 anyway.

- `PartialHue` (13,259 of 65,536 ids) decides whether a hue repaints every pixel
  or only the grey ones, from a file only the shard has. Item 597 is a wooden
  screen with painted flowers; one mode reddens the flowers, the other the whole
  screen, and both decode. Hence land takes no hue segment and `h0` is not a key.

Plus the namespace trap that compiled: unqualified `TileData` binds to ServUO's
own `Server.TileData`, because the enclosing namespace beats `using Ultima;`.

§14 records what the wire gained -- the `static` and `land` families, `families`
on `assets.sources`, and `assets.fetch` becoming shared plumbing whose family is
DERIVED from the keys (§5 made the key the address; a request naming its family
too would have two places to be wrong and one of them silent). Additive, so the
protocol stays 8 and EXTRACTOR_VERSION stays 2. §15 records that `link` needed
nothing in phases 4 or 5: it forwards verbatim in both directions.

§17.10 is the four org-lead decisions. §12 and modules/uo/SCHEMA.md carry the
website side: `uploads/items/`, per-row `catalog` staleness, and why a key with
no art writes no row at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 06:16:15 -05:00
2590d4cc58 Merge pull request 'docs(link): two of the eight player bodies existed, and 233 nobody asked about (Phase 4)' (#239) from docs/asset-bridge-p4 into main
Reviewed-on: #239
2026-09-11 10:09:39 +00:00
6ef4b06c76 docs(link): two of the eight player bodies existed, and 233 nobody asked about (Phase 4)
Phase 4 built 4.3's UOP animation reader. What it found first changed what the phase
was worth, so the plan is corrected rather than merely annotated.

  - New 4.9: what phase 4 measured. Of the EIGHT player bodies 4.8 assigned this
    phase, two are in the client at all -- gargoyles 666/667, in AnimationFrame3.uop.
    The six ghost bodies are in no package, and that is established rather than
    unfound: the five packages hold 10,724 entries and the
    build/animationlegacyframe/%06d/%02d.bin scheme claims every one, leaving no room
    for another naming. Also the format as read (AMOU, a per-frame ARGB1555 palette,
    direction as a slice of the frame table), the nine bodies whose frame count is not
    a multiple of five, the validate-as-we-go bounds and the measurement that says
    they refuse nothing real, and the live rig.
  - 5.2 rewritten: the player-body set is the LIVING pair per race, six ids not
    twelve. Ghost ids left it because no client has art for any of them. Still asked
    of the shard, never hardcoded -- only the question changed. And with phase 4 in,
    all six have art for the first time.
  - 4.3 rewritten against what was measured, including why searching five UOP packages
    for one body is NOT the never-sweep rule being broken: a legacy index is addressed
    by position, a UOP entry by the hash of a name carrying the body id, which the
    payload then declares again.
  - 11 sizing: the catalogue is 1,022, not 787. The mix is recorded because "add every
    body" sounds like it changes what a catalogue is, and it does not -- the legacy
    787 was already 366 equipment bodies.
  - 14: manifest and fetch rows carry `source` (legacy/uop). Additive, so protocol
    stays 8; EXTRACTOR_VERSION 1 -> 2 is the change consumers actually see.
  - 16 phase 4 marked DONE; 17.9 records the four org-lead decisions (fallback applies
    to every body; ghost ids leave the set; own PNG encoder; NO_IMAGING stays flat).
  - 4.8 and 8.1 keep their numbers as the record of what those phases measured, with a
    pointer to where the answer landed.

Two consumer docs repeated the ghost claim as fact and are corrected:
website/SPAWN_ATLAS.md (787 -> 1,022, and "two thirds of the playable ghost and
gargoyle bodies have no art" -> about half the addressable body range) and
modules/uo/API.md (same sentence).

Code: servuo-plugins#31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 04:59:55 -05:00
afd0772289 Merge pull request 'docs(link): the catalogue is real, and UOFiddler's last job is gone (Phase 3)' (#238) from docs/asset-bridge-p3 into main
Reviewed-on: #238
2026-09-10 23:57:18 +00:00
d07772a3d8 docs(modules): the cliloc conversion step IS avoidable now (phase 2 debt)
SCHEMA.md's cliloc section still described the pre-protocol-8 world, two
phases after phase 2 changed it. Three sentences said the same false thing,
so fixing only the flagged one would have left the section arguing with
itself:

- "Sourced from files the operator supplies" -- the BASE comes from the shard
  on any install with uo-link configured; only the overlays are the
  filesystem's, and that asymmetry has a reason worth stating (ServUO has no
  server-side notion of a custom cliloc, so there is nothing to ask for).
- "A base (the converted client table)" -- not converted any more.
- "The conversion step is not avoidable ... so the shard cannot supply names
  on our behalf" -- it does supply them. Phase 2 ported UOFiddler's Mythic
  decompressor into the overlay precisely so nobody converts anything.

Rewritten to say what is true and why the file path still exists (deprecated,
not removed, so an existing install keeps working), plus the two things a
reader of this table actually needs: `shard_cliloc_meta.payload` keeps the
base's fingerprint under `base` SEPARATELY from the overlay hashes -- because
on the bridge the old `clilocs.plain` label is supposed to disappear and one
flat hash map would read that upgrade as a vanished source -- and boot does
not import on the bridge path at all.

Each claim checked against the code rather than from memory:
shardClilocs.model.js:307 (`base: fingerprint`), :438-440 (refreshOnBoot
returns `skipped` on the bridge), clilocSource.js:333 (`missingOverlays`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 18:50:45 -05:00
1a7481e9f4 docs(link): the catalogue is real, and UOFiddler's last job is gone (Phase 3)
Phase 3 is built and walked on a live shard. What the walk measured, and the
two places the design of record needed correcting:

§8.1, new: the catalogue is 787 exactly as §4.8 predicted, and the whole scan
of bodies 1-2047 takes 734 ms cold -- so the wall-clock paging §11 designed
never fires on this client. Every §4.8/§5.2 prediction held when the bytes were
rendered and LOOKED at: 320, 607, 608 and 666 come back absent rather than as
another creature's picture, and the direction split is 783 at index 1 against 4
at index 0 -- four player bodies, not six.

44 of the 787 hashes are shared by two or three bodies, which is the exact
signature of the wrong-picture bug, so it was chased rather than assumed. It is
the client's own Body.def aliasing (83 {1}, 84 {1}, 106 {12, 59}), and the check
that settles it is at the source: Translate(ref body, ref hue) rewrites `body`
only when bit 31 is set, unlike the one-argument overload -- and ResolveAnimation
calls that same two-argument overload, so validator and decoder resolve the
identical record.

§12.1, new: **§12 is right about the outcome and wrong about the mechanism.**
`shard_spawn_creatures` is emptied and refilled by every atlas refresh, and a
refresh runs on every boot -- so an imported filename written to that row is
destroyed by an ordinary re-parse of the ServUO tree, and the next Update finds
the client files unchanged and never restores it. Three tables outside that
blast radius, and the atlas import re-derives `art` on the way past.

§14: **§16 listed phase 3 as servuo-plugins + module-uo and that was wrong.**
web.rs routes every command explicitly, so `link` is in the phase. Corrected in
both places.

UOFIDDLER.md is DELETED, two phases earlier than §9.1 predicted -- creature art
was the only thing still on it. SPAWN_ATLAS.md §Artwork is rewritten around the
import, keeping the operator's own map as the thing that wins; the module's
SCHEMA.md gains the three tables and API.md the two admin routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 18:41:08 -05:00
4c6b0c566a Merge pull request 'docs(link): clilocs come over the bridge now, and UOFiddler's first job is gone' (#237) from docs/asset-bridge-p2 into main
Reviewed-on: #237
2026-09-10 16:19:35 +00:00
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
df9b3fd990 Merge pull request 'docs(link): phase 1 built the transport, and re-measured the catalogue' (#236) from docs/asset-bridge-p1 into main
Reviewed-on: #236
2026-09-10 15:04:39 +00:00
1a048ae1be docs(link): phase 1 built the transport, and re-measured the catalogue
Asset Bridge phase 1, docs half. Code: RunicGateway/servuo-plugins#28,
RunicGateway/link#41.

## The correction, which is most of this

**New §4.8.** The animation path has §4.5's shared-buffer defect too, and 357 of the
1,144 bodies §1.1 counted are **wrong pictures on a stock client** — ids with `length 0`
that return whichever body was decoded before them. Proved by decoding body 320 after a
dragon (a dragon), a wolf (a wolf) and a human (a human).

So the catalogue is **787 bodies**, and the numbers that were derived from 1,144 move
with it: §11's working set, its ~173,000-frame full set (now ~119,000), phase 3's scope.

**§5.2's table was wrong in the direction that matters.** The elf ghosts were listed as
decoding; their index entry has no record, and what came back was the elf female. Four
of twelve player bodies have art, not six — which takes phase 4's UOP decoder from six
ids to eight.

§1.1 now says outright that every "decoded" count in it is an upper bound. It is not a
table to size anything from any more.

## What phase 1 settled

- **§3.3** — the two numbers: a 512 KiB batch budget under a 1 MiB inbound line cap, with
  the factor of two load-bearing rather than cautious.
- **§3.2** — flow control is enforced **on the shard**, as a single slot answering
  `bridge.busy`, not serialised in the sidecar and not left to the website as a
  convention. Records what it costs: a status poll shares the slot.
- **§3.4, new** — one paging envelope (`more`/`cursor`/`cut`) for all five families that
  will page, defined before the first one needs it. `cut` because "short page" has three
  meanings and only one of them means finished.
- **§6** — hashing had to come off the request path entirely. The gate is unchanged; what
  changed is that "the normal case must cost nothing" now also means "and the abnormal
  case must not time out", because the first hash of 1.06 GB does not fit in 10 s.
- **§14** — which commands exist now, and which phase brings the rest.
- **§16, §17** — phase 1 done; decisions 6 and 7.

## Elsewhere

- **`SHARD_PREREQS.md`** gains the libgdiplus requirement (§4.4) — Linux hosts only, with
  the archived-upstream caveat and the `NO_IMAGING` status the shard now reports on the
  source gate.
- **`INTEGRATION.md`** advertised `X-UOLink-Version: 6`. It was already two versions stale
  before this change; now 8.

- [x] AI-assisted — Claude Code (Opus 5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 08:32:58 -05:00
f6ea5c0484 Merge pull request 'docs(link): phase 0 ran, and the fault it found is not a crash' (#235) from docs/asset-bridge-p0 into main
Reviewed-on: #235
2026-09-10 08:04:13 +00:00
018f1af5ff docs(link): phase 0 ran, and the fault it found is not a crash
Records the Asset Bridge phase 0 spike (§16) against v8.md, and closes the
last open decision.

§4's choice to call ServUO's vendored `Ultima` STANDS: nothing faulted on a
path this protocol calls, and §9's cliloc reader reproduced UOFiddler's
123,490-entry table byte for byte in 218 ms from inside the shard.

But the spike was looking for the wrong kind of failure. `LoadStatic` and
`LoadLand` decode out of a buffer that is reused, only ever grown, and
filled by a `Read` whose return value is discarded — so a short, absent or
out-of-bounds record does not throw, it renders the PREVIOUS asset. On the
stock, unmodified client on this machine that is 22,102 ids whose index
entry reads `lookup 0, length 0`, all of which the library returns a picture
for. §1.1's "32,766 decoded" was counting these.

New §4.5 states the rule that answers it — validate before calling — with
the six checks phase 0 implemented, the eight deliberate defects they caught
(seven of which the library rendered silently, including a verdata lookup
past verdata.mul's own end, which `Verdata.Seek` bounds-checks nowhere), and
the number that makes the boundary defensible: zero false refusals across
49,151 statics and 16,384 land tiles on a clean client.

New §4.6: `FileIndex`'s UOP constructor ends `MulPath = uopPath`, so
`artLegacyMUL.uop` wins outright and `art.mul` is never opened on a current
client. Bounding an offset against the wrong file is not approximate, it is
meaningless — the spike's first run refused 34,299 good statics that way,
and every refusal read like a real finding.

New §4.7: `Ultima.Gumps.GetGump(2)`, called once from inside a running
shard, made the ServUO process disappear — no catch reached, no console
line, the probe's checkpoint file the only record. §4.1's rule is earned.

§17 now has nothing open:

  * item 4 — the default audience — SETTLED: an asset inherits the audience
    of the page that uses it.
  * item 5 is new: validate-before-calling, chosen ahead of the spike over
    a child-process extractor and over reversing §4, and confirmed by it.
    The dangerous failure turns out to be a wrong picture, which no
    containment strategy would have caught.

§16 marks phase 0 done and adds the half it deliberately left unbuilt to
phase 1: the animation path has no validator, and the patched client's wolf
decoded something else in silence to prove it.

Full measurements and the rig recipe live in servuo-plugins
`tools/scaffolding/README.md`; the code is RunicGateway/servuo-plugins#27.

- [x] AI-assisted — Claude Code (Opus 5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 02:59:56 -05:00
f2e074c3f7 Merge pull request 'docs(link): the Asset Bridge (Protocol 8) — client assets without UOFiddler' (#234) from docs/client-assets-v8 into main
Reviewed-on: #234
2026-09-10 07:03:55 +00:00
041e1f4069 docs(link): name it the Asset Bridge, and make libgdiplus a stated requirement
The work has a name now — the Asset Bridge — for commits, PR titles, branches
(feat/asset-bridge-p<n>) and conversation. Protocol number stays 8 and the file
stays docs/link/v8.md.

§4.4 closes the last real open question rather than deferring it to phase 0, and
takes all three answers instead of choosing one. ServUO targets net48, so a Linux
host runs it under Mono, and Mono's System.Drawing is a thin layer over
libgdiplus — which §4.2 put in the decode path, not just the encode. So on Linux
it is a hard prerequisite for art. Cliloc and atlas import are unaffected; neither
touches pixels. Windows hosts need nothing at all.

It is now written down as: a SHARD_PREREQS.md entry, a doctor check in the
installer, and a named NO_IMAGING status when it is missing, in the same family
as the cliloc reader's COMPRESSED — never a stack trace, never a 500. Install
routes per distro are in the section, apt-get install libgdiplus being the
normal one.

One fact recorded because depending on something unmaintained should be a
conscious act: github.com/mono/libgdiplus was ARCHIVED in March 2025 and is
read-only. Distributions still package and patch it, so installing it today is
supported and ordinary — but it is the strongest long-term argument for moving
extraction off System.Drawing eventually, and phase 4's UOP reader is written
without it so that door stays open.

§17 restructured: three settled items kept because each changes numbers
elsewhere, and one genuinely open question (the default audience) that does not
block starting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 02:01:07 -05:00
d39e4163d1 docs(link): a UOP decoder for the player bodies, and the spider that proves we need one
The six player-character bodies the vendored Animations cannot reach are the
player character, and the scope says player models, so they get a decoder rather
than a caveat. New §4.3, new phase 4, scoped as narrowly as possible: a reader
for AnimationFrame*.uop used ONLY for bodies the legacy path cannot resolve.
Everything the vendored code already decodes keeps going through it.

Verified genuinely absent rather than mis-addressed, and the way that was
established is now the most important warning in the document.

Bodyconv.def maps gargoyle 666 to anim5 and BodyConverter.Convert faithfully
returns fileType 5, where this client has nothing. Asking the OTHER anim files
for index 666 does not fail — it returns 175 decodable action/direction
combinations of a giant spider, because something unrelated occupies that index
in anim2.mul, while fileTypes 3 and 4 return misaligned colour fragments. All of
it rendered and looked at, which is the only reason it was caught: every one of
those reads reports success.

So the extractor takes Convert's answer and reports nothing when that yields
nothing. It must never sweep file types looking for a hit. That does not find
missing art — it silently puts a spider on the gargoyle page, with no error
raised anywhere and nothing downstream able to detect it. A "0 rows" outcome is
correct behaviour; a confident wrong picture is the failure this protocol most
needs to avoid.

Phase 4 sits after the catalogue, not inside it: the catalogue is useful with
1,138 of 1,144 bodies, and the UOP reader is the only genuinely new format work
here, so putting it on the critical path would hold up every website-side phase
behind it. Its acceptance test is that a gargoyle looks like a gargoyle, checked
by eye.

References available and license-compatible: ClassicUO's animation loader
(GPL-3) and UOFiddler 4.22 (Beerware).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 01:53:42 -05:00
44039e83d5 docs(link): call ServUO's decoders — the crash is on a branch we never take
Decision: no decoders are reimplemented. Extraction goes through ServUO's own
vendored Ultima, which Scripts.csproj already references, so the art half of
this protocol is plumbing and the Mythic cliloc reader (§9) becomes the only
decoder Protocol 8 writes rather than calls.

What makes that safe rather than merely cheap is a distinction §1.1 did not
draw. All three decoders build a FileIndex, but only Gumps passes
hasExtra: true — and FileIndex.cs's own comment says that branch exists FOR
gumpartlegacy.uop, the one UOP layout with an extra field. Art passes
hasExtra: false and probed 49,150 statics plus 16,384 land tiles with zero
faults; Animations touches no UOP at all and probed 1,144 bodies clean. The
access violation is a bug on a branch exactly one decoder reaches, and that
decoder was already out of scope. So "nothing calls Ultima.Gumps" is now a
safety rule, and adding gump art later means fixing that path first.

§4.2 records the three costs this accepts: six of twelve stock player bodies
have no art (UOP-only, not reachable by calling the existing code differently),
System.Drawing stays in the decode path, and we inherit whatever Ultima a shard
vendors — EXTRACTOR_VERSION already covers the last one.

Phase 0 changes shape with it. It was going to prove new decoders byte-identical;
it now tries to BREAK the vendored ones on purpose, from inside a running ServUO
against a deliberately patched client, because the probes behind §1.1 ran in
PowerShell against a stock client and neither is the real environment.

§17 is down to one real question: libgdiplus on Linux/Mono shards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 01:11:13 -05:00
b62d0b6307 docs(link): player bodies face you, everything else does not
Player character bodies take direction index 0 (head-on); every other body takes
index 1 (front three-quarter). Direction still does not appear in the key — both
indices are extractor configuration.

The split follows the legibility caveat rather than fighting it: a humanoid
reads fine head-on and a character portrait should look at you, while a wolf
seen head-on is a dark blob and the same wolf at index 1 is obviously a wolf.

§5.2 is new: which bodies count as player characters is asked of the shard, via
Race.AllRaces and each race's MaleBody/FemaleBody/MaleGhostBody/FemaleGhostBody,
never hardcoded. Twelve ids on stock 57.4. Two things say a hardcoded list would
have been wrong — RaceDefinitions.cs passes the gargoyle's ghost bodies in the
opposite order to the other two races (695 male, 694 female), and a shard that
calls RegisterRace adds ids no table of ours would contain.

And the finding that matters most: SIX OF THOSE TWELVE do not decode at all with
ServUO's vendored Animations — both human ghosts and every gargoyle body,
because they live in AnimationFrame*.uop which that library never reads. The one
part of the scope with the most attention on it is the part the vendored library
serves worst, which is now the strongest single argument for §4's recommendation
to own the decoders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 01:06:32 -05:00
0c3f0d65cc docs(link): one direction, not five — and it is index 0
Only the viewer-facing direction is wanted for players and monsters, so
direction leaves the key entirely (§5.1) rather than being a segment nobody
varies. Every depth count in §11 falls by exactly 5x.

Which index that is was rendered, not looked up: all five directions for a
human, a wolf and a dragon. Index 0 is head-on — face, chest and front legs —
and index 4 is directly away, with a quadruped's tail toward the camera. The
small-thumbnail version of the same test suggested the opposite, which is why
the finding is in the doc rather than in someone's head.

Measured consequence: body 400 drops 1,050 -> 210 frames, and a complete
one-direction set for all 1,144 bodies is ~173,000 frames (~170 MB) rather than
~865,000. That moves a bulk-fill-everything switch from "never" to something
phase 5 should leave room for.

One caveat kept as an open question: index 0 is the least legible view for
four-legged creatures — a head-on wolf is a dark blob, a side-on wolf is a wolf
— so the extractor takes the index as configuration defaulting to 0, and §17
asks whether the catalogue should default to 1 or 2 instead.

No client-derived image is committed; the render was inspected and discarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 00:47:14 -05:00
556141341f docs(link): Protocol 8 — client assets over the bridge
The shard host already has the UO client — ServUO cannot boot without one, and
Config/DataPath.cfg resolves into Server.Core.DataDirectories at runtime. So the
cliloc table, the creature and item art, and the spawn atlas's own source files
can all reach the website over the bridge that already exists, and UOFiddler,
the desktop conversion step and the website's shared-filesystem view of the
ServUO tree all go away.

Design of record for the work: the shard extracts, the sidecar forwards, the
website decides — which is the only arrangement that keeps the sidecar a dumb
forwarder while still resolving creature slug -> body id, something only code
running inside ServUO can do.

Measured against this machine's ServUO 57.4 tree and client rather than assumed:
Art.GetStatic and GetLand decode ~66,000 ids with no faults, 1,144 bodies have a
decodable first frame, and body 400 alone is 1,050 frames across its actions and
directions — which is what makes the bulk set one thumbnail per body and
everything deeper on demand.

Two findings shape the build. Gumps.GetGump(2) does not fail, it corrupts the
process (AccessViolationException, 0xC0000005) — uncatchable on .NET Framework
4.8 and a shard crash in-process — so §4 recommends owning bounds-checked
decoders rather than calling ServUO's vendored Ultima, which also removes the
System.Drawing/libgdiplus dependency and the UOP gap that leaves gargoyle bodies
666/667 empty. And the shard -> sidecar direction has no line cap today, which
Protocol 8 must close before it starts sending large lines deliberately.

UOFiddler is Beerware, so its Mythic cliloc decompressor can be ported into this
GPL-3.0-or-later tree and the conversion step retired entirely.

Nine phases, four decisions still open in §17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 00:25:25 -05:00
132620e8b3 Merge pull request 'docs(events): the site, the landing page, and the cutover step they found (Phase 16c)' (#233) from docs/events-p16c-site into main
Reviewed-on: #233
2026-09-10 04:28:14 +00:00
81b55ac29b docs(events): the site, the landing page, and the cutover step they found (Phase 16c)
The last leg of the events plan, and the record of what it turned up.

**16c began by closing 16b's missing seventh step.** Six repositories were cut
over and every one showed 0 commits on `edge` that are not on `main`. This one
showed 46: step 6 landed the record *on* `edge` rather than cutting `edge` over,
so `docs` `main` opened `EVENTS.md` with "revision 5. No code written. Read
against ... MODULE_API_VERSION 1.9.0 - sidecar protocol 5" while six repositories
shipped the engine on protocol 7 — and `link/v6.md` and `v7.md` existed on no
default branch anywhere.

Nothing in this workstream could have caught that, and the reason is worth
writing down: every check that guards a contract lives in the repository that
DEPENDS on the contract, and a documentation repository has no dependants. The
one check that reads `docs` from outside is `runicgateway.com`'s
`checkReference.mjs`, and 16c is the only phase that would ever have run it.
Closed by #232.

**The site.** Nine `checkFacts` values and twenty-seven `Bridge.cfg` keys, both
red before the phase started — which is the bargain that repository's §12 struck.
Two pages, the treatment Teams has. Two capability entries, the calendar one
deliberately not `needsModule` because a bare core can author and run an event.
A `deploy-events` privacy row, because the participation ledger is personal data
and nothing named it. And `reference/event-catalog` retitled "Shard event
catalog", route unchanged, because two things in the documentation were called an
event catalog.

**`.profile`.** One bullet saying the posture rather than the feature list, and
two stale numbers: protocol 5 → 7 in the four values the installer prints, and
module-uo v1.1.0 → v1.2.2.

**And the plan is marked COMPLETE.** All seventeen phases on `main`, in every
repository they touch. No contract moves in this phase; it makes the ones already
moved legible from outside the organisation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 22:07:46 -05:00
2db5737f22 Merge pull request 'docs(events): the Event System record — the cutover step 16b missed (edgemain)' (#232) from edge into main
Reviewed-on: #232
2026-09-10 02:54:40 +00:00
eae8a6f93d Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#230) from chore/sync-website-tree into main
Reviewed-on: #230
2026-09-10 01:22:25 +00:00
runic-docs-bot
537c79e5de docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@655fbf3 [skip ci] 2026-09-10 00:44:20 +00:00
abfdcd4658 Merge pull request 'docs(tree): sync android/PROJECT_TREE.md' (#229) from chore/sync-android-tree into main
Reviewed-on: #229
2026-09-10 00:28:24 +00:00
runic-docs-bot
90f95520a3 docs(tree): sync android/PROJECT_TREE.md from RunicGateway/Android-app@5e61ee2 [skip ci] 2026-09-09 20:10:00 +00:00
14 changed files with 1996 additions and 446 deletions

View File

@@ -37,7 +37,6 @@ sidecar as a service, and hands you the values the website needs.
| [MODERATION_APPEALS.md](website/MODERATION_APPEALS.md) | Moderation actions, content reports and the appeals flow |
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
| [CLILOCS.md](website/CLILOCS.md) | UO's id → name table: converting one from your client so items have names |
| [UOFIDDLER.md](website/UOFIDDLER.md) | **Operator runbook** — step-by-step extraction from your own UO client (cliloc table, creature art) |
| [MARKETPLACE.md](website/MARKETPLACE.md) | The player-vendor index: how it is gathered, what it costs, how to tune it |
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
| [test-plan.md](website/test-plan.md) | The website's test strategy and harness |

View File

@@ -79,6 +79,8 @@ android-app/
│ │ │ │ │ │ └── PushTickle.kt
│ │ │ │ │ ├── result/
│ │ │ │ │ │ └── ApiResult.kt
│ │ │ │ │ ├── time/
│ │ │ │ │ │ └── Instants.kt
│ │ │ │ │ ├── web/
│ │ │ │ │ │ ├── WebHandoff.kt
│ │ │ │ │ │ └── WebsiteUrls.kt
@@ -90,6 +92,7 @@ android-app/
│ │ │ │ │ │ │ ├── AdminDto.kt
│ │ │ │ │ │ │ ├── AuthDto.kt
│ │ │ │ │ │ │ ├── ContactDto.kt
│ │ │ │ │ │ │ ├── EventsDto.kt
│ │ │ │ │ │ │ ├── NotificationsDto.kt
│ │ │ │ │ │ │ ├── PageDto.kt
│ │ │ │ │ │ │ ├── PlayerShardDto.kt
@@ -102,6 +105,7 @@ android-app/
│ │ │ │ │ │ ├── AdminApi.kt
│ │ │ │ │ │ ├── AuthApi.kt
│ │ │ │ │ │ ├── AuthRefreshApi.kt
│ │ │ │ │ │ ├── EventsApi.kt
│ │ │ │ │ │ ├── MeApi.kt
│ │ │ │ │ │ ├── NotificationsApi.kt
│ │ │ │ │ │ ├── PlayerShardApi.kt
@@ -117,11 +121,13 @@ android-app/
│ │ │ │ │ ├── ConnectionRepository.kt
│ │ │ │ │ ├── ContactRepository.kt
│ │ │ │ │ ├── ContentRepository.kt
│ │ │ │ │ ├── EventsRepository.kt
│ │ │ │ │ ├── NotificationsRepository.kt
│ │ │ │ │ ├── PlayerShardRepository.kt
│ │ │ │ │ ├── SettingsRepository.kt
│ │ │ │ │ ├── ShardFeaturesRepository.kt
│ │ │ │ │ ├── ShardRepository.kt
│ │ │ │ │ ├── SiteCapabilitiesRepository.kt
│ │ │ │ │ └── WikiRepository.kt
│ │ │ │ ├── di/
│ │ │ │ │ ├── AppModule.kt
@@ -157,6 +163,16 @@ android-app/
│ │ │ │ │ ├── contact/
│ │ │ │ │ │ ├── ContactScreen.kt
│ │ │ │ │ │ └── ContactViewModel.kt
│ │ │ │ │ ├── events/
│ │ │ │ │ │ ├── EventScreen.kt
│ │ │ │ │ │ ├── EventSeriesScreen.kt
│ │ │ │ │ │ ├── EventSeriesViewModel.kt
│ │ │ │ │ │ ├── EventsScreen.kt
│ │ │ │ │ │ ├── EventsViewModel.kt
│ │ │ │ │ │ ├── EventTimes.kt
│ │ │ │ │ │ ├── EventViewModel.kt
│ │ │ │ │ │ ├── MyEventsScreen.kt
│ │ │ │ │ │ └── MyEventsViewModel.kt
│ │ │ │ │ ├── home/
│ │ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ │ └── HomeViewModel.kt
@@ -335,6 +351,7 @@ android-app/
│ │ │ │ │ └── WikiDtoTest.kt
│ │ │ │ └── fake/
│ │ │ │ ├── FakeAdminApi.kt
│ │ │ │ ├── FakeEventsApi.kt
│ │ │ │ ├── FakeNotificationsApi.kt
│ │ │ │ ├── FakePlayerShardApi.kt
│ │ │ │ ├── FakePublicApi.kt
@@ -345,7 +362,8 @@ android-app/
│ │ │ └── repository/
│ │ │ ├── AccountTrustedDevicesTest.kt
│ │ │ ├── ConnectionVersionGuardTest.kt
│ │ │ ── ShardFeaturesRepositoryTest.kt
│ │ │ ── ShardFeaturesRepositoryTest.kt
│ │ │ └── SiteCapabilitiesRepositoryTest.kt
│ │ ├── ui/
│ │ │ ├── admin/
│ │ │ │ ├── AdminContentViewModelTest.kt
@@ -356,8 +374,12 @@ android-app/
│ │ │ │ └── BrandAssetsTest.kt
│ │ │ ├── contact/
│ │ │ │ └── ContactViewModelTest.kt
│ │ │ ├── events/
│ │ │ │ ├── EventsViewModelsTest.kt
│ │ │ │ └── EventTimesTest.kt
│ │ │ ├── navigation/
│ │ │ │ ├── MenuAccessTest.kt
│ │ │ │ ├── MenuCapabilityGatingTest.kt
│ │ │ │ ├── MenuFeatureGatingTest.kt
│ │ │ │ ├── NavOverridesTest.kt
│ │ │ │ ├── NavPathsTest.kt

View File

@@ -57,9 +57,9 @@ The wire protocol is versioned so a mismatch is caught immediately instead of fa
The current version is **6**. It is not released yet — it lives on `edge` and ships with the event system's cutover; the last released pairing is protocol **5**, sidecar **v2.1.0** + overlay **v1.1.0**, resolved as bundle **2026.09.01**, never as "latest of each".
- Every response carries an **`X-UOLink-Version: 6`** header.
- Every response carries an **`X-UOLink-Version: 8`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 6`.
- **Optionally**, send `X-UOLink-Version: 6` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
- **Optionally**, send `X-UOLink-Version: 8` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json
{ "error": "protocol version mismatch", "sidecar_protocol": 6, "client_protocol": "5" }
@@ -1277,7 +1277,7 @@ so a retry loop cannot quietly swallow a mismatched deployment.
A typical character page:
```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "6" };
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "8" };
// 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

View File

@@ -69,3 +69,44 @@ World: Loading...
## Unrelated, still open
`DllNotFoundException: zlibwapi64` crashed this shard once (`Crash 6-5-2026-22-38-3.log`) while sending a packed gump. `zlibwapi64.dll` is present in the repo root, so this is a working-directory / native-load-path problem. It will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
---
## Host prerequisite: `libgdiplus` on Linux (Protocol 8)
Everything above is a repair to one shard's *scripts*. This one is different in kind: it is a
requirement on the **host**, it applies to every shard, and only to Linux ones.
The Asset Bridge ([`v8.md`](v8.md) §4.4) has the shard read art out of the operator's own UO client
files. ServUO targets `net48`, so on Linux it runs under Mono, and Mono's `System.Drawing` is a thin
layer over **libgdiplus** — which sits in the **decode** path and not merely the encode:
`Ultima.Frame` writes ARGB1555 through a `LockBits` pointer. Without that library a Linux shard
cannot read a sprite at all.
**Windows shard hosts need nothing.** `System.Drawing` ships with .NET Framework.
| Host | Get it with |
|---|---|
| Debian / Ubuntu | `sudo apt-get install libgdiplus` — in Debian since bullseye (6.0.4) and bookworm/trixie (6.1), and in Ubuntu universe |
| Fedora / RHEL | `sudo dnf install libgdiplus` (EPEL or the Mono repository) |
| Docker | `RUN apt-get update && apt-get install -y libgdiplus` in the shard image |
| Alpine, or a distro with no package | Build from source. This is the awkward case, and it is worth avoiding by choosing a Debian-based image |
Upstream is <https://github.com/mono/libgdiplus>. **That repository was archived in March 2025** and
is read-only; distributions still package and patch it, so installing it is a normal supported thing
to do today, but nobody is maintaining it upstream. It is the strongest long-term argument for
eventually moving extraction off `System.Drawing`.
**Its absence is not an error and never a crash.** The shard reports a named status on the source
gate — the first call any import makes — so an operator meets this while setting the shard up rather
than as an empty bestiary weeks later:
```
imaging: { ok: false, code: "NO_IMAGING",
reason: "This shard host cannot render images — Mono's System.Drawing needs
libgdiplus. Install it (apt-get install libgdiplus) and re-run the
import. Cliloc and atlas import are unaffected." }
```
Clilocs and the ServUO tree files are genuinely unaffected: neither touches a pixel. The installer's
`doctor` checks for this alongside its other host checks.

View File

@@ -823,6 +823,11 @@ shapes are the plain binary layout and a `number<TAB|,|;>text` export; the site
and writes the plain form. A shard that never converts is fully supported — names render as ids,
exactly as before.
> **Superseded by [`v8.md`](v8.md) §9 (protocol 8, phase 2).** The decompressor is in the overlay
> now, the shard reads its own client, and `server/tools/cliloc-export/` has been deleted. The
> paragraph above is kept as the record of why the manual step existed; everything else in this
> section — the overlay set, the hash gate, the refusals — still describes what runs.
**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

1455
link/v8.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ knowing a module answers now.
|---|---|---|
| `/api/v1/public/shard` | 19 | Anonymous. **Never site-mode gated** — the shard surface stays readable during maintenance, per feature audience. |
| `/api/v1/public/atlas` | 6 | Anonymous, and unlike `/shard` it **is** site-mode gated: nothing here touches the sidecar, it is parsed shard content. |
| `/api/v1/admin/shard` | 26 | Behind core's `isLoggedIn + noindex + staffOnly` group gate, then **mixed per route** — see below. |
| `/api/v1/admin/shard` | 28 | Behind core's `isLoggedIn + noindex + staffOnly` group gate, then **mixed per route** — see below. |
| `/api/v1/admin/uo-link` | 7 | `adminOnly`. The sidecar connection config, its live status, the admin SSE stream and the town crier. |
| `/api/v1/player/shard` | 8 | `requireAuth`, **any role** — staff are a superset of players — and every handler is self-scoped to `req.user.id`. |
| `/api/v1/admin/users/:id/shard/*` | 6 | `adminOnly`. The module's routes hanging off a **core** resource, through core's `admin.users.detail` extension slot: core owns the user, the module owns what it knows about their game accounts. |
@@ -56,7 +56,7 @@ feed) are described where their wire frames are, in
| GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). |
| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. |
| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). |
| GET | `/shard/market?q=&minPrice=&maxPrice=&itemId=&map=&region=&sort=&limit=&offset=` | search the player-vendor marketplace (Protocol 3.0 `vendor.listing`). Returns **listings**, not vendors — "who sells X and for how much" is the question, and a vendor-shaped result would make every caller flatten the shops back out. Served from `shard_vendors` + `shard_vendor_items`, so it renders while the shard is down. Behind `requireFeature('market')` **and rate-limited** — the first genuinely expensive public read on the site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*` table, reachable with no session). `sort ∈ {price_asc, price_desc, recent}`. `q` matches the resolved display name **or** the item's literal name, with `%`/`_` escaped: they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let `?q=%` match every listing on the shard. Every response repeats `staleAt` (the oldest vendor row) because the shard sweeps round-robin — a banner that ages with the results it labels, not one fetched once. |
| GET | `/shard/market?q=&minPrice=&maxPrice=&itemId=&map=&region=&sort=&limit=&offset=` | search the player-vendor marketplace (Protocol 3.0 `vendor.listing`). Returns **listings**, not vendors — "who sells X and for how much" is the question, and a vendor-shaped result would make every caller flatten the shops back out. Served from `shard_vendors` + `shard_vendor_items`, so it renders while the shard is down. Behind `requireFeature('market')` **and rate-limited** — the first genuinely expensive public read on the site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*` table, reachable with no session). `sort ∈ {price_asc, price_desc, recent}`. `q` matches the resolved display name **or** the item's literal name, with `%`/`_` escaped: they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let `?q=%` match every listing on the shard. Every response repeats `staleAt` (the oldest vendor row) because the shard sweeps round-robin — a banner that ages with the results it labels, not one fetched once. As of Protocol 8 phase 5 each listing also carries **`art`**: the FILENAME of the item's picture under `uploads/items/`, already hued, or `null` where this site does not hold one. `null` is ordinary — the picture is fetched behind the page and never by it, so a newly listed item shows text first and gains its icon a pass later, and some item ids have no art in any client. The same field appears on a character sheet's equipment entries. |
| GET | `/shard/market/meta` | index size, staleness (`staleAt`/`freshAt`) and which facets and regions actually hold vendors, so a client builds its filters without running a search it will discard. |
| GET | `/shard/market/vendors/:serial` | one shop and its listings; `:serial` must match `/^0x[0-9A-Fa-f]{1,16}$/` or **400** before any query runs. **404** = a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer, and distinguishing them would leak that a hidden vendor exists. `truncated` (with `total` exceeding `count`) means the shop holds more than the shard publishes per frame. |
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§4 below), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
@@ -81,6 +81,9 @@ account-linking routes and the sidecar config under `/admin/uo-link` are in the
| 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`): 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. |
| GET | `/shard/assets` | client-asset import status (`adminOnly`, Protocol 8): the imported body catalogue, how many sprites are on disk, how many atlas creatures resolved to a body id, and the shard's own client files beside them. `drift:true` means the client was patched. `shard.imaging.ok:false` is the named `NO_IMAGING` state — a Linux shard host with no `libgdiplus` cannot decode a sprite at all, and the reason names the package. No public counterpart: the pictures are served as ordinary files under `/uploads`. |
| POST | `/shard/assets/import` | import creature artwork from the shard's UO client; `{force}` ignores the hash gate, `{approve}` accepts a catalogue that no longer offers assets this site holds (refused by default — an unmounted client volume and a deliberate downgrade are indistinguishable, and the wrong guess deletes artwork). **This is the ONLY thing that imports** — boot deliberately never calls the shard. An operator's `spawnAtlas.art.json` always wins over an imported sprite. A body this client has no art for is **not** a failure: about half the addressable body range is in that state on a stock client. |
| POST | `/shard/assets/warm` | fetch item and land pictures this site is missing, now, instead of waiting for the warm timer (`adminOnly`, Protocol 8 phase 5). The pass works out which item pictures the site's own rows name — every distinct (ItemID, hue) on a player vendor, plus anything a character sheet has shown since the last pass — and fetches the ones it does not hold, **hued on the shard**, into `uploads/items/`. There is deliberately **no manifest and no bulk import**: the client addresses 49,152 item graphics times 3,000 hues, so the working set is what the site displays. `{force}` re-fetches pictures already held (how an operator recovers a wiped uploads volume); `{limit}` bounds one pass, default 400, because the shard serves one asset request at a time. A plugin overlay older than phase 5 answers `status:"unavailable"`, `code:"UNSUPPORTED"` with a sentence naming the fix. |
| 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. |
## 4. Shard visibility — the audience boundary (Protocol 3.0)

View File

@@ -21,7 +21,6 @@ these routes *mean* are the ones that already existed and did not move:
| [`SPAWN_ATLAS.md`](../../website/SPAWN_ATLAS.md) | The bestiary / spawn atlas, parsed from the shard's own ServUO tree |
| [`MARKETPLACE.md`](../../website/MARKETPLACE.md) | The player-vendor index |
| [`CLILOCS.md`](../../website/CLILOCS.md) | UO's id → name table |
| [`UOFIDDLER.md`](../../website/UOFIDDLER.md) | Operator runbook for extracting the cliloc table and creature art |
| [`../../link/PLAN.md`](../../link/PLAN.md), [`../../link/INTEGRATION.md`](../../link/INTEGRATION.md) | The wire protocol this module speaks to the sidecar |
---

View File

@@ -188,10 +188,80 @@ Four column choices worth stating, because each one is a trap:
Deceit"). The live `champ.update` feed in `shard_champs` is the separate answer to "it is on level 3
right now". Both exist; they are not the same data.
**`shard_spawn_creatures.art` is always NULL on a fresh import.** The project ships no creature
artwork: sprites live in the operator's own client `.mul`/`.uop` files and are theirs, not ours to
redistribute. An operator supplies art via a gitignored map plus images under the (already
gitignored) `server/uploads/atlas/`. Text-only is the normal, supported state.
**`shard_spawn_creatures.art` is DERIVED, never written by the atlas import itself** — see
`shard_assets` below. The project still ships no creature artwork: sprites live in the operator's own
client `.mul`/`.uop` files and are theirs, not ours to redistribute. What changed in Protocol 8 is
who extracts them: the shard does, from its own client, over the bridge. An operator's hand-drawn map
plus images under the (already gitignored) `server/uploads/atlas/` still wins over anything imported.
Text-only is still the normal, supported state — an install with no shard link never imports one, and
even a complete import leaves two thirds of the playable ghost and gargoyle bodies without art.
## shard_assets / shard_creature_bodies / shard_asset_meta — the Asset Bridge (Protocol 8)
Creature artwork read from the shard's own UO client ([`link/v8.md`](../../link/v8.md) §6, §8, §12).
| Table | Shape |
|---|---|
| `shard_assets` | `asset_key` VARCHAR PK (§5's key, e.g. `body/34/a0`, `static/3922/h33`, `land/3`), `family`, `sha256`, `bytes`, `width`, `height`, `body`, `direction`, `file`, `catalog`, `imported_at` |
| `shard_creature_bodies` | `slug` PK, `type_name` (the ServUO class name asked), `body` nullable, `status`, `resolved_at` |
| `shard_asset_meta` | Singleton (`id = 1`), `payload` JSON (catalogue id, extractor version, source fingerprint, counts), `imported_at` |
**These are the one part of this schema that is deliberately NOT import-owned**, and the reason is
the atlas tables sitting directly above them. `replaceAtlas` empties and refills
`shard_spawn_creatures` on every refresh, and a refresh runs on every boot; an imported filename
stored on that row would be destroyed by an ordinary re-parse of the ServUO tree, with the next asset
Update finding the client files unchanged, reporting "nothing to do", and never restoring it. So the
assets live out here, upserted per key, and `replaceAtlas` re-derives `art` from them on the way
past — `{ ...derived, ...operatorMap }`, which is the one place "the operator's map wins" is
enforced.
Four details that are load-bearing rather than incidental:
- **`file` is a filename under the uploads directory, never a path**, and it is content-addressed
(`uo-body-34-a0-<sha8>.png`). A stable name overwritten in place would leave every browser and CDN
serving the previous client's sprite from cache, with the row perfectly correct.
- **The `art` derivation joins on the catalogue key**, `a.asset_key = CONCAT('body/', b.body, '/a0')`,
not `a.body = b.body`. The simpler join is correct today and stops being correct the moment deeper
animation keys (`body/400/a2/f0`) arrive, at which point one slug matches dozens of rows.
- **`shard_creature_bodies` IS replaced whole**, unlike `shard_assets`: it is derived from the atlas's
creature list, so a slug that has left the atlas has no meaning, and the pass that rebuilds it is a
shard round trip rather than a file transfer.
- **`status` keeps the negative answers** — `unknown` (the spawn files name a type this shard's
scripts do not define, which is real drift), `notCreature` (a spawn entry for an item or
decoration, a permanent answer), `failed`. Without them the next pass asks again, and each name
costs a real constructor on the shard's Core thread.
`shard_spawn_creatures.name` already holds the ServUO **class name** — the atlas build picks the
winning spelling of the spawn type token rather than inventing a display label — which is why the
body pass needs no extra column to ask its question.
### Item and land art: the same table, a different shape of use (phase 5)
The creature catalogue is a **set**: one manifest walk covers every key, so one stored fingerprint in
`shard_asset_meta` describes all of them and an Update is a hash diff. Item art has no set — the
client addresses 49,152 item graphics times 3,000 hues — so those rows arrive one at a time, because
something on this site named the key.
Three consequences in this schema:
- **`catalog` is per row**, and it is what phase 5 added. It records the shard's art catalogue id (a
hash of `artLegacyMUL.uop`/`art.mul`, `hues.mul`, `tiledata.mul`, `verdata.mul` and its extractor
version), so staleness is a column comparison rather than a manifest diff. A client patch changes
it; a restart does not. NULL means "written before the column existed", which is stale by the same
test and costs one re-fetch. The body import fills it too, so one column answers the question
everywhere.
- **`shard_asset_meta` stays the body catalogue's alone.** A warm pass writing there would tell the
body import that a client it never looked at is unchanged, and the creature catalogue would quietly
stop updating.
- **`family` is now load-bearing**, not decoration: `body`, `static` and `land` rows share the table
and have different lifetimes. The admin status counts them separately for the same reason — there
is no "how many are there" to compare `static` against, so the only honest number is how many the
site has been asked for and holds.
Pictures land in `server/uploads/items/` (gitignored like `uploads/atlas/`), content-addressed the
same way — `uo-static-3922-h33-<sha8>.png`. A key the shard has no art for writes **no row at all**:
an empty row would make it "held" and it would never be asked again, including after the operator
patches in the graphic that was missing.
## shard_clilocs / shard_cliloc_meta — UO's localization table (Protocol 3.0)
@@ -203,29 +273,49 @@ marketplace listing — but with no table to resolve it against, the character s
| Table | Shape |
|---|---|
| `shard_clilocs` | `number` INT PK, `flag`, `text` TEXT |
| `shard_cliloc_meta` | Singleton (`id = 1`), `payload` JSON (source file, sha256, count, `parserVersion`), `imported_at` |
| `shard_cliloc_meta` | Singleton (`id = 1`), `payload` JSON (`source``bridge` or `file` — the base's fingerprint under `base`, the overlay `hashes`, per-source counts, `parserVersion`), `imported_at` |
Import-owned and all-or-nothing in one transaction, same contract as the atlas — including **`DELETE`,
not `TRUNCATE`**, for the same reason.
**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`](../../website/CLILOCS.md).
**The base table comes from the SHARD** on any install with uo-link configured (Protocol 8, phase 2):
it reads its own client's compressed `Cliloc.enu` and serves the table paged over the bridge, so
nothing is converted and nothing is copied to the web host. Without a shard link it falls back to a
converted file on disk at a path from the `cliloc_client_path` setting, then `UO_CLIENT_PATH` — the
pre-protocol-8 pipeline, deprecated rather than removed so an existing install keeps working.
**Overlays are always the filesystem's**, either way: ServUO has no server-side notion of a custom
cliloc, so `custom/` is the only place shard-added ids exist and there is nothing on the shard to ask
for. That gap is in the *game*, not in this pipeline.
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`](../../website/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
carry cliloc ids no stock client table has. A base (from the shard, or a converted file) plus every
overlay under `custom/` are 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
either — so the shard cannot supply names on our behalf. The plain layout and a delimited text export
are both accepted, sniffed by header rather than extension.
**The conversion step used to be unavoidable, and is not any more.** Every current client ships its
cliloc files compressed (first DWORD's high byte `0x8E`) and ServUO's own bundled
`Ultima.StringList` cannot read that either — which is why, for two protocol versions, the operator
had to install UOFiddler, build a converter against its `Ultima.dll` and copy a 5 MB file to the web
host. Protocol 8 phase 2 ported the Mythic decompressor into the overlay, so **the shard reads its
own client and supplies the names**. The file half survives only as the fallback above, where the
plain layout and a delimited text export are both accepted, sniffed by header rather than extension.
Two consequences for what this table holds. `shard_cliloc_meta.payload` carries the base's
fingerprint under `meta.base` (the shard's file size, mtime, hash and `EXTRACTOR_VERSION`) separately
from the overlay hashes, because on the bridge the old `clilocs.plain` label is *supposed* to
disappear and a single hash map would read that upgrade as a vanished source. And **boot does not
import on the bridge path**: a file could be re-hashed locally on every restart, but asking the shard
would put a sidecar round trip in the boot sequence for a table that changes only when an operator
patches their client. Importing is an admin action.
Three decisions worth stating:

View File

@@ -1,7 +1,8 @@
# 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.
**Status:** Complete. The base table now arrives **over the bridge** — protocol 8, phase 2.
**Design:** [`docs/link/v3.md` §8.6](../link/v3.md) (the table itself, Protocol 3.0) and
[`docs/link/v8.md` §9](../link/v8.md) (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
@@ -12,86 +13,70 @@ 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
## Where the table comes from
This is the awkward part, and it is not avoidable:
**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:
**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
```
ServUO shard ──`cliloc.table`──▶ uo-link sidecar ──`GET /cliloc`──▶ website
reads Cliloc.enu, forwards, keeps merges overlays,
decompresses, pages nothing replaces the table
```
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.
**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.
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.
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`](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
@@ -103,12 +88,16 @@ atlas, which reads `Regions.xml` + `Locations/*.xml` + `Spawns/*.xml` +
```
<cliloc path>/
clilocs.plain ← base: the converted client table
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`,
@@ -116,8 +105,9 @@ Overlays use the same delimited-text format, are read in **sorted order**, and
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.**
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
@@ -125,11 +115,14 @@ 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 }
{ "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,
@@ -143,28 +136,29 @@ 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
## Choosing the source
Two ways to point at the sources, the setting winning over the environment:
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.
| Source | Notes |
The two escape hatches, both deliberate:
| | |
|---|---|
| `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 |
| 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 |
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:
`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
@@ -175,16 +169,25 @@ reason: This is a compressed (Mythic-format) cliloc file, which the site cannot
## 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.
- **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
@@ -201,7 +204,7 @@ 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
**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
@@ -209,11 +212,28 @@ is escalated rather than applied:
```
status: needsReview
reason: 1 previously-loaded cliloc source(s) are missing;
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 }`.
@@ -229,15 +249,20 @@ admin was already going to run.
| | |
|---|---|
| Parsed from a stock `Cliloc.enu` | **123,490** entries |
| 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 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.
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
@@ -297,13 +322,24 @@ 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 |
| `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 |
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.
**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.

View File

@@ -15,6 +15,14 @@ UO actions, the integrations, the authoring UI, the public surface — needed no
Those fourteen phases reach the game only to *announce*, over verbs the write plane already carries; nothing in them creates or
changes a thing in the world.
**COMPLETE as of 2026-09-10.** All seventeen phases are built and on `main` in every repository they
touch. P16 ran as three legs — **16a** the acceptance walk from `edge`, **16b** the six-step cutover
and the re-verify against released artefacts, **16c** `runicgateway.com` and `.profile` — and 16c
opened by closing the seventh cutover step 16b had left standing (`docs#232`). The platform the
workstream leaves behind is sidecar **v2.2.0**, overlay **v1.2.0**, bundle **2026.09.10** on
**protocol 7**, `Module-uo` **v1.2.2**, and `MODULE_API_VERSION` **1.10.0**. Each phase's record is
in its own section below; `edge` stays standing, unused, in every repository.
---
## Before anything: three facts about the ground
@@ -2025,6 +2033,75 @@ core, docs, then the kit's re-pin and `runicgateway.com`.
- **`.profile`** — the org landing page is updated when the *shape* of the project changes, which a new
subsystem is.
> **16c BUILT (2026-09-09/10) — and it began by closing the cutover's missing seventh step.**
> `runicgateway.com#30` and `.profile#6`, both onto `main`; the leg's first act was `docs#232`.
>
> #### The step 16b left on `edge`
>
> Six repositories were cut over and every one of them showed **0 commits on `edge` that are not on
> `main`**. This one showed **46**. Step 6 (`#231`) landed the record *on* `edge` rather than cutting
> `edge` over — an easy thing to miss, because the step's own PR merged green and closed. The
> consequence was quiet and total: `docs` `main` opened `EVENTS.md` with *"revision 5. **No code
> written.** Read against … `MODULE_API_VERSION` 1.9.0 · sidecar protocol 5"* while six repositories
> shipped the engine on protocol 7, and `link/v6.md` and `v7.md` — the specs of record for two
> protocol versions — existed on no default branch anywhere.
>
> **Nothing in this workstream could have caught it.** Every check that guards a contract lives in
> the repository that *depends* on the contract, and a documentation repository has no dependants.
> What found it was the one check that reads `docs` from outside: `runicgateway.com`'s
> `checkReference.mjs` asserts every canonical document it names still exists on `main`, and adding
> the current protocol spec failed with `✗ canonical doc link/v7.md`. **The site is the docs
> repository's only dependant, and 16c is the only phase that would ever have run that check.**
>
> The merge was clean, and both `PROJECT_TREE.md` files stayed on `main`'s newer automated syncs —
> `edge` never edited them, so git kept `main`'s side. `edge` stays standing, per 16b's decision,
> now four generated commits behind.
>
> #### The site
>
> The checks were red before the phase started and named their own answers, which is the whole
> bargain §12 of that repository's plan struck: nine `checkFacts` values (protocol 5 → **7** in all
> three declaration sites, `moduleApi` → **1.10.0**, the bundle triple, `link` **v2.2.0**, `Module-uo`
> **v1.2.2** — a third module release, one past the v1.2.1 the cutover cut), and **twenty-seven
> `Bridge.cfg` keys** the site listed nowhere. Those became five groups rather than an appendix,
> because `EventsEnabled` is a *second consent switch* and belongs beside the ceilings it governs
> rather than filed under `AdminWriteEnabled`.
>
> Two pages, the treatment Teams has: **Scheduled events** under Administration and **Events
> architecture**. Two capability entries, so the homepage, `/features/` and `/modules/` stop omitting
> the subsystem — and the calendar one is deliberately **not** `needsModule`, because a bare core can
> author and run an event and that marker means "present, correct and permanently empty".
>
> **`/privacy` owed a row and had none.** `event_run_participants` is personal data — scores and
> ranks against a module-opaque member key, linked to an account where one is linked, feeding a
> participant's own history. The new `deploy-events` row states the retention exactly, including the
> asymmetry that matters: the diagnostic log is swept after 90 days **and only on terminal runs**,
> while the run, its steps and its participants are never swept, because they are the record of what
> was done to a shared world.
>
> **A naming collision worth fixing while it was cheap.** `reference/event-catalog` is about what a
> shard *emits*; with a scheduled-event system shipped, two things in the documentation were called
> an event catalog. Retitled **"Shard event catalog"**, with the route left alone so nothing outside
> that repository breaks — and the page now opens by saying which of the two it is, since the kinds
> it lists are exactly what a phase can wait for.
>
> **No screenshots**, and stated as a choice: capturing the events surfaces means standing the whole
> rig back up for images no check requires, and the engagement workstream's site leg added none
> either.
>
> #### `.profile`
>
> One bullet, and two stale numbers. The bullet says the posture rather than the feature list — off
> by default, caps in the database, cleanup generated from a ledger, and *an event does not edit the
> world, it holds a lease the game restores on its own deadline*. The numbers are protocol **5 → 7**
> in the four values the installer prints (the block a reader copies into Admin → Shard, where a
> wrong number is a pairing failure with no obvious cause) and **module-uo v1.1.0 → v1.2.2**.
>
> #### What 16c did not need
>
> No `EVENTS.md` change, no `MODULE_API_VERSION` change, no protocol change, no release. The phase
> moves no contract — it makes the ones already moved legible from outside the organisation.
---
## What this plan does not do

View File

@@ -169,6 +169,8 @@ website/
│ │ ├── lib/
│ │ │ ├── adminNav.js
│ │ │ ├── engagementRules.js
│ │ │ ├── eventAuthoring.js
│ │ │ ├── eventCalendar.js
│ │ │ ├── format.js
│ │ │ ├── heroLayout.js
│ │ │ ├── moduleAdmin.js
@@ -216,6 +218,11 @@ website/
│ │ │ │ │ ├── EngagementSuppressions.jsx
│ │ │ │ │ ├── EngagementTemplates.jsx
│ │ │ │ │ ├── EngagementTriggers.jsx
│ │ │ │ │ ├── EventActions.jsx
│ │ │ │ │ ├── EventEditor.jsx
│ │ │ │ │ ├── EventRun.jsx
│ │ │ │ │ ├── EventsAdmin.jsx
│ │ │ │ │ ├── EventsCalendar.jsx
│ │ │ │ │ ├── HeroEditor.jsx
│ │ │ │ │ ├── InvitesAdmin.jsx
│ │ │ │ │ ├── Moderation.jsx
@@ -246,6 +253,7 @@ website/
│ │ │ │ ├── ForgotPassword.jsx
│ │ │ │ ├── PlayerAccount.jsx
│ │ │ │ ├── PlayerAppeals.jsx
│ │ │ │ ├── PlayerEvents.jsx
│ │ │ │ ├── PlayerInbox.jsx
│ │ │ │ ├── PlayerLogin.jsx
│ │ │ │ ├── PlayerNotifications.jsx
@@ -258,6 +266,9 @@ website/
│ │ │ ├── public/
│ │ │ │ ├── About.jsx
│ │ │ │ ├── CmsPage.jsx
│ │ │ │ ├── EventPage.jsx
│ │ │ │ ├── Events.jsx
│ │ │ │ ├── EventSeries.jsx
│ │ │ │ ├── FiveOnFriday.jsx
│ │ │ │ ├── Maintenance.jsx
│ │ │ │ ├── News.jsx
@@ -279,6 +290,8 @@ website/
│ │ ├── apiClient.test.js
│ │ ├── emailTemplates.test.js
│ │ ├── engagementRules.test.js
│ │ ├── eventAuthoring.test.js
│ │ ├── eventCalendar.test.js
│ │ ├── featureGate.test.js
│ │ ├── format.test.js
│ │ ├── heroLayout.test.js
@@ -352,6 +365,7 @@ website/
│ │ │ └── validateBlocks.js
│ │ ├── config/
│ │ │ ├── brand.js
│ │ │ ├── coreEventActions.js
│ │ │ ├── coreStreams.js
│ │ │ ├── coreTriggers.js
│ │ │ ├── csp.js
@@ -393,6 +407,18 @@ website/
│ │ │ ├── suppressions.js
│ │ │ ├── templates.js
│ │ │ └── templateSeeds.js
│ │ ├── events/
│ │ │ ├── announce.js
│ │ │ ├── authorize.js
│ │ │ ├── cleanup.js
│ │ │ ├── dispatch.js
│ │ │ ├── gates.js
│ │ │ ├── ledger.js
│ │ │ ├── participants.js
│ │ │ ├── price.js
│ │ │ ├── recurrence.js
│ │ │ ├── spec.js
│ │ │ └── verify.js
│ │ ├── middleware/
│ │ │ ├── botScore.js
│ │ │ ├── loginProtection.js
@@ -442,6 +468,25 @@ website/
│ │ │ │ ├── engagementSuppressions.db.js
│ │ │ │ ├── engagementTemplates.db.js
│ │ │ │ └── engagementTemplates.model.js
│ │ │ ├── events/
│ │ │ │ ├── eventActionSettings.db.js
│ │ │ │ ├── eventCalendar.model.js
│ │ │ │ ├── eventDefinitions.db.js
│ │ │ │ ├── eventDefinitions.model.js
│ │ │ │ ├── eventJson.js
│ │ │ │ ├── eventPhaseGates.db.js
│ │ │ │ ├── eventPublic.model.js
│ │ │ │ ├── eventRunBudget.db.js
│ │ │ │ ├── eventRunControls.model.js
│ │ │ │ ├── eventRunLog.db.js
│ │ │ │ ├── eventRunParticipants.db.js
│ │ │ │ ├── eventRunResources.db.js
│ │ │ │ ├── eventRuns.db.js
│ │ │ │ ├── eventRuns.model.js
│ │ │ │ ├── eventRunSteps.db.js
│ │ │ │ ├── eventSeries.db.js
│ │ │ │ ├── eventSeries.model.js
│ │ │ │ └── eventVersions.db.js
│ │ │ ├── invites/
│ │ │ │ ├── invites.db.js
│ │ │ │ └── invites.model.js
@@ -559,6 +604,8 @@ website/
│ │ │ │ │ ├── emailConfig.controller.js
│ │ │ │ │ ├── engagement.controller.js
│ │ │ │ │ ├── engagement.router.js
│ │ │ │ │ ├── events.controller.js
│ │ │ │ │ ├── events.router.js
│ │ │ │ │ ├── imageUpload.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── invites.controller.js
@@ -607,6 +654,8 @@ website/
│ │ │ │ ├── player/
│ │ │ │ │ ├── appeals.controller.js
│ │ │ │ │ ├── appeals.router.js
│ │ │ │ │ ├── events.controller.js
│ │ │ │ │ ├── events.router.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── teamForum.controller.js
│ │ │ │ │ ├── teamForum.router.js
@@ -615,6 +664,8 @@ website/
│ │ │ │ ├── public/
│ │ │ │ │ ├── engagement.controller.js
│ │ │ │ │ ├── engagement.router.js
│ │ │ │ │ ├── events.controller.js
│ │ │ │ │ ├── events.router.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── modules.controller.js
│ │ │ │ │ ├── modules.router.js
@@ -646,6 +697,7 @@ website/
│ │ │ ├── engagementEmit.js
│ │ │ ├── engagementRetentionPrune.js
│ │ │ ├── engagementWorker.js
│ │ │ ├── eventRunner.js
│ │ │ ├── forumHtml.js
│ │ │ ├── htmlShell.js
│ │ │ ├── logger.js
@@ -718,6 +770,27 @@ website/
│ │ ├── engagementRetentionSql.test.js
│ │ ├── engagementTemplatesAdmin.test.js
│ │ ├── engagementTriggers.test.js
│ │ ├── eventActionRegistry.test.js
│ │ ├── eventAnnounce.test.js
│ │ ├── eventAuthorize.test.js
│ │ ├── eventCleanup.test.js
│ │ ├── eventGates.test.js
│ │ ├── eventIntegrations.test.js
│ │ ├── eventLedger.test.js
│ │ ├── eventModuleContract.test.js
│ │ ├── eventParticipants.test.js
│ │ ├── eventPrice.test.js
│ │ ├── eventPublic.test.js
│ │ ├── eventRecurrence.test.js
│ │ ├── eventRunControls.test.js
│ │ ├── eventRunner.test.js
│ │ ├── eventRunnerSql.test.js
│ │ ├── eventsAdmin.test.js
│ │ ├── eventSchedule.test.js
│ │ ├── eventSeries.test.js
│ │ ├── eventSpec.test.js
│ │ ├── eventsRoles.test.js
│ │ ├── eventVerify.test.js
│ │ ├── honeypot.test.js
│ │ ├── htmlShell.test.js
│ │ ├── inviteController.test.js

View File

@@ -213,28 +213,60 @@ implicitly commit, defeating the all-or-nothing guarantee. Point ids are assigne
explicitly rather than left to `AUTO_INCREMENT`, because the join rows need them
and `conn.batch()` reports no usable `insertId`.
## Artwork — operator-supplied, never shipped
## Artwork — the shard extracts it now (Protocol 8)
**This project ships no creature art and no extraction tooling, and never will.**
UO sprites live in the operator's own client `.mul`/`.uop` files. They are the
operator's, not ours to redistribute.
The atlas is fully functional as text. `shard_spawn_creatures.art` is nullable
and is NULL on every fresh import; pages render without images, which is the
normal and supported state, not a degraded one.
What changed in protocol 8 is not that rule — it is who does the extracting. The
shard already has those files (a ServUO server cannot boot without a UO client),
so as of [`v8.md`](../link/v8.md) phase 3 it decodes them itself and hands the
pictures to the website over the bridge. Nobody installs UOFiddler and nobody
copies images to a web host.
An operator who wants art — step-by-step, with the UOFiddler side spelled out, in
[`UOFIDDLER.md`](UOFIDDLER.md) §Part 2:
**Admin → Shard → Import.** The import walks the shard's asset manifest, fetches
only the sprites whose hash changed, writes them under `uploads/atlas/`, asks the
shard for a body id per creature (§8 — the shard constructs the creature and
reads `Body.BodyID`, which is the only thing that is right for a shard's own
custom creatures) and points each `shard_spawn_creatures.art` at its picture.
Boot never calls the shard for this: the files change when an operator patches
their client, which is an event they know about and the site does not.
1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or
any art extractor).
2. Drops the images under `server/uploads/atlas/`.
3. Copies `server/db/data/spawnAtlas.art.example.json` to `spawnAtlas.art.json`
and maps creature slugs to file names.
4. Restarts, or runs `npm run atlas:import -- --force`.
On this machine's stock client that is **1,022 creature portraits**, about a
megabyte in total — 787 out of the legacy `anim*.mul` files and 235 more out of
`AnimationFrame*.uop`, which ServUO's own decoder never opens
([`../link/v8.md`](../link/v8.md) §4.9).
Both `spawnAtlas.art.json` and `server/uploads/` are gitignored, so neither the
map nor the images can be committed by accident.
**NULL stays a first-class state, and always will be.** An install with no shard
link has never imported one; a Linux shard host without `libgdiplus` cannot
render a sprite at all (a named `NO_IMAGING` status, not an error); and about
half the addressable body range has no art in any client file. Pages render
without images, which is normal and supported, not degraded.
### The operator's own artwork still wins
An operator who has drawn their own portraits keeps them. The map is unchanged:
1. Drop the images under `server/uploads/atlas/`.
2. Copy `server/db/data/spawnAtlas.art.example.json` to `spawnAtlas.art.json` and
map creature slugs to file names.
3. Restart, or run the import.
`spawnAtlas.art.json` is applied **over** anything imported, per slug, so a sprite
rip never replaces a hand-drawn portrait on the next Update. Both it and
`server/uploads/` are gitignored, so neither the map nor the images can be
committed by accident.
### Why the imported art is not stored on the creature row
`shard_spawn_creatures` is emptied and refilled by every atlas refresh, and a
refresh happens on every boot. So the body ids and the imported files live in
`shard_creature_bodies` and `shard_assets`, outside that blast radius, and the
atlas import re-derives `art` from them on the way past. Storing it on the row
would mean an ordinary re-parse of the ServUO tree silently deleting every
portrait — with the next asset Update finding the client files unchanged,
reporting "nothing to do", and never putting them back.
## Code layout

View File

@@ -1,282 +0,0 @@
# Extracting from your own UO client (UOFiddler)
**Audience:** the shard operator, once, at setup time.
**Related:** [`CLILOCS.md`](CLILOCS.md) (why the cliloc conversion is unavoidable),
[`SPAWN_ATLAS.md`](SPAWN_ATLAS.md) (where creature art fits).
Two features read data that **only exists inside a UO client**, and a UO client's
files are EA's, not ours to redistribute. So neither this repo nor any image we
publish can ship them — the operator extracts from **their own** client, once,
and points the site at the result.
| Feature | What it needs | Required? | Without it |
|---|---|---|---|
| **Item / title names** ([`CLILOCS.md`](CLILOCS.md)) | `Cliloc.enu`, converted | No | Names render as raw ids — `id 1023721` instead of *quarter staff* |
| **Creature art** ([`SPAWN_ATLAS.md`](SPAWN_ATLAS.md)) | Sprites from `.mul`/`.uop` | No | Atlas pages render as text, which is the normal state |
**Both are optional and neither is load-bearing.** A shard that never does any of
this is fully supported. Do part one and skip part two if art is not worth your
time — they share only the tool.
Everything you extract stays **outside the repository**: the converted cliloc
file lives at a path you choose, and `spawnAtlas.art.json` plus `server/uploads/`
are gitignored, so none of it can be committed by accident.
---
## Part 0 — Get UOFiddler
[UOFiddler](https://github.com/polserver/UOFiddler) is the community client-file
editor. We use it because its `Ultima.dll` already contains the cliloc
decompressor, maintained by people who do this for a living.
1. Download the latest release zip from
<https://github.com/polserver/UOFiddler/releases/latest> — one asset, named
`UOFiddler-<version>.zip` (4.22.2 is ~2 MB).
2. Extract it. The zip contains a single top-level folder, and the two files that
matter are at **its root**:
```
UOFiddler-4.22.2/
Ultima.dll ← the decompressor (Part 1 needs this path)
UoFiddler.exe ← the GUI (Part 2 needs this)
plugins/
```
3. **Runtime:** UOFiddler 4.22.2 is built for **.NET 10**. Running `UoFiddler.exe`
needs the .NET 10 **Desktop** Runtime (Windows only); loading `Ultima.dll` from
the converter in Part 1 needs the .NET 10 runtime. Install from
<https://dotnet.microsoft.com/download/dotnet/10.0>.
### Finding your client files
The cliloc file is in your **UO client installation directory**, not in your
ServUO tree — the shard server has no copy of it. Look for `Cliloc.enu` (English;
the other seven are `chs`, `cht`, `deu`, `esp`, `fra`, `jpn`, `kor`) beside
`art.mul` / `artLegacyMUL.uop`. The EA Classic Client's default location is:
```
C:\Program Files (x86)\Electronic Arts\Ultima Online Classic\
```
**If your shard distributes its own patched client to players, use that copy.**
Any cliloc edits you shipped to players are then already in the base table and
you need no overlay for them (see [`CLILOCS.md`](CLILOCS.md) §Shard-added and
shard-edited items).
---
## Part 1 — Convert the cliloc table
**Goal:** turn the client's compressed `Cliloc.enu` into a file the site can
read, and point the site at it.
The site cannot read `Cliloc.enu` directly. Every modern client compresses it
(the "Mythic" container), and so does ServUO's own bundled `Ultima.StringList` —
which is why the shard cannot supply names on our behalf either. The full
reasoning is in [`CLILOCS.md`](CLILOCS.md) §Why the operator has to convert the
file; this section is just the procedure.
Two routes. **The bundled tool is the recommended one** — the GUI export needs a
fixup step, described below.
### Route A — the bundled converter (recommended)
Needs a .NET SDK (any version 8 or newer — the project targets `net8.0` and rolls
forward, so whatever you have works) **plus** the .NET 10 runtime from Part 0,
which is what actually loads `Ultima.dll`.
```bash
cd website/server/tools/cliloc-export
dotnet build -c Release
# plain binary — recommended, exact
dotnet run -c Release -- \
"/path/to/UOFiddler-4.22.2/Ultima.dll" \
"/path/to/UO client/Cliloc.enu" \
/srv/uo-data/clilocs.plain
# or tab-delimited text, if you want to eyeball or hand-edit it
dotnet run -c Release -- \
"/path/to/UOFiddler-4.22.2/Ultima.dll" \
"/path/to/UO client/Cliloc.enu" \
/srv/uo-data/clilocs.tsv --tsv
```
Expected output for a stock English client:
```
wrote 123490 entries to /srv/uo-data/clilocs.plain (maxTextBytes=12150, skippedOversize=0)
```
**Sanity-check that number.** A stock `Cliloc.enu` is ~123,000 entries. A few
hundred means it read something else and you should not ship the result. The
tool exits non-zero and says `no entries were written — is that a cliloc file?`
when it gets nothing at all.
The conversion runs on whatever machine has the client (usually Windows), and the
site reads the output wherever it runs — so **copy the output file to the server**
if those are different machines. It is a single self-contained file (~5 MB); the
`--tsv` form is larger but diff-able.
<details>
<summary>Errors you may hit</summary>
| Message | Cause |
|---|---|
| `Ultima.StringList not found — is that really UOFiddler's Ultima.dll?` | First argument points at some other `Ultima.dll` (ServUO ships one too — it is **not** the same assembly and cannot do this) |
| `You must install .NET to run this application` | Missing the .NET 10 runtime from Part 0 step 3 |
| `Unexpected Ultima.StringList API` | UOFiddler older than 4.21 |
| `usage: clilocexport …` | Fewer than three arguments |
</details>
### Route B — the UOFiddler GUI
Use this if you would rather not install a .NET SDK. **It needs one extra step**,
so do not skip the fixup.
1. Launch `UoFiddler.exe` and point it at your client directory when it asks
(or **Options → Path Settings**).
2. Open the **Cliloc** tab and use its **export to CSV** action.
3. It writes `CliLoc.csv` to UOFiddler's configured output path, in **three**
columns with a header row:
```
Number;Text;Flag
1023721;quarter staff;0
```
4. **Strip the trailing flag column.** The site's text parser reads
`number<TAB|,|;>text`, so that third field is otherwise absorbed into the name
and every item on the site renders as `quarter staff;0`.
```bash
sed -E 's/;[0-9]+$//' CliLoc.csv > clilocs.csv
```
```powershell
Get-Content CliLoc.csv |
ForEach-Object { $_ -replace ';\d+$','' } |
Set-Content -Encoding utf8 clilocs.csv
```
The header row needs no removal — a line whose first field is not an integer
is skipped. Blank entries (`1005008;`) survive the fixup correctly and are
dropped at import, as intended.
5. Copy `clilocs.csv` to the server.
**Why the fixup is not just done for us:** the parser already handles
`number,flag,text` — the flag in the *middle*, which is what several exports
emit. UOFiddler puts it at the *end*, where it is indistinguishable from a name
that genuinely ends in `;0`. One `sed` on the operator's side beats a parser
heuristic that would corrupt real names.
### Point the site at it
Two ways, the setting winning over the environment:
| Where | How |
|---|---|
| **Admin → Shard → cliloc path** | Takes effect on the next refresh, no redeploy |
| `UO_CLIENT_PATH` env var | The deploy-time default |
The value may be **the file itself or a directory to search** — both are natural
answers to "where is it", and overlays are picked up either way.
Setting the path deliberately does **not** import as a side effect. Click
**Import** (or `POST /api/v1/admin/shard/clilocs/import`) to load it.
### Verify
`GET /api/v1/admin/shard/clilocs`, or the Admin → Shard panel, reports what each
source contributed:
```json
"sources": [
{ "label": "clilocs.plain", "kind": "base", "entries": 123490, "added": 123490, "overrode": 0 }
]
```
Roughly **67,500 rows stored** from a stock table is correct — about half a
cliloc table is empty strings for ids the client reserves and never uses.
Then load any character sheet with equipment: items should show names rather than
`id 1023721`.
<details>
<summary>What a refusal means</summary>
A bad file answers `200` with a `status` and a named reason, not a `500` — you
need to be told *which file* to fix.
| `code` | Meaning |
|---|---|
| `COMPRESSED` | You pointed at the raw client `Cliloc.enu`. Convert it — this whole page. |
| `TRUNCATED` | Half-copied file. Re-copy; the loaded table is untouched. |
| `EMPTY` | A text source with no parseable rows — the file is named in the reason. |
| `status: needsReview` + `missingSources` | A previously-loaded source has vanished (unmounted volume? deliberate deletion?). Nothing changes until you re-import with `{ "approve": true }`. |
</details>
### Custom items — do *not* re-export for these
Shard-added items carry ids no client table has. Drop a small delimited file in a
`custom/` directory beside the base file and re-import:
```
/srv/uo-data/
clilocs.plain ← base, from this guide
custom/
01-uomysticmoon.tsv ← your additions and overrides
```
Files are read in sorted order and **later sources win**, so an overlay both adds
new ids and overrides stock ones you have re-purposed. **Adding one item never
means re-exporting a 5 MB client file.** Details in [`CLILOCS.md`](CLILOCS.md).
---
## Part 2 — Creature art for the spawn atlas (optional)
**Goal:** put sprites on atlas pages. Purely cosmetic — the atlas is fully
functional as text, and `art` is NULL on every fresh import.
**This project ships no art and no art-extraction tooling, and never will.**
1. In `UoFiddler.exe` (paths configured as in Route B step 1), open the
**Animations** tab for creature sprites — or **Items** for object art — find
the creature, and export as PNG. Right-click an entry for its export options,
or use the tab's *Export All* action for a batch. (4.22.2 added an export
option to the Animation tab's thumbnail list, which is the convenient one
here.)
2. Put the images under `server/uploads/atlas/`.
3. Copy `server/db/data/spawnAtlas.art.example.json` to `spawnAtlas.art.json` in
the same directory and map creature slugs to file names:
```json
{
"lizardman": "lizardman.png",
"orc": "orc.png"
}
```
**Keys are the slugs the atlas API reports**, derived from the type names in
your own shard's `Spawns/*.xml` — read them off the atlas rather than guessing.
A creature with no entry renders without art, which is the default.
4. Restart, or `npm run atlas:import -- --force`.
The art map is re-read on every atlas refresh, so adding one image is an edit plus
a refresh. Both `spawnAtlas.art.json` and `server/uploads/` are gitignored.
---
## Licensing, briefly
UO's strings and sprites are EA's. Extracting from **your own** client for
**your own** shard is the arrangement here; redistributing the extracted files is
not something this project does or can advise on. That is the whole reason this
page exists instead of a download link.