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
21 KiB
module-uo — the tables it owns
The 27 tables module-uo creates and owns, and the reasoning behind their shapes. 26 shard_*
plus uo_link_config, all created by the idempotent server/db/schema.sql fragment core replays
after its own schema on every boot, and all dropped by server/db/purge.sql when an operator purges
the module's data.
This page moved out of BACKEND_DESIGN.md §3 when Phase 4 closed
(MODULE_SYSTEM.md §2.7.2): core's schema reference describes
core's tables, and these are not core's. The text is unchanged — where it says "the shard", it means
the Ultima Online shard this module fronts. Core's own module bookkeeping table,
installed_modules, stays in BACKEND_DESIGN.md where it belongs.
The shard_ and uo_link_ prefixes are grandfathered
(MODULE_API.md §6.5). A new module's tables are prefixed with its
own id; these predate the rule, and they are read by the shipped Android app, so renaming them would
be a data migration plus a client break.
Not every table has a section here. The pre-3.0 ingest tables (shard_events, shard_online,
shard_economy, shard_houses, shard_champs, shard_guilds, shard_governors,
shard_governor_terms, shard_presence, shard_pages, shard_account_links, uo_link_config)
are described where their wire frames are — link/PLAN.md §5 and
link/INTEGRATION.md. What follows is everything that carried a
design note worth keeping.
shard_ruleset — the shard's published ruleset (Protocol 3.0)
Singleton row (id = 1, CHECK-constrained) holding the latest world.ruleset frame: rev,
expansion, payload JSON (the whole frame), t, updated_at. The shard re-emits the complete
ruleset on every sidecar connect, so this is an overwrite, not an append — and the kind is
deliberately not in LOGGED_KINDS, since logging it would put a duplicate row in shard_events
on every reconnect while server.hello already marks each of those.
The frame is stored whole rather than normalized into columns: it is a flat description of server
config that is read as one page, so splitting it up would mean a schema change every time the shard
grows a new block. rev (the shard's FNV-1a of the body) and expansion are hoisted only because
they are cheap to display — the same payload-plus-hoisted-columns shape shard_champs uses.
No row means the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false),
served as null rather than {}: "not published yet" and "published, everything off" are different
answers and the page renders them differently.
shard_points_boards — points / loyalty leaderboards (Protocol 3.0)
One row per point system, keyed by the shard's own PointsType name (QueensLoyalty,
CleanUpBritannia, …). The shard carries ~25 of these, each a standing players build over months.
Columns: system (PK), name, name_cliloc, max_points, players, show_on_gump, payload JSON
(the whole points.board frame), t, updated_at.
The top-N list stays inside payload rather than being normalized into a shard_points_entries
table. It is a fixed-size list (10 by default) that is only ever read whole — exactly like
shard_governors.candidates — so normalizing buys nothing until something needs a per-character
reverse lookup, and a character's own standings already ride inside char.profile instead.
Board state, not events: points.board is not in LOGGED_KINDS, for the same reason
guild.update isn't. The shard emits a frame every time anyone's score moves a top ten, so logging
would grow shard_events without bound for something whose only interesting value is its latest
version. There is also no delete path — the shard's set of systems is fixed at startup, so there is
no points.remove to mirror.
Two values carry non-obvious meanings, both set by the plugin and both documented in
link/INTEGRATION.md §4:
max_points = 0means uncapped, and on a real shard that is the common case (ServUO's uncapped idiom isdouble.MaxValue, which the plugin normalises to 0). Anything renderingpoints / max_pointsmust special-case it.nameis usually NULL, withname_clilocset instead — most systems name themselves with a cliloc rather than a literal. Listing therefore orders byCOALESCE(name, system), so boards awaiting cliloc resolution sort by their own key rather than clumping together under NULL.
shard_vendors / shard_vendor_items — the player-vendor marketplace (Protocol 3.0)
The shard-wide shop index, fed by vendor.listing / vendor.listing.remove. One row per player
vendor and one per priced listing. Full operator detail in MARKETPLACE.md; the
design is docs/link/v3.md §8.
| Table | Shape |
|---|---|
shard_vendors |
serial (PK), shop_name, owner_serial, owner_name, map/x/y/z, region, house, item_count, item_total, truncated, t, updated_at. Indexes on owner, map, region and updated_at. |
shard_vendor_items |
id (PK), vendor_serial, serial, item_id, hue, amount, price, name, cliloc, display_name, child. Indexes on vendor_serial, price, item_id, display_name, and (display_name, price). |
Ingest is per-vendor and authoritative: the frame is the whole shop, so ingest is
delete-then-insert of that vendor's listings inside one transaction. All-or-nothing matters
specifically because the two writes are "the shop" and "what is in it" — a failure between them
leaves a shop advertising an inventory it no longer has, which is visibly wrong and indistinguishable
from a genuinely empty shop. No foreign keys, consistent with every other shard_* table.
There is deliberately no payload column, unlike shard_points_boards directly above. The
board's top-N is a fixed-size list read whole, so it lives in JSON; here the items are the
searchable rows, so they are normalized and nothing is left worth duplicating. The sidecar keeps the
whole blob — outage resilience is its job, search is ours.
Market state, not events: neither kind is in LOGGED_KINDS, and this is the strongest case of the
three v3 kinds. One frame carries up to 250 listings and the sweep re-emits a shop on any price
change, so logging would turn shard_events into a price history nobody reads.
Two columns carry non-obvious meanings:
item_countvsitem_total.item_countis what the frame published;item_totalis what the shop actually holds. They differ whentruncated— the shard caps listings per frame (Bridge.MarketMaxListings, 250 by default), and a commodity reseller with thousands of stacks genuinely exceeds it. Any UI must show both or it presents a partial shop as complete.display_nameis denormalized at ingest, resolved from the item's literalname(preferred — a player set it, so it is more specific) else itsclilocagainstshard_clilocs. Resolving at query time would put the cliloc table on the hot path and make search-by-name impossible. Because the shard's diff sweep will not re-send an unchanged shop just because the site learned what its items are called, a cliloc import triggers a bulk re-resolution of this column (after a boot import and after an admin import; ~50 ms per thousand rows, never throws).
updated_at is written explicitly on every upsert rather than left to ON UPDATE CURRENT_TIMESTAMP,
which MariaDB does not fire when every column is written back unchanged. A shop re-published
identically is still freshly confirmed, and without this the staleness banner would age a perfectly
current shop forever.
shard_feature_visibility — per-feature audience config (Protocol 3.0)
One row per shard feature: feature (PK), enabled, audience (a rung on the ladder in API.md),
stream (whether the feature's kinds fan out over SSE at all), field_rules JSON ({field: rung}
for the sensitive fields only), updated_by, updated_at.
An absent row means "use the compiled default", and the compiled defaults reproduce pre-3.0
behavior — so an empty table is a no-op and there is nothing to seed. Stored rows are merged over
the defaults on read, which is also where the invariants are re-applied: a row naming an unknown
feature is ignored (a stale row must not resurrect a removed feature), an invalid rung falls back to
the default rather than failing open, and a rule touching a locked field (acct / webId) is
discarded. See API.md.
shard_spawn_* / shard_regions / shard_landmarks / shard_champion_spawns / shard_atlas_meta — the spawn atlas (Protocol 3.0)
Static shard content, not live shard state. Nothing here comes from the sidecar: the atlas is
derived from the shard's own ServUO tree, re-read on every server boot and hash-gated so an
unchanged tree costs one read pass and no write. Nothing is precomputed and committed — a shard's
maps change over its life, and a snapshot in the repo would silently drift from the world players
actually see. These tables stay populated whether the shard is up or not. Full operator detail in
SPAWN_ATLAS.md; the design is docs/link/v3.md §6.
No facet name appears anywhere in the code. A shard may add facets, replace them, or rename them
when its maps are updated; the facet set is discovered from the tree, and the loose spellings in
Data/Locations are matched against it rather than looked up in a table.
| Table | Key columns |
|---|---|
shard_spawn_creatures |
slug PK, name, total, points, facets JSON, art NULL |
shard_spawn_points |
id PK, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, tod_start/end/mode, region, landmark, label |
shard_spawn_point_types |
(point_id, slug) PK, max_count |
shard_regions |
facet, name, type, priority, parent, rects JSON |
shard_landmarks |
facet, name, grp, x, y, z |
shard_champion_spawns |
slug PK, name, grp, type, random_type, facet, x, y, z, radius, label |
shard_atlas_meta |
Singleton (id = 1), payload JSON (counts, a sha256 per source file, parserVersion), imported_at |
shard_atlas_pending |
Singleton (id = 1), status (pending/rejected), payload JSON, detected_at |
The first seven are import-owned: a refresh empties and reloads every one inside a single
transaction, so a failed reload leaves the previous atlas intact rather than a half-loaded world.
Nothing else writes to them, and nothing holds a foreign key to them — no FKs at all, consistent with
every other shard_* table.
shard_atlas_pending is the security-relevant one. A refresh that would REMOVE a facet is never
applied automatically: facet loss is indistinguishable at boot from a half-copied or mid-update tree,
so it is staged here for an admin to approve or reject, and startup is never blocked by it. Only
the decision is stored — source hashes plus the facet diff, a few KB — and approving re-parses the
tree, so a multi-megabyte blob never lands in the database and what gets applied matches the tree at
approval time. A rejection is remembered against those exact hashes so a declined refresh does not
re-prompt on every restart. Everything else (new facets, renamed regions, changed spawns) applies
immediately, since none of it can destroy data an operator would miss.
The boot refresh is best-effort by contract: no configured path, an unreadable mount, a malformed
file or a database error is caught and logged, and the site comes up serving whatever atlas it had.
The tree path comes from the spawn_atlas_servuo_path setting, falling back to SERVUO_PATH.
A refresh re-derives when the tree changed OR the parser did. spawnAtlasSource.PARSER_VERSION
is stored in shard_atlas_meta beside the source hashes and bumped whenever the parser produces
different data from identical files. Hashing the tree alone would strand an install whose maps never
change on whatever an older build derived — a corrected parse would ship and never reach the data.
Four column choices worth stating, because each one is a trap:
spawn_range, notrange, andgrp, notgroup— both are reserved words.DELETE, notTRUNCATE.TRUNCATEis DDL in MariaDB and implicitly commits, which would defeat the all-or-nothing reload. At ~7k rows the difference does not matter.- Point ids are assigned explicitly, not left to
AUTO_INCREMENT: theshard_spawn_point_typesrows need to know them, andconn.batch()reports no usableinsertIdfor a multi-row insert. - Plain
INDEXonname, deliberately notFULLTEXT. ~800 creature rows makes aLIKEscan free, and FULLTEXT's minimum token length would break searches for names like "orc".
shard_champion_spawns is the configured altar roster ("there is an Unholy Terror altar in
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 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 §6, §8, §12).
| Table | Shape |
|---|---|
shard_assets |
asset_key VARCHAR PK (§5's key, e.g. body/34/a0), family, sha256, bytes, width, height, body, direction, file, 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:
fileis 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
artderivation joins on the catalogue key,a.asset_key = CONCAT('body/', b.body, '/a0'), nota.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_bodiesIS replaced whole, unlikeshard_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.statuskeeps 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.
shard_clilocs / shard_cliloc_meta — UO's localization table (Protocol 3.0)
Items on the wire carry a LabelNumber, not a name. The bridge has always sent it —
char.profile.equipment.cliloc, reward titles as a cliloc number in string form, and one per
marketplace listing — but with no table to resolve it against, the character sheet could only render
id 1023721 where the game renders "quarter staff".
| 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 |
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.
It reads a SET of sources, not one file, because shards edit items and add new ones and those
carry cliloc ids no stock client table has. A base (the converted client table) plus every overlay
under custom/ are re-read on every boot and hash-gated together, exactly as the atlas re-reads
Regions.xml + Locations/*.xml + Spawns/*.xml + ChampionSpawns.xml. Later sources win, so an
overlay both adds ids and overrides stock ones, and adding one custom item never means re-exporting a
5 MB client file. Scale, measured on the live shard: its script tree references 16,434 cliloc ids and
only 37 are absent from stock — tens of entries against a 67k base, which is why this is an overlay
and not a second table.
The conversion step is not avoidable: every current client ships its cliloc files compressed
(first DWORD's high byte 0x8E), and ServUO's own bundled Ultima.StringList cannot read that
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.
Three decisions worth stating:
textis TEXT, not VARCHAR. Long property descriptions reach 12 KB. The index that matters for marketplace search is the denormalizedshard_vendor_items.display_name, not this table.- Blank entries are dropped at import — 123,490 parsed → 67,496 stored. Roughly half a cliloc table is empty strings for ids the client reserves and never uses; a row that resolves to no name is indistinguishable from no row at all, and dropping them makes the binary and text imports converge on identical content.
- Two refusals, one of them the atlas's. A corrupt source fails the parse on a truncated record,
so it is caught outright and leaves the previous table serving. But a source that has vanished
parses perfectly and imports a table quietly missing everything it contributed — an unmounted volume
and a deliberate deletion are indistinguishable from here, which is precisely the ambiguity the
atlas stages a facet removal for. So it is escalated:
status: 'needsReview', nothing applied,missingSourcesreported by both the import andstatus(), and an admin accepts it with{approve:true}. That is a flag rather than the atlas's approve/reject pair because the atlas stores a pending decision so that approving re-parses the tree; here nothing is stored, so re-reading at approval time is automatic.
Resolution is server-side and there is no public route. The table is never served as a table:
67k rows would dwarf any page using them, and the Android client consumes the same already-resolved
JSON. resolveMany() returns only ids that resolved to something displayable — placeholders like
~1_val~ are stripped, since the bridge sends the id and never the property packet that carries the
arguments — and it never throws, because a cliloc lookup is decoration on a character sheet.