1 Commits

Author SHA1 Message Date
f6a22734d3 docs(android): link the M11 app PRs and name the remaining gate
Both parts are built: Android-app#30 (the visibility rules + read-model adds)
and #31 (the four screens, stacked on it). The on-device five-rung walk against
a website on the cutover branch is what edge->main is now actually waiting on.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 02:51:53 -05:00
10 changed files with 26 additions and 810 deletions

View File

@@ -22,7 +22,6 @@ ci/ cross-cutting CI/quality notes
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework | | [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: what the shard contains, parsed from its own ServUO tree | | [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 | | [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 | | [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) | | [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout | | [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |

View File

@@ -913,6 +913,10 @@ push, and Play (M6M8) follow the designed app.
`/public/shard/features` simply `404`s, and each consumer below falls back to exactly today's `/public/shard/features` simply `404`s, and each consumer below falls back to exactly today's
behavior. The app declares no protocol version and never talks to the sidecar. behavior. The app declares no protocol version and never talks to the sidecar.
**Both parts are built and in review:** Part 1 `RunicGateway/Android-app#30`, Part 2 (stacked on
it) `#31`. 336 unit tests pass and lint is clean on both; the on-device five-rung walk below is
the remaining gate, and it is what the cutover is actually waiting on.
- **Part 1 — the visibility rules + the read-model adds.** The security-shaped half, reviewed on - **Part 1 — the visibility rules + the read-model adds.** The security-shaped half, reviewed on
its own: its own:
- `GET /public/shard/features` → `{ level, features[] }`: the features **this caller** may reach. - `GET /public/shard/features` → `{ level, features[] }`: the features **this caller** may reach.
@@ -970,22 +974,14 @@ push, and Play (M6M8) follow the designed app.
(`atlas`). Note the path: `/public/atlas`, **not** `/public/shard` — the atlas is static shard (`atlas`). Note the path: `/public/atlas`, **not** `/public/shard` — the atlas is static shard
*content*, not live shard *state*, and unlike `/shard/*` it **is** `siteMode`-gated like *content*, not live shard *state*, and unlike `/shard/*` it **is** `siteMode`-gated like
`/posts` and `/wiki`, so a site in maintenance mode withholds it independently of the sidecar. `/posts` and `/wiki`, so a site in maintenance mode withholds it independently of the sidecar.
Three traps. Two are units/naming, from `v3.md` §6.3: respawn delays are **seconds** Two units/naming traps from `v3.md` §6.3: respawn delays are **seconds** throughout, and
throughout, and `points` is a *count* on the search route while `spawners` is the *list* on `points` is a *count* on the search route while `spawners` is the *list* on the detail route.
the detail route. The third is a **shape**: `places` is a list of
`{facet, label, spawners, maxAlive}` **objects**, not of place-name strings — it is the
aggregate the screen exists to show ("Shrines, Isamu-Jima, Yew"), it arrives only on the
detail route, and typing it `List<String>` makes that whole route fail to decode while the
request itself returns `200`.
- **Verification** — the five-rung walk (`anonymous`, `logged_in`, `player`, `staff`, `admin`) - **Verification** — the five-rung walk (`anonymous`, `logged_in`, `player`, `staff`, `admin`)
against a local website on the cutover branch, per against a local website on the cutover branch, per
[`../link/v3.md`](../link/v3.md) §11 and the shard-visibility smoke harness; plus one pass with [`../link/v3.md`](../link/v3.md) §11 and the shard-visibility smoke harness; plus one pass with
**every feature disabled** in Admin → Shard Visibility, confirming the app *hides* each surface **every feature disabled** in Admin → Shard Visibility, confirming the app *hides* each surface
instead of erroring on it. Unit tests cover the menu filter (role × feature set), the instead of erroring on it. Unit tests cover the menu filter (role × feature set), the
`404`/`403`/`503` mapping, and DTO decode for each new shape. **Decode tests must feed real `404`/`403`/`503` mapping, and DTO decode for each new shape.
captured JSON**, not DTOs built in Kotlin: the fakes under `data/api/fake/` construct objects
directly, so they can never catch a wire/type mismatch — which is how the `places` shape above
shipped past a green suite.
- **Excluded**, in the same class as M10's exclusions: the admin *configuration* panels — Shard - **Excluded**, in the same class as M10's exclusions: the admin *configuration* panels — Shard
Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot
config, uo-link config and OAuth-provider setup. config, uo-link config and OAuth-provider setup.

View File

@@ -85,7 +85,6 @@ android-app/
│ │ │ │ │ │ │ ├── PlayerShardDto.kt │ │ │ │ │ │ │ ├── PlayerShardDto.kt
│ │ │ │ │ │ │ ├── PostDto.kt │ │ │ │ │ │ │ ├── PostDto.kt
│ │ │ │ │ │ │ ├── PublicDto.kt │ │ │ │ │ │ │ ├── PublicDto.kt
│ │ │ │ │ │ │ ├── ShardContentDto.kt
│ │ │ │ │ │ │ ├── ShardDto.kt │ │ │ │ │ │ │ ├── ShardDto.kt
│ │ │ │ │ │ │ ├── SsoDto.kt │ │ │ │ │ │ │ ├── SsoDto.kt
│ │ │ │ │ │ │ └── WikiDto.kt │ │ │ │ │ │ │ └── WikiDto.kt
@@ -107,7 +106,6 @@ android-app/
│ │ │ │ │ ├── NotificationsRepository.kt │ │ │ │ │ ├── NotificationsRepository.kt
│ │ │ │ │ ├── PlayerShardRepository.kt │ │ │ │ │ ├── PlayerShardRepository.kt
│ │ │ │ │ ├── SettingsRepository.kt │ │ │ │ │ ├── SettingsRepository.kt
│ │ │ │ │ ├── ShardFeaturesRepository.kt
│ │ │ │ │ ├── ShardRepository.kt │ │ │ │ │ ├── ShardRepository.kt
│ │ │ │ │ └── WikiRepository.kt │ │ │ │ │ └── WikiRepository.kt
│ │ │ │ ├── di/ │ │ │ │ ├── di/
@@ -173,8 +171,6 @@ android-app/
│ │ │ │ │ ├── session/ │ │ │ │ │ ├── session/
│ │ │ │ │ │ └── SessionViewModel.kt │ │ │ │ │ │ └── SessionViewModel.kt
│ │ │ │ │ ├── shard/ │ │ │ │ │ ├── shard/
│ │ │ │ │ │ ├── AtlasScreen.kt
│ │ │ │ │ │ ├── AtlasViewModel.kt
│ │ │ │ │ │ ├── ChampsScreen.kt │ │ │ │ │ │ ├── ChampsScreen.kt
│ │ │ │ │ │ ├── ChampsViewModel.kt │ │ │ │ │ │ ├── ChampsViewModel.kt
│ │ │ │ │ │ ├── FrameFields.kt │ │ │ │ │ │ ├── FrameFields.kt
@@ -184,13 +180,7 @@ android-app/
│ │ │ │ │ │ ├── GuildsViewModel.kt │ │ │ │ │ │ ├── GuildsViewModel.kt
│ │ │ │ │ │ ├── HousesScreen.kt │ │ │ │ │ │ ├── HousesScreen.kt
│ │ │ │ │ │ ├── HousesViewModel.kt │ │ │ │ │ │ ├── HousesViewModel.kt
│ │ │ │ │ │ ├── LeaderboardsScreen.kt
│ │ │ │ │ │ ├── LeaderboardsViewModel.kt
│ │ │ │ │ │ ├── LiveBoard.kt │ │ │ │ │ │ ├── LiveBoard.kt
│ │ │ │ │ │ ├── MarketScreen.kt
│ │ │ │ │ │ ├── MarketViewModel.kt
│ │ │ │ │ │ ├── RulesScreen.kt
│ │ │ │ │ │ ├── RulesViewModel.kt
│ │ │ │ │ │ ├── ShardComponents.kt │ │ │ │ │ │ ├── ShardComponents.kt
│ │ │ │ │ │ ├── ShardEventText.kt │ │ │ │ │ │ ├── ShardEventText.kt
│ │ │ │ │ │ ├── ShardScreen.kt │ │ │ │ │ │ ├── ShardScreen.kt
@@ -294,7 +284,6 @@ android-app/
│ │ │ │ │ ├── PlayerShardDtoTest.kt │ │ │ │ │ ├── PlayerShardDtoTest.kt
│ │ │ │ │ ├── PublicDtoTest.kt │ │ │ │ │ ├── PublicDtoTest.kt
│ │ │ │ │ ├── ShardBoardDtoTest.kt │ │ │ │ │ ├── ShardBoardDtoTest.kt
│ │ │ │ │ ├── ShardContentDtoTest.kt
│ │ │ │ │ ├── ShardDtoTest.kt │ │ │ │ │ ├── ShardDtoTest.kt
│ │ │ │ │ ├── SsoDtoTest.kt │ │ │ │ │ ├── SsoDtoTest.kt
│ │ │ │ │ └── WikiDtoTest.kt │ │ │ │ │ └── WikiDtoTest.kt
@@ -305,8 +294,7 @@ android-app/
│ │ │ │ └── FakeShardStream.kt │ │ │ │ └── FakeShardStream.kt
│ │ │ └── repository/ │ │ │ └── repository/
│ │ │ ├── AccountTrustedDevicesTest.kt │ │ │ ├── AccountTrustedDevicesTest.kt
│ │ │ ── ConnectionVersionGuardTest.kt │ │ │ ── ConnectionVersionGuardTest.kt
│ │ │ └── ShardFeaturesRepositoryTest.kt
│ │ ├── ui/ │ │ ├── ui/
│ │ │ ├── admin/ │ │ │ ├── admin/
│ │ │ │ ├── AdminContentViewModelTest.kt │ │ │ │ ├── AdminContentViewModelTest.kt
@@ -316,8 +304,7 @@ android-app/
│ │ │ ├── contact/ │ │ │ ├── contact/
│ │ │ │ └── ContactViewModelTest.kt │ │ │ │ └── ContactViewModelTest.kt
│ │ │ ├── navigation/ │ │ │ ├── navigation/
│ │ │ │ ── MenuAccessTest.kt │ │ │ │ ── MenuAccessTest.kt
│ │ │ │ └── MenuFeatureGatingTest.kt
│ │ │ ├── notifications/ │ │ │ ├── notifications/
│ │ │ │ └── NotificationRoutingTest.kt │ │ │ │ └── NotificationRoutingTest.kt
│ │ │ ├── player/ │ │ │ ├── player/
@@ -328,8 +315,6 @@ android-app/
│ │ │ │ ├── FrameFieldsTest.kt │ │ │ │ ├── FrameFieldsTest.kt
│ │ │ │ ├── LiveBoardTest.kt │ │ │ │ ├── LiveBoardTest.kt
│ │ │ │ ├── ShardBoardViewModelTest.kt │ │ │ │ ├── ShardBoardViewModelTest.kt
│ │ │ │ ├── ShardContentHelpersTest.kt
│ │ │ │ ├── ShardContentViewModelTest.kt
│ │ │ │ └── ShardEventTextTest.kt │ │ │ │ └── ShardEventTextTest.kt
│ │ │ ├── theme/ │ │ │ ├── theme/
│ │ │ │ └── BrandColorTest.kt │ │ │ │ └── BrandColorTest.kt

View File

@@ -1,376 +0,0 @@
# Runic Gateway Installer — plan
Status: **planning**. No installer code exists yet. This document is the design of record; it
supersedes the informal overview it grew out of, which described a ServUO integration that does not
match how `servuo-plugins` actually ships (see [Corrections](#corrections-to-the-original-overview)).
---
## 1. Purpose
Take a stock ServUO installation and configure it for Runic Gateway with minimal manual steps, while
keeping the components separated and independently maintainable.
The installer handles environment detection, ServUO overlay deployment, the optional stock-file
patch tier, uo-link installation and service registration, version tracking, diagnostics, and
updates from Gitea releases.
**It is a deployment tool, not a hosted bootstrapper.** There is no `curl | bash`, no installer
service, and no hosted bootstrap script. Artifacts are downloaded from a Gitea release page and run.
**It does not replace ServUO startup behavior.** ServUO keeps running through its existing
release/start scripts. The installer never writes a launcher.
### Decisions locked
| Question | Decision |
|---|---|
| Audience | **Public** — any ServUO operator, not just shards we run |
| Code signing | **Unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen/Gatekeeper warnings are expected and documented, as with most self-hosted tooling |
| Language | **Rust** — single static binary per OS, reuses the cross-compile pattern already proven in `link/.gitea/workflows/release.yml` |
| Plugin source | **Release tarball artifact** — no git and no Gitea credentials on the shard host |
| Token handoff | **Print token + prefilled admin URL** at the end of the run |
| Repo | **New repo**, `RunicGateway/installer`. It deploys *both* other components, so living inside `link/` would invert the dependency |
| ServUO version | **Warn and skip.** Patches are verified against stock 57.4 only; on anything else the base install proceeds and the patch tier is skipped with a warning. Forks are the norm in a public audience — refusing outright would block most operators |
| Uninstall | **Never touches the ServUO tree.** Removes uo-link and its service entry, then *prints* the overlay files to delete and the patch hunks to revert. Reverting is the operator's call |
---
## 2. Corrections to the original overview
These are not wording nits — each one changes what the installer has to do.
### 2.1 There is no `RunicGateway.dll` and no `Plugins/` directory
The plugin ships as **C# source** and ServUO compiles it at boot. The real deployable is
`servuo-plugins/overlay/`, which mirrors the server root:
```
overlay/
├── Config/Bridge.cfg
└── Scripts/
├── Scripts.csproj # Phase 0 — whole-file overwrite of a stock file
└── Custom/Bridge/*.cs # 22 files
```
So the plugin step is a hash-compare file sync, not a DLL drop — mechanically easier than the
overview assumed. The sting is that **a successful copy does not mean a working bridge.** Per
`link/SHARD_PREREQS.md`, `ScriptCompiler.Compile()` shells out to `dotnet build`, prints the output,
**ignores the exit code**, and reloads the existing `Scripts.dll`. A broken script build is
invisible: the shard boots clean on stale code. Diagnostics must therefore verify *post-boot* state,
never treat "files copied" as success.
### 2.2 Stock ServUO files *are* modified — by an optional tier
`servuo-plugins/patches/` holds unified diffs against stock ServUO 57.4, plus two `.cs` files that
can only be copied *after* their patch lands (they reference symbols the patch introduces):
| Patch | Target | Companion file | Rebuild required |
|---|---|---|---|
| `playervendor-sale-eventsink.patch` | `Server/EventSink.cs` | `BridgeVendorSale.cs` | **Core**`dotnet build ServUO.sln`; the dynamic script build is not enough |
| `playervendor-sale-gump.patch` | `Scripts/Gumps/PlayerVendorGumps.cs` | (same unit as above) | script build |
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | `BridgeModerationAudit.cs` | script build |
Plus `overlay/Scripts/Scripts.csproj`, which overwrites a stock file (Phase 0 — it fixes the silent
ServUO build bug above).
This is the hardest part of the installer. `git apply` against a hand-modified shard will fail, and
most real shards are hand-modified. Therefore:
- The patch tier is **opt-in and skippable**. The base install must complete without it.
- Always dry-run (`git apply --check`) before applying, and report per-patch.
- When skipped or failed, say plainly what is lost: **no `vendor.sale` events, no in-game moderation
audit forwarding**.
- The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a
shard restart.
- On any ServUO version other than stock **57.4**, skip the whole tier with a warning and continue
with the base install. Do not attempt to apply unverified diffs to an unknown tree.
- Record applied patches in `install.json`, **and cache the applied `.patch` files** next to it
(`/etc/runicgateway/patches/`, `%ProgramData%\RunicGateway\patches\`). Re-runs stay idempotent,
and uninstall can print the exact hunks offline long after the release tarball is gone (§5,
Phase 4).
### 2.3 Config paths collide with what the sidecar actually reads
The sidecar reads `$UOLINK_CONFIG`, else `sidecar.toml` in the **working directory**
(`link/sidecar/src/config.rs`), with keys `[shard].bind`, `[web].bind`, `[web].auth_token`,
`[store].path`. The overview proposed a `config.toml` with `[updates]`, `[link]`, `[servuo]` — keys
the sidecar cannot read.
Two files, two owners:
| File | Owner | Contents |
|---|---|---|
| `/etc/runicgateway/sidecar.toml` | uo-link | The sidecar's own schema, unchanged. Service sets `UOLINK_CONFIG` to this path |
| `/etc/runicgateway/install.json` | installer | Deployed versions, file hashes, applied patches, ServUO path, timestamps |
**Working-directory trap:** the sidecar writes both `sidecar.toml` and `uo-link.db` relative to CWD.
Under `C:\Program Files\` that fails or silently lands in VirtualStore. The service definitions must
pin `UOLINK_CONFIG` and `UOLINK_DB_PATH` explicitly:
- Linux: config `/etc/runicgateway/sidecar.toml`, db `/var/lib/runicgateway/uo-link.db`, dedicated
service user
- Windows: binary under `%ProgramFiles%\RunicGateway\`, **data under `%ProgramData%\RunicGateway\`**
### 2.4 The token handoff was missing entirely
The whole point is the website reaching the sidecar, and today that is manual and undocumented in
the install flow: the sidecar generates a token on first run and logs it, then a human pastes base
URL, WS URL, token, and protocol version into Admin → Shard, where it is AES-GCM encrypted and
becomes write-only. This is the largest "I installed it and nothing happened" failure mode.
The installer closes it by printing a copy-paste block at the end of a successful run — see §6.
### 2.5 `deploy.ps1` cannot be the cross-platform deployer
It is PowerShell-only; a Linux ServUO host running .NET typically has no `pwsh`. It also hard-throws
when the ServUO process is running — correct behavior, and the installer must inherit it (detect and
refuse, rather than corrupt a live `Scripts.dll`). The installer reimplements the sync natively; it
is a short hash-compare-and-copy that never deletes.
`deploy.ps1` **stays** in `servuo-plugins` as the developer-facing tool. The installer is for
operators.
### 2.6 Prerequisites the overview assumed away
- **`servuo-plugins` has no release workflow.** Only `link` does. "Pull latest repository" is
replaced by a release tarball, which has to be built first (Phase 0).
- **arm64 is not buildable today.** `link/release.yml` cross-compiles only
`x86_64-unknown-linux-gnu` and `x86_64-pc-windows-gnu`. An arm64 `.deb` needs another cross
toolchain.
- **The compat matrix has no home.** `PROTOCOL_VERSION` currently lives only in
`link/sidecar/src/main.rs`. The sidecar publishes it via `X-UOLink-Version` and `/health`, and the
website stores an expected value — but the *plugin's* protocol version is not queryable before
boot. See §7.
---
## 3. Distribution model
Components are published as Gitea release artifacts. Operators download from the release page
(browser, `curl`/`wget`, or `scp` to the server) and run the binary.
```
Runic Gateway Installer v1.0.0
├── runicgateway-installer-windows-x86_64.exe
├── runicgateway-installer-linux-x86_64
└── SHA256SUMS
uo-link v3.x.y (existing release, extended)
├── uo-link-sidecar-windows-x86_64.exe
├── uo-link-sidecar-linux-x86_64
├── runicgateway-link_<ver>_amd64.deb (Phase 5)
└── SHA256SUMS
servuo-plugins v<ver> (new release, Phase 0)
├── runicgateway-overlay-<ver>.tar.gz # overlay/ + patches/ + manifest.json
└── SHA256SUMS
```
```bash
scp runicgateway-installer-linux-x86_64 user@server:/tmp/
chmod +x runicgateway-installer-linux-x86_64
sudo ./runicgateway-installer-linux-x86_64
```
### Unsigned-binary posture
Because releases are unsigned, trust is anchored on checksums and the operator's own verification.
The docs must state this up front rather than let users discover it as a scary dialog:
- Every release publishes `SHA256SUMS`; the install docs lead with the verification command for both
OSes.
- Windows will show a SmartScreen "unrecognized app" prompt. Documented, with the exact click path.
- The installer verifies the SHA256 of everything **it** downloads (overlay tarball, sidecar binary)
against the release's `SHA256SUMS` and refuses on mismatch. Self-verification is not optional just
because the installer itself is unsigned.
- Revisit signing if it ever becomes affordable; the release layout should not have to change.
---
## 4. Component architecture
```
Runic Gateway Installer (Rust, one binary per OS)
┌───────────────┴────────────────┐
▼ ▼
ServUO integration uo-link
│ │
┌────────┴────────┐ ┌────────┴────────┐
▼ ▼ ▼ ▼
overlay sync patch tier (opt-in) binary install service registration
(never deletes) (git apply + guard) + config + data (systemd / Windows SCM)
```
Each component keeps its own lifecycle. ServUO's existing startup process is untouched.
---
## 5. Phases
### Phase 0 — prerequisites (no installer code)
Repo work that must land before an installer can exist.
1. **`servuo-plugins`: add `.gitea/workflows/release.yml`.** Retarget the release *engine* half of
`link/release.yml` (its header comment explicitly anticipates this — the plan/release steps
consume only `{version, changelog, artifacts}`). The adapter half produces
`runicgateway-overlay-<ver>.tar.gz` containing `overlay/`, `patches/`, and a `manifest.json`
(version, commit, per-file SHA256, declared protocol version, minimum ServUO version).
2. **`link`: make the sidecar installable.** Confirm/settle default data paths, and add a way to
read back config non-interactively (e.g. `--print-config` emitting JSON: bind addresses, token,
protocol version, db path) so the installer does not have to scrape logs for the token.
3. **Decide and document the compat matrix format** (§7).
4. **`docs`: this file, plus `docs/installer/INSTALL.md`** (the operator-facing guide) once the
shape is settled.
### Phase 1 — installer core
- ServUO root detection and validation (`ServUO.exe`, `Scripts/`, `Config/`), with version detection
and an explicit refusal when the ServUO process is running.
- Overlay sync: fetch tarball → verify SHA256 → hash-compare against the server tree → add/change,
**never delete**. Port of `deploy.ps1` semantics including its `-Verify` dry run (`--verify`).
- Write `install.json`: component, version, source commit, per-file hashes, applied patches,
timestamp.
- Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing.
### Phase 2 — uo-link install and service
- Linux: binary → `/usr/bin/runicgateway-link`, config → `/etc/runicgateway/sidecar.toml`, db →
`/var/lib/runicgateway/`, systemd unit with a dedicated user, `enable` + `start`.
- Windows: `%ProgramFiles%\RunicGateway\`, data in `%ProgramData%\RunicGateway\`, service
registration with automatic start and restart-on-failure.
- Both: `UOLINK_CONFIG` and `UOLINK_DB_PATH` pinned in the service definition (§2.3).
- Token surfacing (§6).
### Phase 3 — patch tier (opt-in)
Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and
degrade loudly rather than silently.
### Phase 4 — diagnostics and updates
`runicgateway doctor` — the command that makes the whole thing supportable:
```
✓ ServUO found /opt/ServUO (57.4)
✓ Overlay in sync 23 files, all hashes match install.json
⚠ Patch tier 1 of 3 applied — vendor.sale unavailable
✓ uo-link installed 3.0.1
✓ Service running, enabled
✓ Sidecar reachable 127.0.0.1:8080 /health ok
✓ Protocol sidecar 3 = overlay manifest 3
✗ Shard connected no shard has dialed in since boot
```
The last check matters most: it is the only thing that distinguishes "files copied" from "the bridge
actually works" (§2.1).
`runicgateway update` — asymmetric by component, deliberately:
- **uo-link**: query the Gitea releases API → compare versions → download → verify checksum →
replace binary → restart service.
- **plugin overlay**: download the newer overlay tarball → verify → re-sync → record commit → tell
the operator ServUO must restart (the installer does not restart the shard).
`runicgateway uninstall` — **removes only what it exclusively owns, and never edits the ServUO
tree.** The installer cannot know what the operator has changed in those files since deployment, so
a clever automatic revert risks silently eating their work. It removes and it reports:
| Action | Scope |
|---|---|
| Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` and the cached patch set |
| Kept | `sidecar.toml` and `uo-link.db` (config and history survive; `--purge` to drop them) |
| **Printed, not done** | Every overlay file deployed into the ServUO tree, listed by path, for the operator to delete |
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs`, `Logging.cs`, rendered from the cached `.patch` files, for the operator to revert by hand |
The printed report is also written to a file, so it survives the terminal scrollback of a long
uninstall.
### Phase 5 — packaging polish
`.deb` packaging, Windows MSI, arm64 cross build, and optional automated backup before upgrade.
Deliberately last: v1 can register services directly (`sc create` / a written systemd unit) and ship
plain binaries. Nothing in Phases 14 should have to change to add these.
---
## 6. Token handoff (the end of a successful run)
```
Runic Gateway is installed.
One manual step remains — connect the website to this sidecar:
Base URL http://<this-host>:8080
WebSocket URL ws://<this-host>:8080/ws
Protocol version 3
Auth token 4f9c... (also in /etc/runicgateway/sidecar.toml)
Paste these into Admin → Shard on your Runic Gateway site:
https://<your-site>/admin/shard
The token is write-only once saved — the site will never show it back to you.
```
The installer prompts for the site URL only to build that link; it never contacts the website. A
future "installer registers itself with the website" flow (claim code + authenticated endpoint) is
explicitly **out of scope** — it is real backend work in a security-sensitive area and can be added
later without changing anything here.
---
## 7. Version tracking and the compat matrix
Three components version independently, bound by a protocol contract:
- **sidecar** — `PROTOCOL_VERSION` in `link/sidecar/src/main.rs`, exposed on `/health` and as
`X-UOLink-Version` on every response; a mismatch is rejected `409`.
- **website** — stores an expected protocol version in `uoLinkConfig` (admin-managed).
- **plugin overlay** — has no queryable version before ServUO boots. The overlay release
`manifest.json` declares it, and `install.json` records what was deployed. `doctor` compares the
recorded overlay protocol version against the sidecar's live one.
**Open risk:** the v3 cutover is mid-flight — protocol work landed on `edge` branches with the
`edge → main` cutover still open across four repos. Until that lands, `main` and `edge` disagree
about `PROTOCOL_VERSION`, so the installer must not hardcode a version anywhere; it reads what the
artifacts declare. See `docs/link/v3.md`.
---
## 8. Open questions
1. **Windows service mechanism**`sc create` against the plain console binary (simplest, works
today), a bundled WinSW/NSSM shim, or a native `--service` mode in the sidecar using the
`windows-service` crate (cleanest, but changes `link`). Recommendation: `sc create` for v1,
revisit if restart semantics prove inadequate.
2. **Does the installer manage ServUO stop/start?** Currently it refuses while ServUO runs and tells
the operator to restart afterward. Offering to stop/start would be friendlier but means owning
another shard's process lifecycle, and the shard's own start scripts vary.
3. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so
sidecar and ServUO must share a host. Should the installer support installing only uo-link on a
different host, or hard-assume co-location?
4. **Branch targeting for the new repo**`link`, `website`, `servuo-plugins` and `docs` are
mid-cutover between `edge` and `main`. The installer repo starts clean on `main`; the Phase 0
`servuo-plugins` release workflow needs a target branch decision.
Resolved and moved into §1 / §2.2 / §5: uninstall scope, and minimum ServUO version.
---
## 9. Administrator experience
Before:
```
find plugins → copy files → edit ServUO → download bridge → start bridge
→ configure startup → find the token → troubleshoot paths
```
After:
```
download artifact → verify checksum → run installer → select ServUO directory
→ install components → paste 4 values into Admin → Shard → start ServUO normally
```

View File

@@ -18,7 +18,6 @@ link/
│ ├── scripts/ │ ├── scripts/
│ │ └── gen_tree.py │ │ └── gen_tree.py
│ ├── workflows/ │ ├── workflows/
│ │ ├── pr-checks.yml
│ │ ├── release.yml │ │ ├── release.yml
│ │ ├── sonarqube.yml │ │ ├── sonarqube.yml
│ │ └── sync-project-tree.yml │ │ └── sync-project-tree.yml

View File

@@ -350,11 +350,8 @@ Sidecar — `store.rs`: singleton `ruleset(id CHECK(id=1), rev, json, updated_t)
`main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so `main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so
it answers during a shard outage (`PROTOCOL_2.md` §12.2). it answers during a shard outage (`PROTOCOL_2.md` §12.2).
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so it cannot use the Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so follow the
array-only `snapshot()` helper — but it **must still go through `shardIngest.ingest()`**, as `getPresence()` block's explicit form, not the array-only `snapshot()` helper); `shardIngest.js`
`ingestEach` does, rather than calling `shardState.setRuleset` directly: the two arrival orders have
to produce the same stored frame, and a direct call quietly made backfill a second writer that
skipped the normalization below); `shardIngest.js`
`shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello` `shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello`
already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table
(`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind (`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind
@@ -363,17 +360,6 @@ already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_rulese
Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside
`/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`. `/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`.
**The `shard` field falls back to the instance's own name.** ServUO ships `Server.cfg` with
`Name=My Shard`, so an operator who never edited it publishes that verbatim — which is the shard
saying *unnamed*, not naming anything, and the rules page then reads "My Shard" under a header
carrying the real one. `shardIngest` substitutes `settings.getInstanceName()` (the admin-editable
site title, else `BRAND_NAME` — the same resolution `getPublic().brand.name` uses, so one install
never shows two names) when `shard` is absent, blank, or exactly the stock default, matched
case-insensitively and trim-tolerantly but only as a **whole** value: a shard genuinely called
*"My Shard Reborn"* has named itself and keeps it. Applied at **ingest**, not on read, because the
ruleset is also broadcast live — the same object goes to the SSE fan-out, so a read-time
substitution would be undone by the next reconnect's frame.
### 5.4 Risk ### 5.4 Risk
Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit
@@ -652,15 +638,6 @@ Client — NEW `routes/public/Leaderboards.jsx` at `/site/leaderboards`; a "Loya
added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and
`AdminCharacter.jsx`. `AdminCharacter.jsx`.
**An unscored board still renders a row.** Most systems on a young shard have `top: []`, and a page
of blank cards reads as broken rather than as new — so a board with no entries shows a single
placeholder bearing the **instance's own name** with an em dash where a score goes, above the
existing "nobody has earned points here yet" line. It is deliberately **not** shaped like an entry —
no rank, no medal, no bar, muted — because a placeholder that looked like a real standing would be a
fabricated one; the first real entry replaces it outright. Purely presentational: the API keeps
sending an empty `top`, so no consumer ever receives an invented row. Web and app render it the same
way (`Leaderboards.jsx`, `LeaderboardsScreen.kt`).
### 7.5 What the run against a real shard changed ### 7.5 What the run against a real shard changed
The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a
@@ -984,9 +961,12 @@ request rides the same authenticated OkHttp client as every other call, so an ap
the same audience rung as the same account on the web; and every shard DTO in the app is the same audience rung as the same account on the web; and every shard DTO in the app is
nullable-with-defaults, so field projection strips fields without a deserialization failure. nullable-with-defaults, so field projection strips fields without a deserialization failure.
Scoped as **M11 in [`../android/PLAN.md`](../android/PLAN.md) §9**, two PRs (the visibility rules + Scoped as **M11 in [`../android/PLAN.md`](../android/PLAN.md) §9**, two PRs: the visibility rules +
read-model adds, then the four screens). `edge``main` is held until both land, so web and app read-model adds ([Android-app #30](https://gitea.whitlocktech.com/RunicGateway/Android-app/pulls/30))
surface the same shard on the same day. Neither PR is coupled to the merge order — on a pre-v3 website and the four screens ([#31](https://gitea.whitlocktech.com/RunicGateway/Android-app/pulls/31), stacked
on it). Both are **built and in review**; the on-device five-rung walk (§11) against a website on the
cutover branch is the remaining gate. `edge``main` is held until both land, so web and app surface
the same shard on the same day. Neither PR is coupled to the merge order — on a pre-v3 website
every new route and `/public/shard/features` `404`s and the app falls back to today's behavior — so every new route and `/public/shard/features` `404`s and the app falls back to today's behavior — so
holding the cutover is a schedule decision, not a technical dependency. holding the cutover is a schedule decision, not a technical dependency.

View File

@@ -44,11 +44,6 @@ they did before the table existed.
## Converting ## 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. Either format below is accepted; the site sniffs which one it was handed.
| Format | Fidelity | Notes | | Format | Fidelity | Notes |
@@ -82,16 +77,8 @@ dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/cli
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv
``` ```
A UOFiddler GUI export works too, but **not unmodified**: its Cliloc tab writes A UOFiddler GUI export works equally well — anything producing one of the two
`Number;Text;Flag` — three columns, the flag *last* — and the parser reads shapes above is fine.
`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.
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.
## Shard-added and shard-edited items ## Shard-added and shard-edited items

View File

@@ -164,7 +164,6 @@ website/
│ │ │ ├── heroLayout.js │ │ │ ├── heroLayout.js
│ │ │ ├── shardEvents.js │ │ │ ├── shardEvents.js
│ │ │ ├── useAsync.js │ │ │ ├── useAsync.js
│ │ │ ├── useShardFeatures.js
│ │ │ └── useShardFeed.js │ │ │ └── useShardFeed.js
│ │ ├── routes/ │ │ ├── routes/
│ │ │ ├── admin/ │ │ │ ├── admin/
@@ -191,8 +190,6 @@ website/
│ │ │ │ │ ├── SettingsAdmin.jsx │ │ │ │ │ ├── SettingsAdmin.jsx
│ │ │ │ │ ├── ShardAdmin.jsx │ │ │ │ │ ├── ShardAdmin.jsx
│ │ │ │ │ ├── ShardOps.jsx │ │ │ │ │ ├── ShardOps.jsx
│ │ │ │ │ ├── ShardVisibility.jsx
│ │ │ │ │ ├── SpawnAtlas.jsx
│ │ │ │ │ ├── UserDetail.jsx │ │ │ │ │ ├── UserDetail.jsx
│ │ │ │ │ ├── UserEditor.jsx │ │ │ │ │ ├── UserEditor.jsx
│ │ │ │ │ ├── UsersAdmin.jsx │ │ │ │ │ ├── UsersAdmin.jsx
@@ -216,23 +213,17 @@ website/
│ │ │ │ └── ResetPassword.jsx │ │ │ │ └── ResetPassword.jsx
│ │ │ ├── public/ │ │ │ ├── public/
│ │ │ │ ├── About.jsx │ │ │ │ ├── About.jsx
│ │ │ │ ├── Atlas.jsx
│ │ │ │ ├── AtlasCreature.jsx
│ │ │ │ ├── ChampSpawns.jsx │ │ │ │ ├── ChampSpawns.jsx
│ │ │ │ ├── CmsPage.jsx │ │ │ │ ├── CmsPage.jsx
│ │ │ │ ├── FiveOnFriday.jsx │ │ │ │ ├── FiveOnFriday.jsx
│ │ │ │ ├── Governors.jsx │ │ │ │ ├── Governors.jsx
│ │ │ │ ├── Guilds.jsx │ │ │ │ ├── Guilds.jsx
│ │ │ │ ├── Houses.jsx │ │ │ │ ├── Houses.jsx
│ │ │ │ ├── Leaderboards.jsx
│ │ │ │ ├── Maintenance.jsx │ │ │ │ ├── Maintenance.jsx
│ │ │ │ ├── Market.jsx
│ │ │ │ ├── MarketVendor.jsx
│ │ │ │ ├── News.jsx │ │ │ │ ├── News.jsx
│ │ │ │ ├── Newsletter.jsx │ │ │ │ ├── Newsletter.jsx
│ │ │ │ ├── NewsletterIssue.jsx │ │ │ │ ├── NewsletterIssue.jsx
│ │ │ │ ├── Portal.jsx │ │ │ │ ├── Portal.jsx
│ │ │ │ ├── Rules.jsx
│ │ │ │ ├── Screenshots.jsx │ │ │ │ ├── Screenshots.jsx
│ │ │ │ ├── Shard.jsx │ │ │ │ ├── Shard.jsx
│ │ │ │ ├── ShardActivity.jsx │ │ │ │ ├── ShardActivity.jsx
@@ -266,12 +257,9 @@ website/
│ └── sonar-test-reporter.mjs │ └── sonar-test-reporter.mjs
├── server/ ├── server/
│ ├── db/ │ ├── db/
│ │ ├── data/
│ │ │ └── spawnAtlas.art.example.json
│ │ ├── schema.sql │ │ ├── schema.sql
│ │ └── seed.js │ │ └── seed.js
│ ├── scripts/ │ ├── scripts/
│ │ ├── importSpawnAtlas.js
│ │ └── routeManifest.js │ │ └── routeManifest.js
│ ├── src/ │ ├── src/
│ │ ├── auth/ │ │ ├── auth/
@@ -377,27 +365,15 @@ website/
│ │ │ ├── settings/ │ │ │ ├── settings/
│ │ │ │ ├── settings.db.js │ │ │ │ ├── settings.db.js
│ │ │ │ └── settings.model.js │ │ │ │ └── settings.model.js
│ │ │ ├── shardAtlas/
│ │ │ │ ├── shardAtlas.db.js
│ │ │ │ └── shardAtlas.model.js
│ │ │ ├── shardClilocs/
│ │ │ │ ├── shardClilocs.db.js
│ │ │ │ └── shardClilocs.model.js
│ │ │ ├── shardEvents/ │ │ │ ├── shardEvents/
│ │ │ │ ├── shardEvents.db.js │ │ │ │ ├── shardEvents.db.js
│ │ │ │ └── shardEvents.model.js │ │ │ │ └── shardEvents.model.js
│ │ │ ├── shardLinks/ │ │ │ ├── shardLinks/
│ │ │ │ ├── shardLinks.db.js │ │ │ │ ├── shardLinks.db.js
│ │ │ │ └── shardLinks.model.js │ │ │ │ └── shardLinks.model.js
│ │ │ ├── shardMarket/
│ │ │ │ ├── shardMarket.db.js
│ │ │ │ └── shardMarket.model.js
│ │ │ ├── shardState/ │ │ │ ├── shardState/
│ │ │ │ ├── shardState.db.js │ │ │ │ ├── shardState.db.js
│ │ │ │ └── shardState.model.js │ │ │ │ └── shardState.model.js
│ │ │ ├── shardVisibility/
│ │ │ │ ├── shardVisibility.db.js
│ │ │ │ └── shardVisibility.model.js
│ │ │ ├── trustedDevices/ │ │ │ ├── trustedDevices/
│ │ │ │ ├── trustedDevices.db.js │ │ │ │ ├── trustedDevices.db.js
│ │ │ │ └── trustedDevices.model.js │ │ │ │ └── trustedDevices.model.js
@@ -422,14 +398,12 @@ website/
│ │ │ │ │ ├── account.router.js │ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── activity.router.js │ │ │ │ │ ├── activity.router.js
│ │ │ │ │ ├── admin.controller.js │ │ │ │ │ ├── admin.controller.js
│ │ │ │ │ ├── admin.routes.js
│ │ │ │ │ ├── authProviders.controller.js │ │ │ │ │ ├── authProviders.controller.js
│ │ │ │ │ ├── authProviders.router.js │ │ │ │ │ ├── authProviders.router.js
│ │ │ │ │ ├── botActivity.controller.js │ │ │ │ │ ├── botActivity.controller.js
│ │ │ │ │ ├── botActivity.router.js │ │ │ │ │ ├── botActivity.router.js
│ │ │ │ │ ├── dashboard.router.js
│ │ │ │ │ ├── discordBot.controller.js │ │ │ │ │ ├── discordBot.controller.js
│ │ │ │ │ ├── discordBot.router.js
│ │ │ │ │ ├── email.router.js
│ │ │ │ │ ├── emailConfig.controller.js │ │ │ │ │ ├── emailConfig.controller.js
│ │ │ │ │ ├── imageUpload.js │ │ │ │ │ ├── imageUpload.js
│ │ │ │ │ ├── index.js │ │ │ │ │ ├── index.js
@@ -440,25 +414,16 @@ website/
│ │ │ │ │ ├── pages.controller.js │ │ │ │ │ ├── pages.controller.js
│ │ │ │ │ ├── pages.router.js │ │ │ │ │ ├── pages.router.js
│ │ │ │ │ ├── posts.router.js │ │ │ │ │ ├── posts.router.js
│ │ │ │ │ ├── settings.router.js
│ │ │ │ │ ├── shard.router.js
│ │ │ │ │ ├── shardAtlas.controller.js
│ │ │ │ │ ├── shardClilocs.controller.js
│ │ │ │ │ ├── shardOps.controller.js │ │ │ │ │ ├── shardOps.controller.js
│ │ │ │ │ ├── shardVisibility.controller.js
│ │ │ │ │ ├── uoLink.controller.js │ │ │ │ │ ├── uoLink.controller.js
│ │ │ │ │ ├── uoLink.router.js
│ │ │ │ │ ├── uploads.router.js │ │ │ │ │ ├── uploads.router.js
│ │ │ │ │ ├── users.router.js │ │ │ │ │ ├── users.router.js
│ │ │ │ │ ├── usersShard.controller.js │ │ │ │ │ ├── usersShard.controller.js
│ │ │ │ │ └── wiki.router.js │ │ │ │ │ └── wiki.router.js
│ │ │ │ ├── auth/ │ │ │ │ ├── auth/
│ │ │ │ │ ├── auth.controller.js │ │ │ │ │ ├── auth.controller.js
│ │ │ │ │ ├── index.js │ │ │ │ │ ├── auth.routes.js
│ │ │ │ │ ├── invite.controller.js │ │ │ │ │ ├── invite.controller.js
│ │ │ │ │ ├── invite.router.js
│ │ │ │ │ ├── login.router.js
│ │ │ │ │ ├── loginGuards.js
│ │ │ │ │ ├── me.routes.js │ │ │ │ │ ├── me.routes.js
│ │ │ │ │ ├── mobile.controller.js │ │ │ │ │ ├── mobile.controller.js
│ │ │ │ │ ├── mobile.routes.js │ │ │ │ │ ├── mobile.routes.js
@@ -466,10 +431,7 @@ website/
│ │ │ │ │ ├── mobileSso.routes.js │ │ │ │ │ ├── mobileSso.routes.js
│ │ │ │ │ ├── notifications.controller.js │ │ │ │ │ ├── notifications.controller.js
│ │ │ │ │ ├── notifications.routes.js │ │ │ │ │ ├── notifications.routes.js
│ │ │ │ │ ├── password.router.js
│ │ │ │ │ ├── passwordReset.controller.js │ │ │ │ │ ├── passwordReset.controller.js
│ │ │ │ │ ├── register.router.js
│ │ │ │ │ ├── session.router.js
│ │ │ │ │ ├── sso.controller.js │ │ │ │ │ ├── sso.controller.js
│ │ │ │ │ ├── sso.routes.js │ │ │ │ │ ├── sso.routes.js
│ │ │ │ │ └── trustDevice.helper.js │ │ │ │ │ └── trustDevice.helper.js
@@ -477,23 +439,13 @@ website/
│ │ │ │ │ ├── internal.controller.js │ │ │ │ │ ├── internal.controller.js
│ │ │ │ │ └── internal.routes.js │ │ │ │ │ └── internal.routes.js
│ │ │ │ ├── player/ │ │ │ │ ├── player/
│ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── appeals.controller.js │ │ │ │ │ ├── appeals.controller.js
│ │ │ │ │ ├── appeals.router.js │ │ │ │ │ ├── player.routes.js
│ │ │ │ │ ── index.js │ │ │ │ │ ── shard.controller.js
│ │ │ │ │ ├── shard.controller.js
│ │ │ │ │ └── shard.router.js
│ │ │ │ ├── public/ │ │ │ │ ├── public/
│ │ │ │ │ ├── atlas.controller.js
│ │ │ │ │ ├── atlas.router.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── pages.router.js
│ │ │ │ │ ├── posts.router.js
│ │ │ │ │ ├── public.controller.js │ │ │ │ │ ├── public.controller.js
│ │ │ │ │ ├── shard.controller.js │ │ │ │ │ ├── public.routes.js
│ │ │ │ │ ── shard.router.js │ │ │ │ │ ── shard.controller.js
│ │ │ │ │ ├── site.router.js
│ │ │ │ │ └── wiki.router.js
│ │ │ │ └── v1.router.js │ │ │ │ └── v1.router.js
│ │ │ ├── api.router.js │ │ │ ├── api.router.js
│ │ │ ├── cspReport.controller.js │ │ │ ├── cspReport.controller.js
@@ -503,8 +455,6 @@ website/
│ │ │ ├── auth.js │ │ │ ├── auth.js
│ │ │ ├── botInternalClient.js │ │ │ ├── botInternalClient.js
│ │ │ ├── botInternalKey.js │ │ │ ├── botInternalKey.js
│ │ │ ├── clilocParse.js
│ │ │ ├── clilocSource.js
│ │ │ ├── db.js │ │ │ ├── db.js
│ │ │ ├── logger.js │ │ │ ├── logger.js
│ │ │ ├── mailer.js │ │ │ ├── mailer.js
@@ -515,9 +465,6 @@ website/
│ │ │ ├── shardBroadcast.js │ │ │ ├── shardBroadcast.js
│ │ │ ├── shardIngest.js │ │ │ ├── shardIngest.js
│ │ │ ├── shardSales.js │ │ │ ├── shardSales.js
│ │ │ ├── shardVisibility.js
│ │ │ ├── spawnAtlasParse.js
│ │ │ ├── spawnAtlasSource.js
│ │ │ ├── totp.js │ │ │ ├── totp.js
│ │ │ ├── trustProxy.js │ │ │ ├── trustProxy.js
│ │ │ ├── uoLinkClient.js │ │ │ ├── uoLinkClient.js
@@ -536,14 +483,11 @@ website/
│ │ ├── appeals.pure.test.js │ │ ├── appeals.pure.test.js
│ │ ├── appeals.test.js │ │ ├── appeals.test.js
│ │ ├── appLinks.test.js │ │ ├── appLinks.test.js
│ │ ├── atlasController.test.js
│ │ ├── authController.test.js │ │ ├── authController.test.js
│ │ ├── authMe.test.js │ │ ├── authMe.test.js
│ │ ├── authTrustedDevice.test.js │ │ ├── authTrustedDevice.test.js
│ │ ├── botInternalKey.test.js │ │ ├── botInternalKey.test.js
│ │ ├── botScore.test.js │ │ ├── botScore.test.js
│ │ ├── clilocParse.test.js
│ │ ├── clilocSource.test.js
│ │ ├── csp.test.js │ │ ├── csp.test.js
│ │ ├── emailConfig.model.test.js │ │ ├── emailConfig.model.test.js
│ │ ├── honeypot.test.js │ │ ├── honeypot.test.js
@@ -577,32 +521,17 @@ website/
│ │ ├── secretBox.test.js │ │ ├── secretBox.test.js
│ │ ├── selfTrustedDevices.test.js │ │ ├── selfTrustedDevices.test.js
│ │ ├── session.test.js │ │ ├── session.test.js
│ │ ├── shardBroadcast.visibility.test.js
│ │ ├── shardControllerPublic.test.js │ │ ├── shardControllerPublic.test.js
│ │ ├── shardIngest.champsPages.test.js │ │ ├── shardIngest.champsPages.test.js
│ │ ├── shardIngest.market.test.js
│ │ ├── shardIngest.points.test.js
│ │ ├── shardIngest.protocol2.test.js │ │ ├── shardIngest.protocol2.test.js
│ │ ├── shardIngest.ruleset.test.js
│ │ ├── shardMarket.model.test.js
│ │ ├── shardState.governorTerms.test.js │ │ ├── shardState.governorTerms.test.js
│ │ ├── shardState.model.test.js │ │ ├── shardState.model.test.js
│ │ ├── shardVisibility.test.js
│ │ ├── spawnAtlas.parse.test.js
│ │ ├── spawnAtlas.source.test.js
│ │ ├── ssoCallback.test.js │ │ ├── ssoCallback.test.js
│ │ ├── ssoState.test.js │ │ ├── ssoState.test.js
│ │ ├── ssoTrustedDevice.test.js
│ │ ├── totp.test.js │ │ ├── totp.test.js
│ │ ├── trustedDevices.test.js │ │ ├── trustedDevices.test.js
│ │ ├── trustProxy.test.js │ │ ├── trustProxy.test.js
│ │ ├── uoLinkClient.test.js
│ │ └── usernamePolicy.test.js │ │ └── usernamePolicy.test.js
│ ├── tools/
│ │ └── cliloc-export/
│ │ ├── clilocexport.csproj
│ │ ├── Program.cs
│ │ └── README.md
│ ├── .env.example │ ├── .env.example
│ ├── package-lock.json │ ├── package-lock.json
│ ├── package.json │ ├── package.json

View File

@@ -223,8 +223,7 @@ 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 and is NULL on every fresh import; pages render without images, which is the
normal and supported state, not a degraded one. normal and supported state, not a degraded one.
An operator who wants art — step-by-step, with the UOFiddler side spelled out, in An operator who wants art:
[`UOFIDDLER.md`](UOFIDDLER.md) §Part 2:
1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or 1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or
any art extractor). any art extractor).

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.