Files
docs/modules/uo/README.md
wtclaude 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

126 lines
9.0 KiB
Markdown

# module-uo — the Ultima Online module
Everything specific to Ultima Online that the website serves. Code:
[RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo). Module id `uo`,
installed at `modules/uo/` on the website's modules volume.
This page is **orientation**: what the module is, what it serves, what it owns, and what an operator
has to know. It is not a second copy of the contract — [`../../website/MODULE_API.md`](../../website/MODULE_API.md)
is normative for anything about how a module and core fit together, and the module's own repo is
authoritative for its file layout. Where this page and either of those disagree, they win.
Module documentation aggregates here rather than in module repos
([`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md) §2.10), so the feature docs that describe what
these routes *mean* are the ones that already existed and did not move:
| Doc | What it covers |
|---|---|
| [`API.md`](API.md) | **This module's 72 URLs** and the audience ladder that gates them — moved out of core's `BACKEND_DESIGN.md` §4/§6.5 |
| [`SCHEMA.md`](SCHEMA.md) | **The 27 tables it owns** and why they are shaped that way — moved out of core's `BACKEND_DESIGN.md` §3 |
| [`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [`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 |
| [`../../link/PLAN.md`](../../link/PLAN.md), [`../../link/INTEGRATION.md`](../../link/INTEGRATION.md) | The wire protocol this module speaks to the sidecar |
---
## What it serves
**72 URLs**, frozen in the module's own
[`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json)
and documented in its
[`swagger-fragment.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/swagger-fragment.json),
which core merges into `/api/docs.json` while the module is running. Route counts, gates and the
notes that used to sit in core's contract are in [`API.md`](API.md).
| Mount | Tier | What |
|---|---|---|
| `/api/v1/public/shard` | public | Status, activity feed, economy, presence, houses/IDOC, champs, guilds, governors, points boards, the player-vendor market, the ruleset, and the SSE event stream |
| `/api/v1/public/atlas` | public | The bestiary: creatures, regions, landmarks, champion altars, and what is loaded |
| `/api/v1/admin/shard` | admin | Shard ops (kick/ban/broadcast/pages), the visibility config, atlas and cliloc imports, market admin, account links |
| `/api/v1/admin/uo-link` | admin | The sidecar connection config, live status, and the town crier |
| `/api/v1/player/shard` | player | A player's own linked accounts: rosters, character sheets, vendors, sales |
| `/api/v1/admin/users/:id/shard/*` | admin | Six routes filling core's `admin.users.detail` **extension slot** — a module's routes hanging off a *core* resource, since core owns the user |
Every URL is byte-identical to the one core served before the extraction. That is the whole point of
moving the code and not the paths: the shipped Android app calls
`POST /api/v1/admin/shard/kick`, the Discord bot reads `/api/v1/public/shard/*`, and neither knows a
module answers now.
**In the SPA**: twelve public pages under `/uo/*`, the admin views under `/admin/uo/*`, the player
views under `/player/uo/*`, three extension-slot fills, and the nav rows for all of them, interleaved
into core's nav so an operator can reorder, relabel or hide them like any other row.
## What it owns
- **27 database tables** — 26 `shard_*` plus `uo_link_config`, documented one by one in
[`SCHEMA.md`](SCHEMA.md). Created by an idempotent
`schema.sql` fragment core replays on every boot, after its own schema. The `shard_`/`uo_link_`
prefixes are **grandfathered** ([`MODULE_API.md`](../../website/MODULE_API.md) §6.5): the rule for
a new module is `<id>_`, and these predate it.
- **Seven push notification streams** and the announce leg `towncrier`, likewise grandfathered —
they are stored in `notification_subs` and `announce_job_legs.leg` and read by the shipped Android
app, so renaming them would be a data migration plus a client break.
- **Two settings rows**: `game_account_signup` and the shard's protocol pin.
- **The public-safety filter.** Which shard event kinds may reach the *public* SSE stream is decided
here, not in core — the kinds, the streams and the filter are one file that moves together.
## For an operator
**Installing — three ways in, and none of them is a build.** A release publishes
`module-uo-<version>.tar.gz`, an install manifest `module-uo-<version>.json` carrying its `sha256`,
and a `SHA256SUMS`. **You never build anything**: the client chunk is prebuilt and the one runtime
dependency ships inside the tarball.
| | How | Where it fits |
|---|---|---|
| **Admin panel** | **Admin → Modules**, paste the URL of the release's `module-uo-<version>.json`, then press Restart when it asks | The click path — no shell on the box. Core downloads the artifact the manifest names, verifies the declared `sha256`, inspects the whole archive before writing anything, and unpacks it as `modules/uo/` |
| **`MODULES`** | Declare it in the environment and the container resolves it at every start:<br>`MODULES=uo@0.3.0=https://…/module-uo-0.3.0.json` | The compose-managed host. The running set is a line in a file you version-control. Already at that version ⇒ no network at all, so a restart with the internet down comes up unchanged |
| **By hand** | `tar -xf module-uo-0.3.0.tar.gz -C ./modules && mv modules/module-uo-0.3.0 modules/uo`, then restart | Development, and any host where the other two do not fit. The bundle's top-level directory is named after the release, not after the module id — rename it to `uo` |
The install source must be an `https` host on the allowlist (seeded from `MODULE_SOURCE_HOSTS`,
editable in the panel from then on). The `sha256` in the manifest is the trust anchor; the allowlist
is what stops a pasted URL from being an SSRF primitive as well.
**Uninstalling, and the one destructive choice.** Uninstall removes the module's directory and
leaves its row `disabled`**your data is kept**, and reinstalling picks it up exactly where it was.
Deleting the data is a separate, opt-in tick box *inside* the uninstall dialog, and it has to be
there rather than after: `purge.sql` is a file inside the directory being deleted. There is also a
standalone **Purge** action on a module that is still installed but disabled. Purging drops all 27
tables; it does **not** touch the two `settings` rows (renaming or deleting
`uo_link_protocol_3_migrated` would re-arm a protocol migration against tables that no longer
exist), and what the module can re-derive from your ServUO tree — the atlas and the cliloc table — is
rebuilt at the next boot. Everything the shard and your players produced is gone.
**Disabling is a kill switch, not a visibility flag.** Disable runs the module's `onShutdown`
immediately: the uo-link WebSocket closes, the SSE streams end, and its routes, nav rows and client
chunk answer 404. Re-enabling flips the row and asks for a restart, because there is no `onBoot`
re-dispatch — the hooks have never been promised to be re-entrant.
**Connecting it to a shard.** The module needs the
[uo-link sidecar](https://gitea.whitlocktech.com/RunicGateway/link) running next to the ServUO
shard. Deploy that with the [installer](https://gitea.whitlocktech.com/RunicGateway/installer) —
[`../../installer/INSTALL.md`](../../installer/INSTALL.md) is the operator guide — and paste the four
values it prints into **Admin → Shard**. The token is encrypted at rest and write-only in the API.
**Nothing requires the shard to exist.** With no sidecar configured the site renders normally and
shows the shard offline. That is the same bargain the module system makes one level up: a module
that fails to load never takes the site down.
**Environment variables** — four, all optional, all read by the module and documented in its README:
`UOLINK_BASE_URL`, `UOLINK_WS_URL`, `UOLINK_PROTOCOL`, `TOWNCRIER_DURATION_SEC`. They live in the
Compose `.env`, because that is what reaches the container.
## Compatibility
`module.json` declares a `coreApi` semver range, checked at boot against core's
`MODULE_API_VERSION`. A mismatch fails **loudly** — the module is marked `startup_failed` and the
site comes up without it. This is a separate number from `PROTOCOL_VERSION`, which versions the
shard wire and says nothing about a website module.
The module's CI clones core at a **pinned** ref ([`MODULE_API.md`](../../website/MODULE_API.md) §5.3)
to generate its frozen manifest. Bumping that pin is a deliberate commit that says which core the
module was last proved against — not a tracking reference that turns core's unrelated changes into
red Xes here.