1 Commits

Author SHA1 Message Date
d0363cd62d docs(installer): lead the remote-website case with a reverse proxy
A TLS reverse proxy in front of the sidecar is a supported deployment already
running on a real domain, not the fallback the guide framed it as. Recommend it
first, keep [web] bind on loopback in that arrangement, and demote widen-the-
bind-and-firewall to the trusted-LAN alternative - on that path the token and
every event cross the network in the clear.

Adds the four things a proxy must actually do, checked against web.rs: forward
the WebSocket upgrade (/ws is the entire live feed, and losing it leaves REST
working with no events - a confusing half-working state); pass headers through
unmodified (auth is Authorization: Bearer or X-Api-Key, and a stripped
X-UOLink-Version silently skips the 409 mismatch check); no buffering and
long-lived connections (the sidecar pings every 30s, so a 60s+ read timeout is
safe as it stands); and no query-string logging, since ?token= is an accepted
auth form. Nothing needs X-Forwarded-For - the sidecar never uses the client IP
for authorization. Includes an nginx block that satisfies all four, and notes
Caddy and Traefik need no equivalent.

The website gets the proxied https:// / wss:// URLs, not the pair the installer
prints: those are composed from the sidecar's own bind address, which knows
nothing about what fronts it. PLAN records that the installer deliberately does
not try to detect a proxy - nothing visible from the sidecar's side says what is
in front of it, so guessing would print a confidently wrong URL.

Two new troubleshooting rows for the symptoms this causes: REST works but no
events (upgrade not forwarded), and a feed that drops every minute or two (read
timeout under the ping interval, or buffering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 12:12:46 -05:00
7 changed files with 192 additions and 1641 deletions

View File

@@ -14,16 +14,11 @@ installer/ docs for the installer that deploys a shard's bridge components
ci/ cross-cutting CI/quality notes
```
**Setting up a shard?** [`installer/INSTALL.md`](installer/INSTALL.md) is the operator guide, and
the installer is the supported path: one binary deploys the plugin overlay, installs the uo-link
sidecar as a service, and hands you the values the website needs.
### `website/`
| Doc | What it covers |
|---|---|
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [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 |
@@ -59,7 +54,7 @@ sidecar as a service, and hands you the values the website needs.
### `installer/`
| Doc | What it covers |
|---|---|
| [INSTALL.md](installer/INSTALL.md) | **Start here to set up a shard** the installer deploys the plugin overlay and the uo-link sidecar, registers the service, and connects it to the website. Appendix A is the same thing by hand, still supported |
| [INSTALL.md](installer/INSTALL.md) | **Operator guide** — installing Runic Gateway on a ServUO shard, connecting it to the website, and diagnosing it. Includes the by-hand path, which works today |
| [PLAN.md](installer/PLAN.md) | Installer design of record — phases, locked decisions, the bundle/compat-matrix model |
## Provenance

View File

@@ -3,15 +3,17 @@
Operator guide for the **Runic Gateway installer** — the tool that takes a working ServUO
installation and connects it to a Runic Gateway website.
> **The installer is the supported way to set this up.** Download one binary, run `install`, paste
> four values into your website. It deploys the plugin overlay, installs the uo-link sidecar and
> registers it as a service, and gives you [`doctor`, `update` and `uninstall`](#7-day-two)
> afterwards. Start at [§1](#1-download-and-verify).
> **Status: the installer binary is not released yet.**
>
> [Appendix A](#appendix-a--installing-by-hand) is the same deployment done by hand. It is
> **supported, not deprecated** — use it on a host that cannot run the binary, when you want to
> place things yourself, or when you are developing on the bridge and installing from a working
> tree rather than a release. It is also the reference for what the installer does under the hood.
> Everything it installs *is* released and published — the sidecar, the plugin overlay, and the
> [bundle manifest](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json)
> that names the checked combination of the two. This guide is the operator-facing contract those
> phases build to, and it is written first on purpose: it is the specification of what the run
> looks like, what it asks, where it writes, and what it prints.
>
> **You can install today without it** — [Appendix A](#appendix-a--installing-by-hand) is the same
> deployment done by hand, with the commands verified against the current releases. When the binary
> ships, Appendix A stays as the reference for what it does under the hood.
>
> Design of record: [PLAN.md](PLAN.md).
@@ -25,7 +27,7 @@ Three things, on the machine that runs your shard:
|---|---|---|
| 1 | **The plugin overlay** — C# source that ServUO compiles at boot, copied into your server tree | [`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins) release tarball |
| 2 | **The uo-link sidecar** — a small Rust service that the shard dials out to, and that your website reads from | [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) release binary |
| 3 | **A record of what it did**`install.json`, plus cached copies of the patches and of every file the patch tier edited | Written by the installer |
| 3 | **A record of what it did**`install.json`, plus a cached copy of any patches it applied | Written by the installer |
```
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
@@ -51,7 +53,7 @@ Only the sidecar is exposed, and only to your website.
| Requirement | Detail |
|---|---|
| A working ServUO install | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
| ServUO **57.4** *(patch tier only)* | **57.4 is the only supported version.** The base install works on any reasonably current ServUO. The patch tier is written and tested against stock 57.4; on any other version it is **unsupported and untested** — you can still choose to run it, behind an explicit opt-in, and it applies only where the exact lines it patches are unchanged. See [§4](#4-the-patch-tier-optional). |
| ServUO **57.4** *(patch tier only)* | The base install works on any reasonably current ServUO. The patch tier is verified against stock 57.4 only, and is skipped with a warning on anything else. |
| ServUO **stopped** | `ServUO.exe` holds a lock on `Scripts.dll` and writes `Saves/` on exit. The installer refuses to deploy under a running shard. |
| Administrator / root | It writes into system directories and registers a service. |
| Outbound HTTPS | To `gitea.whitlocktech.com`, to fetch the bundle and the two artifacts. Nothing inbound is needed, and no Gitea account or git client is required. |
@@ -73,16 +75,10 @@ Download the installer for your OS, plus `SHA256SUMS`, from the
```
runicgateway-installer-linux-x86_64
runicgateway-installer-linux-aarch64
runicgateway-installer-windows-x86_64.exe
SHA256SUMS
```
`linux-aarch64` is for arm64 hosts — Ampere/Graviton instances, Pi-class boxes. `uname -m` says
`aarch64` on those and `x86_64` otherwise. There is no macOS build and no Windows-on-arm build: the
shard dials the sidecar out on loopback, so the two have to share a host, and no ServUO host is
either of those.
**Linux**
```bash
@@ -144,9 +140,8 @@ one file); `doctor`, `update` and `uninstall` are run from it later. Examples be
1. **Your ServUO root** — detected if the installer is run from inside it or from an obvious
sibling, otherwise prompted. A directory qualifies only if it contains `ServUO.exe`, `Scripts/`
and `Config/`.
2. **Whether to apply the patch tier** — off unless you say yes. On a ServUO that is not 57.4 the
prompt defaults to **no** and carries an unsupported-version warning you have to answer past.
See [§4](#4-the-patch-tier-optional).
2. **Whether to apply the patch tier** — off unless you say yes, and not offered at all if your
ServUO is not 57.4. See [§4](#4-the-patch-tier-optional).
3. **The hostname your website should use to reach this machine** — used only to compose the two
URLs it prints at the end. The sidecar's bind address is frequently `127.0.0.1` or `0.0.0.0`,
neither of which is something to hand to a website.
@@ -170,19 +165,16 @@ Overlay sync
ADD Config/Bridge.cfg
ADD Scripts/Custom/Bridge/*.cs (22 files)
CHANGE Scripts/Scripts.csproj
deployed. add=23 change=1 unchanged=0 kept=0
deployed. add=23 change=1 unchanged=0
Patch tier not selected
Patch tier skipped (not selected)
Without it: no vendor.sale events, no in-game moderation audit forwarding.
uo-link sidecar
binary /usr/bin/runicgateway-link install
✓ sidecar binary verified sha256 27d491ef…
config /etc/runicgateway/sidecar.toml created
database /var/lib/runicgateway/uo-link.db
listening on shard 127.0.0.1:7788 website 127.0.0.1:8080
service runicgateway-link.service active, enabled
running as runicgateway
uo-link
binary /usr/bin/runicgateway-link
config /etc/runicgateway/sidecar.toml (created)
database /var/lib/runicgateway/uo-link.db
service runicgateway-link.service enabled, running
Recorded /etc/runicgateway/install.json
@@ -209,18 +201,11 @@ reports "unchanged" and writes nothing.
| `--verify` | `install`, `update` | Dry run. Report every change that would be made; write nothing. |
| `--servuo <path>` | `install`, `doctor`, `update` | Name the ServUO root instead of detecting or prompting. |
| `--bundle <tag>` | `install`, `update` | Pin an exact published bundle instead of the current one. |
| `--patches` / `--no-patches` | `install`, `update` | Decide the patch tier non-interactively. `--patches` never loosens the region check: patches whose target lines are not stock are reported for you to apply by hand, not forced. On `update` it is what takes up a feature the shard does not already have. |
| `--patches-unsupported-servuo` | `install` | Required *in addition to* `--patches` to run the patch tier on a ServUO that is not 57.4. Unsupported and untested — see [§4](#4-the-patch-tier-optional). Ignored on 57.4. |
| `--patches` / `--no-patches` | `install` | Decide the patch tier non-interactively. `--patches` still refuses on a non-57.4 tree. |
| `--host <name>` | `install` | The hostname to print in the website URLs. |
| `--site-url <url>` | `install` | Your site's base URL, for the Admin → Shard link. |
| `--yes` | all | Assume the default answer to every prompt. Combine with the flags above for an unattended run. **On `uninstall` it means yes** — that prompt defaults to no, and typing `uninstall --yes` is not an accident. |
| `--no-backup` | `install`, `update` | Do not copy the files this run is about to overwrite. They are otherwise saved under the state directory — see [§7](#7-day-two). |
| `--purge` | `uninstall` | Also delete `sidecar.toml`, `uo-link.db`, the cached patch set and every backup, all of which are otherwise kept. |
Exit codes are `0` success, `1` the run failed, `2` the arguments were unusable. Two commands also
use `1` for a run that *completed* and found something wrong, so they can be read from a script:
`doctor` when any check failed, and `uninstall` when a step could not be carried out (everything
else still was).
| `--yes` | all | Assume the default answer to every prompt. Combine with the flags above for an unattended run. |
| `--purge` | `uninstall` | Also delete `sidecar.toml` and `uo-link.db`, which are otherwise kept. |
---
@@ -233,9 +218,7 @@ else still was).
| `/usr/bin/runicgateway-link` | The sidecar binary |
| `/etc/runicgateway/sidecar.toml` | Sidecar config, including the auth token |
| `/etc/runicgateway/install.json` | What the installer deployed: versions, commit, per-file hashes, applied patches, timestamps |
| `/etc/runicgateway/patches/` | Copies of the patches the tier evaluated, so `uninstall` can print the exact hunks long after the release tarball is gone, and a refused one is still on hand to apply yourself |
| `/etc/runicgateway/patches/originals/` | Each file the patch tier edited, exactly as it was beforehand — a revert you can verify rather than reconstruct |
| `/etc/runicgateway/backups/<timestamp>/` | Copies of the files a run replaced, with a `manifest.json` naming each. Newest three kept; skip with `--no-backup` |
| `/etc/runicgateway/patches/` | Copies of any patches applied, so `uninstall` can print the exact hunks long after the release tarball is gone |
| `/var/lib/runicgateway/uo-link.db` | The sidecar's SQLite store (event history, cached profiles, link map) |
| `/etc/systemd/system/runicgateway-link.service` | The service unit, running as a dedicated user |
@@ -247,11 +230,8 @@ else still was).
| `%ProgramData%\RunicGateway\sidecar.toml` | Sidecar config, including the auth token |
| `%ProgramData%\RunicGateway\install.json` | As above |
| `%ProgramData%\RunicGateway\patches\` | As above |
| `%ProgramData%\RunicGateway\patches\originals\` | As above |
| `%ProgramData%\RunicGateway\backups\<timestamp>\` | As above |
| `%ProgramData%\RunicGateway\uo-link.db` | The sidecar's SQLite store |
| `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log` | The service's log. A Windows service has no console to write to, so it logs here instead; rolled daily, seven kept. A foreground run still logs to stdout as usual |
| Service `RunicGatewayLink` | Automatic start, restart on failure, running as `NT SERVICE\RunicGatewayLink`. Needs a sidecar **v1.2.0 or newer** — see [Troubleshooting](#troubleshooting) on error 1053 |
| Service `RunicGatewayLink` | Automatic start, restart on failure |
**Inside your ServUO tree** (added by the overlay sync — 24 files):
@@ -261,30 +241,11 @@ Scripts/Custom/Bridge/*.cs 22 files: the plugin itself
Scripts/Scripts.csproj OVERWRITES a stock file (see below)
```
**Both service definitions pin the config path**, because the sidecar's own default is relative to
its working directory and a service manager's working directory is not somewhere you want a
database or a config file. On Windows it can be `%SystemRoot%\System32` or, under
Both service definitions pin `UOLINK_CONFIG` and `UOLINK_DB_PATH` explicitly. The sidecar's own
defaults are relative to its working directory, and a service manager's working directory is not
somewhere you want a database — on Windows it can be `%SystemRoot%\System32` or, under
`C:\Program Files\`, a silently redirected VirtualStore copy.
How the *database* path is pinned differs by platform, and that is deliberate:
| | Config | Database |
|---|---|---|
| **Linux** | `Environment=UOLINK_CONFIG=` in the unit | `Environment=UOLINK_DB_PATH=` in the unit — `/etc` and `/var/lib` are different directories, so both need naming |
| **Windows** | `--config` inside the service's own `binPath` | nothing to set: a relative `[store] path` resolves against the config's directory, which *is* `%ProgramData%\RunicGateway` |
The Windows service would otherwise need a **machine-wide** environment variable — `sc.exe` has no
per-service one — which every process on the host inherits and which outlives an uninstall.
**Both run as a dedicated, unprivileged account.** Linux gets a `runicgateway` system user; Windows
gets a virtual service account, `NT SERVICE\RunicGatewayLink`, which Windows creates as part of
registering the service and which has no password. Neither runs as root or `LocalSystem`.
**`sidecar.toml` is locked down, because it holds your auth token.** Neither default location
protects it on its own — `/etc` is world-readable, and `%ProgramData%` grants `Users` read access by
inheritance — so the installer sets the permissions itself: `chmod 600` plus `chown` to the service
user on Linux, and an explicit ACL of SYSTEM, Administrators and the service account on Windows.
> **`Scripts.csproj` is overwritten deliberately.** The stock file omits `Scripts/Custom/`, so the
> plugin would sit in the tree and never compile — and ServUO would not tell you, because it
> ignores the script build's exit code and silently reloads the previous `Scripts.dll`. That
@@ -311,87 +272,15 @@ How the installer handles it:
- **Opt-in.** The base install completes without it, and declining is a supported outcome, not a
degraded one.
- **Dry-run first, always.** Every patch is checked before anything is applied, and reported per
patch. Most real shards are hand-modified; a patch that does not apply is expected, not alarming.
- **A modified file is not automatically a refusal.** These patches touch three small regions of
three large files. If you have edited `Logging.cs` somewhere else entirely, the installer says so
and still applies the patch — it checks whether *the lines the patch edits* are still stock, not
whether the whole file is. It applies only where the surrounding lines match the patch exactly and
appear exactly once; anything less and it stops and hands you the hunk to apply by hand. It never
force-fits a patch by loosening the match.
- **Dry-run first, always.** Every patch is checked (`git apply --check`) before anything is
applied, and reported per patch. Most real shards are hand-modified; a patch that does not apply
is expected, not alarming.
- **All or nothing per feature.** The two vendor-sale patches are one unit and are applied together
or not at all — and within a patch, if one hunk cannot be placed safely, none are. A patch that
could have been placed but was held back by its sibling says exactly that; it is never reported as
applied.
- **It does not need `git`, and does not use it.** The matching and the writing are the installer's
own, which is why it can place a patch on a shard where `git apply` refuses — the shipped patches
and their target files do not all use the same line endings, and that alone defeats `git apply`.
Nothing outside a patched region is touched, down to the byte, and inserted lines take your file's
own line ending.
- **Your ServUO version is reported, not decisive** — but see the warning below before running this
on anything other than 57.4.
or not at all.
- **Skipped entirely on a ServUO that is not 57.4**, with a warning. Unverified diffs are never
applied to an unknown tree.
- **Recorded, and the `.patch` files cached**, so re-runs stay idempotent and `uninstall` can print
the exact hunks to revert — along with how each was applied, since a patch placed into a file you
had already modified is one to look at more carefully when reverting. Patches that were *not*
applied are cached too, because that is the copy the run tells you to apply by hand.
- **A copy of every file it edits is kept, exactly as it was beforehand**, under
`patches/originals/` in the installer's own directory — not in your ServUO tree. It is written
before the first edit and never overwritten, so however many times you re-run `install`, it stays
the version from before the tier ever touched the file. That is what lets you verify a revert
rather than reconstruct one.
- **Re-running is safe.** A patch already in place is recognised and left alone, and the record
keeps the way it originally landed rather than relabelling it.
### ⚠ On any ServUO that is not 57.4: unsupported, untested, no guarantees
> **Runic Gateway is designed, built and tested against stock ServUO 57.4.** That is the only
> supported version.
>
> On any other version — a newer release, an older one, or a fork — the patch tier is
> **UNSUPPORTED, UNTESTED, and NOT GUARANTEED TO WORK.** You may run it. If you do, you are on your
> own: it is not covered by support, and a bad outcome may not show up until your shard is live,
> because ServUO's script build reports success even when it failed and quietly keeps running the
> previous `Scripts.dll`.
>
> The installer will still refuse to place a patch anywhere the exact lines it edits have changed —
> but matching text is not the same as matching behaviour. A hunk can land correctly and still be
> wrong for a tree that has diverged around it.
>
> **Back up your ServUO tree first, and verify your shard boots and compiles afterwards.**
Because of that, on a non-57.4 tree the tier is off by default and takes a deliberate yes:
- the interactive prompt defaults to **no** and prints the warning above;
- `--patches` on its own is **not** enough — an unattended run must also pass
`--patches-unsupported-servuo`;
- the choice is recorded, and `doctor` keeps showing an unsupported-version row for the life of the
install — so whoever looks after this shard next can see it without being told.
A run where the tier is selected on a shard that has been worked on looks like this:
```
Patch tier 2 of 3 applied
✓ playervendor-sale-eventsink Server/EventSink.cs
stock file — applied at line 171, 1521, 1771, 2416
✓ playervendor-sale-gump Scripts/Gumps/PlayerVendorGumps.cs
file modified, patched region stock — applied at line 95
✗ commandlogging-event Scripts/Commands/Logging.cs
patched region has been modified (hunk 1) — not applied
apply this by hand, then re-run install to record it:
/etc/runicgateway/patches/commandlogging-event.patch
⚠ Server/EventSink.cs — a CORE ServUO file was patched. Rebuild the solution:
dotnet build ServUO.sln
A shard restart is not enough; ServUO's dynamic script build does not rebuild the core, and it
will not tell you so.
Not applied, so you do not get: no in-game moderation audit forwarding.
Everything else works. Apply the hunks by hand if you want them, then re-run install to record it.
```
The line numbers are where each hunk was actually found in *your* file, not where it sits in stock
ServUO — they differ as soon as anything above the region has been edited, and yours is the one to
go to.
the exact hunks to revert.
If it is skipped or fails, you lose exactly two things — **`vendor.sale` events** and **in-game
moderation audit forwarding**. Everything else works. You can apply the patches later by hand (see
@@ -436,20 +325,68 @@ Saving restarts the site's ingest client, so the change takes effect immediately
AES-GCM encrypted at rest and **never returned to any client** — losing it means reading it back
from `sidecar.toml` on the shard host, not from the website.
If the sidecar sits behind a reverse proxy, paste your **public** `https://` and `wss://` URLs
instead of the two the installer printed — it composes those from the sidecar's own bind address,
which knows nothing about what fronts it. Everything else on the page is unchanged.
### If your website is on a different machine
The sidecar binds `127.0.0.1:8080` by default, which is reachable only from the shard host. If your
website runs elsewhere, you must widen the bind — and then narrow the access:
website runs elsewhere, the recommended arrangement is a **TLS reverse proxy in front of the
sidecar** — this is a supported deployment and the one Runic Gateway itself runs, on a real domain
name.
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` (or a specific LAN address) and restart the
service.
2. **Firewall port 8080 to your website's address only.** The auth token is always on, but it
travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse proxy or a
VPN/WireGuard link, and give the website the proxied `https://` / `wss://` URLs.
**Leave `[web] bind` on `127.0.0.1:8080`** and let the proxy be the only thing that talks to it.
Widening the bind and firewalling the port is the alternative, not the default (see below).
The `[shard] bind` line is a different matter: leave it on `127.0.0.1:7788`. That socket accepts
*inbound commands* to the game, and being loopback-only is what makes that safe.
Give the website the **proxied** URLs — `https://link.example.com` and
`wss://link.example.com/ws` — in place of the `http://` / `ws://` pair the installer prints. Those
values are the sidecar's own view of itself; the proxy is what the outside world sees.
What the proxy must do:
| Requirement | Why |
|---|---|
| **Forward the WebSocket upgrade** (`Upgrade` / `Connection` headers, HTTP/1.1 to the upstream) | `/ws` is the live event feed. Without it the site's REST calls work and events never arrive — a confusing half-working state. |
| **Pass request headers through unmodified** | Auth is `Authorization: Bearer` (or `X-Api-Key`), and the website sends `X-UOLink-Version`. A proxy that strips unknown headers turns into a `401`, and a stripped version header just silently skips the mismatch check. |
| **Do not buffer the WS connection, and allow long-lived ones** | The feed is idle between events. The sidecar sends a WebSocket **Ping every 30 s**, so a read timeout of 60 s or more is safe as it stands — but a proxy that buffers responses will hold events instead of streaming them. |
| **Do not log query strings** | The sidecar also accepts `?token=…` (for clients that cannot set headers). If anything in your stack uses that form, a default access-log format writes your auth token to disk on every request. |
Nothing needs `X-Forwarded-For`: the sidecar never uses the client's IP for authorization, and the
browser IP that account provisioning cares about is supplied by the website in the request body.
An nginx server block that satisfies all of the above:
```nginx
server {
listen 443 ssl;
server_name link.example.com;
# your certificate directives here
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; # "upgrade" for WS, "" otherwise
proxy_set_header Host $host;
proxy_buffering off;
proxy_read_timeout 300s;
}
}
```
(with the usual `map $http_upgrade $connection_upgrade { default upgrade; '' close; }` at `http`
level). Caddy and Traefik handle WebSocket upgrades automatically and need no equivalent stanza.
**Without a proxy**, on a trusted network only: set `[web] bind` to `0.0.0.0:8080` or a specific LAN
address, restart the service, and **firewall the port to your website's address**. The auth token is
always required, but the sidecar speaks HTTP — on that path the token and every event cross the
network in the clear. Do not do this over the public internet.
The `[shard] bind` line is a different matter entirely: leave it on `127.0.0.1:7788` and never proxy
it. That socket accepts *inbound commands* to the game, and being loopback-only is what makes that
safe.
---
@@ -509,43 +446,21 @@ The command that makes this supportable. Run it before asking anyone for help
first thing a maintainer will want.
```
✓ Install record /etc/runicgateway/install.json (bundle 2026.08.04, installer 1.0.0, …)
✓ ServUO found /opt/ServUO (57.4)
✓ Overlay in sync 24 files, all hashes match install.json
⚠ Patch tier 1 applied — moderation-audit (region-match)
✓ uo-link installed uo-link-sidecar 1.1.0 (protocol 3)
config /etc/runicgateway/sidecar.toml database /var/lib/runicgateway/uo-link.db
✓ Service runicgateway-link.service active, enabled as runicgateway
✓ Sidecar reachable 127.0.0.1:8080 /health ok, up 6h, database ok
⚠ Patch tier 1 of 3 applied — vendor.sale unavailable
✓ uo-link installed 1.1.0
✓ Service running, enabled
✓ Sidecar reachable 127.0.0.1:8080 /health ok
✓ Protocol sidecar 3 = overlay manifest 3
✗ Shard connected no — the shard is running (pid 8123) but has not dialed in
✓ Bundle 2026.08.04 — up to date
✓ Backups 2026-08-04T09:12:44Z — 3 file(s) replaced by update to bundle 2026.08.04
3 kept in /etc/runicgateway/backups
✗ Shard connected no shard has dialed in since boot
```
Rows come from asking the installed sidecar (`--version`, `--print-config`) rather than from reading
`install.json`, so `doctor` reports what the binary would actually do — including which config and
database file the *service* resolves — rather than what the installer believes it was told. The
overlay row compares live file hashes against `install.json`, which is how it tells "you edited a
deployed file" from "the file is gone"; the bundle row is what tells you the overlay upstream has
moved on. Each patched file is re-checked against the cached copy of its patch, so a core upgrade or
a restored backup that quietly removed the tier's edits is caught here — nothing else would notice.
It writes nothing at all, and it is safe to run while the shard is up; that is in fact the only
state in which the last row can be `✓`.
**Reading the marks:**
| | |
|---|---|
| `✓` | as it should be |
| `⚠` | worth knowing, not broken — a stopped shard, a service you never registered, an unpatched tier, or no route to Gitea to check for a newer bundle |
| `✗` | broken. `doctor` exits `1` if any row is `✗`, so it can be run from a monitoring script; a `⚠` never causes that |
The distinction on the last row is worth spelling out: **shard not running** is a `⚠` (start it),
while **shard running and not dialed in** is a `✗` — that is the silent failure this whole guide
warns about, where ServUO reports a clean boot over a script build that failed.
Three of those rows come from asking the installed sidecar (`--version`, `--print-config`) rather
than from reading `install.json`, so `doctor` reports what the binary would actually do — including
which config and database file the *service* resolves — rather than what the installer believes it
was told. The overlay row compares live file hashes against both `install.json` and the release
manifest, which is how it tells "you edited a deployed file" from "the overlay moved on".
### `runicgateway update`
@@ -559,31 +474,6 @@ together — never to two independently-latest artifacts that may disagree.
Your `sidecar.toml`, your `Bridge.cfg` edits and your database are not touched. `Bridge.cfg` is
overwritten only if you have not changed it; a modified copy is reported, not clobbered.
**Anything it does overwrite is copied first.** Every `.cs` file the overlay owns is replaced
unconditionally — that is deliberate, they are code — so if you have edited one, the run saves your
copy under `backups/<timestamp>/` in the state directory before writing, alongside `sidecar.toml`
and any stock ServUO file the patch tier is about to touch. Each backup carries a `manifest.json`
saying where every file came from. The newest three are kept; `--no-backup` skips taking one.
Putting a file back is yours to do — the installer will not restore an old file over a newer
release, because it cannot know what has changed since. A run that overwrites nothing takes no
backup, so a no-op `update` leaves nothing behind.
It updates the ServUO tree `install.json` names — not a tree it detects — and it needs the shard
stopped, exactly as `install` does. There is nothing to update on a host that was never installed;
it says so rather than performing a first install under a verb that promises to preserve.
**Your auth token is not reprinted.** It has not changed and your website already has it. The one
thing an update can change that the site must be told about is the **protocol version**, and it says
so plainly when that happens — a stale number in Admin → Shard is answered with `409` and looks
exactly like your shard going offline.
**The patch tier under `update`:** features you already have are re-checked against the new release
(normally nothing to do), without asking you again — you consented when they were installed, and
that includes a shard where the tier ran unsupported. Features you never took are **named, not
applied**; run `update --patches` (or `install --patches`) to take one up. A shard that declined the
tier stays unpatched through every update.
### `runicgateway uninstall`
Removes what it exclusively owns, and **prints** everything else. The installer cannot know what you
@@ -592,19 +482,12 @@ your work.
| | |
|---|---|
| **Removed** | The sidecar binary, its service entry, `install.json` |
| **Kept** | `sidecar.toml`, `uo-link.db`, the cached patch set with its pre-patch originals, and every backup an upgrade took (`--purge` drops all of them) |
| **Printed, not done** | Every overlay file deployed into your ServUO tree, by path, for you to delete — with any file you have edited since deployment flagged, so you do not delete your own work by mistake |
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs` and `Logging.cs`, for you to revert — with how each landed, since one placed into a file you had already modified is worth a closer look. The pre-patch copy kept under `patches/originals/` is there to diff against. |
| **Removed** | The sidecar binary, its service entry, `install.json`, the cached patch set |
| **Kept** | `sidecar.toml` and `uo-link.db` — config and history survive (`--purge` drops them) |
| **Printed, not done** | Every overlay file deployed into your ServUO tree, by path, for you to delete |
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs` and `Logging.cs`, for you to revert |
It lists all of that **before** asking, and the prompt defaults to **no**. `--yes` proceeds, which is
what an unattended uninstall needs; nothing else about the command is destructive to your shard,
which is neither stopped nor started.
The report is also written to a file — `runicgateway-uninstall-<timestamp>.txt` in the directory you
ran the command from — so it survives the scrollback. That is why the cached patches and the
originals stay behind by default: they are the only offline record of what the tier changed once the
release tarball is gone, and the report tells you to diff against them.
The report is also written to a file, so it survives the scrollback.
---
@@ -612,17 +495,15 @@ release tarball is gone, and the report tells you to diff against them.
| Symptom | Cause and fix |
|---|---|
| **Windows asks for Administrator as soon as you launch it** | Expected, and it needs Administrator anyway. Windows applies *installer detection* to unsigned executables whose file name contains `install` and elevates them before the program starts. Run it from an already-elevated PowerShell and you will not see the prompt. |
| **"ServUO is running — stop it before installing"** | Correct, and not overridable. `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit; deploying underneath it corrupts one or both. Stop the shard, install, start it again. |
| Shard boots clean but nothing reaches the site | The classic silent failure: ServUO ignores the script build's exit code and reloaded a **stale `Scripts.dll`**. Run `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` and read the errors it prints. |
| `[bridge status` says `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service is running, and that `[shard] bind` in `sidecar.toml` matches `Host`/`Port` in `Bridge.cfg`. |
| `[bridge` is not a command | The plugin did not compile, or `Bridge.cfg` has the bridge disabled. See the row above. |
| Website says the shard is offline; `/health` is fine locally | The website cannot reach port 8080 — bind address or firewall. See [§5](#if-your-website-is-on-a-different-machine). Note that the site is *designed* to render normally with the shard offline, so this fails quietly by design. |
| Website says the shard is offline; `/health` is fine locally | The website cannot reach the sidecar — bind address, firewall, or proxy. See [§5](#if-your-website-is-on-a-different-machine). Note that the site is *designed* to render normally with the shard offline, so this fails quietly by design. |
| REST reads work but **no live events arrive** | The classic reverse-proxy symptom: the WebSocket upgrade is not being forwarded. Confirm the proxy sets `Upgrade`/`Connection` and speaks HTTP/1.1 upstream, and that the site's WebSocket URL is `wss://…/ws` — not `https://`. |
| The event feed connects, then drops every minute or two | A proxy read timeout below the sidecar's 30 s WebSocket ping interval, or response buffering. Raise the timeout and turn buffering off. |
| Website logs `409` from the sidecar | Protocol mismatch: the number in Admin → Shard does not match the sidecar's. The sidecar rejects rather than mis-parsing. Set the field to what `/health` reports (`protocol`). If the *sidecar* and *overlay* disagree, you have a hand-assembled pair — reinstall from a bundle. |
| `401` from the sidecar | Wrong or missing auth token. Read the live one back with `uo-link-sidecar --print-config --config <path>`; do not retype it from a screenshot. |
| **"service NOT REGISTERED" at the end of an otherwise successful run** | The host has no service manager the installer can drive — most often no systemd (a container, or a distro that never had it), or the `runicgateway` user could not be created. The binary and config *are* installed; the run prints the exact unit and commands to finish by hand. It never falls back to running the service as root or `LocalSystem`. |
| **Windows: `sc start` fails with 1053, "the service did not respond in a timely fashion"** | Almost always a **sidecar older than v1.2.0**, which cannot start as a service no matter how correct its config. 1053 is a handshake failure, not a crash: Windows waited 30 seconds for the process to identify itself to the service control manager, and a sidecar built before service support was added never does. Check with `"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --version`. Tell-tale signs: `sc query` shows `SERVICE_EXIT_CODE : 0` (nothing crashed), and running the same binary in the foreground with the same `--config` works perfectly. |
| Service registered but stops immediately | Distinct from 1053 above — here the process really did exit. On Windows read `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log`, which is where a service logs since it has no stdout, and check that `sc qc RunicGatewayLink` shows `--config` in `BINARY_PATH_NAME` and that `NT SERVICE\RunicGatewayLink` has read access to `sidecar.toml`; on Linux check the `runicgateway` user can read `/etc/runicgateway/sidecar.toml` and write `/var/lib/runicgateway/`, and read `journalctl -u runicgateway-link`. |
| A patch will not apply | Expected on a hand-modified shard. The base install is unaffected; you lose only the two features in [§4](#4-the-patch-tier-optional). Apply the hunks by hand if you want them. |
| `vendor.sale` events never arrive despite patching | The `EventSink.cs` patch is a **core** change. A shard restart is not enough — rebuild the solution (`dotnet build ServUO.sln`). |
| Sidecar writes its database somewhere unexpected | A relative `[store] path` resolves against the directory holding `sidecar.toml` — not the working directory. Run `--print-config` to see the absolute path it will actually use. |
@@ -632,20 +513,15 @@ release tarball is gone, and the report tells you to diff against them.
## Appendix A — installing by hand
This is what the installer automates, done by hand. It is a **supported path**, not a deprecated
one — reach for it when the host cannot run the binary, when you would rather not run an unsigned
one, when you want to place every file yourself, or when you are developing on the bridge and
installing from a working tree instead of a release. It is also the reference for what
[§2](#2-run-it) does under the hood.
For a normal shard, [the installer](#1-download-and-verify) is fewer steps and checks more.
This is what the installer automates. It works today, on the current releases, and is the fallback
whenever you would rather not run an unsigned binary.
Throughout: `<servuo>` is your ServUO root, and **the shard is stopped**.
### A1. Fetch the bundle (so you install a checked pair)
```bash
curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json
```
It names the sidecar tag, the overlay tag, their agreed `protocol`, and the SHA256 of every asset.
@@ -687,20 +563,10 @@ cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/
```
`git apply` works in a plain directory — the shard does not need to be a git repo. If you use
`patch` instead, note that some core files are CRLF while others are LF: use `patch --binary`.
**If `git apply` refuses a patch whose target region is visibly untouched, line endings are the
usual cause** — the `.patch` files and their targets do not all use the same ones, and `git apply`
compares them literally. The installer's own tier normalizes line endings and trailing whitespace
for the *comparison* while writing back your file's own endings, which is why it can place patches
`git apply` rejects. Running the installer is the easier route here.
`patch` instead, note the core files are CRLF: use `patch --binary`.
### A3. Install the sidecar
On an arm64 host substitute `uo-link-sidecar-linux-aarch64` for the asset name below (`uname -m`
says `aarch64`); releases from v1.2.0 carry both. Take the version from the bundle you fetched in
A1 rather than the one written here.
```bash
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/SHA256SUMS
@@ -780,43 +646,15 @@ Copy-Item .\uo-link-sidecar-windows-x86_64.exe "$env:ProgramFiles\RunicGateway\u
& "$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe" --print-config --config "$env:ProgramData\RunicGateway\sidecar.toml"
# The config file now holds your auth token. Lock it down before anything else can read it:
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r '*S-1-5-32-544:(F)'
# binPath carries the config path. The single quotes matter: the value itself contains the double
# quotes the service manager needs around a path with spaces in it.
sc.exe create RunicGatewayLink `
binPath= '"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --config "C:\ProgramData\RunicGateway\sidecar.toml"' `
obj= 'NT SERVICE\RunicGatewayLink' start= auto
sc.exe create RunicGatewayLink binPath= "\"$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe\"" start= auto
sc.exe failure RunicGatewayLink reset= 86400 actions= restart/5000
# The service account exists only once sc create has created it, so its grants come after:
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /grant 'NT SERVICE\RunicGatewayLink:(R)'
icacls "$env:ProgramData\RunicGateway" /grant 'NT SERVICE\RunicGatewayLink:(OI)(CI)M'
[Environment]::SetEnvironmentVariable('UOLINK_CONFIG', "$env:ProgramData\RunicGateway\sidecar.toml", 'Machine')
[Environment]::SetEnvironmentVariable('UOLINK_DB_PATH', "$env:ProgramData\RunicGateway\uo-link.db", 'Machine')
sc.exe start RunicGatewayLink
```
Four things there are easy to get wrong:
- **The sidecar must be v1.2.0 or newer.** Earlier builds are plain console programs, and the
Windows service control manager cannot supervise one: it waits 30 seconds for the process to
identify itself, then fails the start with **1053** even though the process is running and healthy.
From v1.2.0 the same binary does both — started by the SCM it runs as a service, started from a
shell it runs in the foreground, with no flag to choose between them.
- **The config path goes in `binPath`, not in a machine environment variable.** `sc.exe` has no
per-service environment, and a machine-wide `UOLINK_CONFIG` would be inherited by every process on
the host and survive an uninstall. Never leave the config path to the default — it is relative to
the service's working directory, which for a service is `%SystemRoot%\System32`.
- **The database needs no pinning here.** A relative `[store] path` resolves against the directory
holding `sidecar.toml`, which is already `%ProgramData%\RunicGateway`.
- **`obj=` is what keeps this off `LocalSystem`.** `NT SERVICE\RunicGatewayLink` is a virtual
service account: Windows creates it with the service, it has no password, and it exists only for
this service. Omit `obj=` and you get the most privileged local identity there is, for a process
listening on two TCP ports.
Once it is running, `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log` is where it logs — a
service has no console to write to. Seven days are kept.
Machine environment variables are read at service start, so set them before starting — and never
leave the config path to the default, which is relative to the service's working directory.
### A5. Connect the website, start the shard, verify

View File

@@ -1,54 +1,19 @@
# Runic Gateway Installer — plan
Status: **Shipped.** All five phases are built, the `edge → main` cutover merged on 2026-08-07
([installer#17](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/17)), and it cut the
first release —
[`v0.1.0`](https://gitea.whitlocktech.com/RunicGateway/installer/releases/tag/v0.1.0), publishing
`linux-x86_64`, `linux-aarch64` and `windows-x86_64.exe` plus `SHA256SUMS`. The binary does
everything [`INSTALL.md`](INSTALL.md) describes: the installer core (bundle resolution, ServUO
detection and validation, the overlay sync, `install.json` — [Phase 1 as
built](#phase-1--installer-core)), the sidecar half (binary, config, service, token handoff —
[Phase 2 as built](#phase-2--uo-link-install-and-service)), the patch tier (the rung ladder, the
unsupported-version path, the cached patch set — [Phase 3 as built](#phase-3--patch-tier-opt-in)),
the day-two commands `doctor`, `update` and `uninstall` ([Phase 4 as
built](#phase-4--diagnostics-and-updates)), and the packaging polish that gated the cutover
([Phase 5](#phase-5--packaging-polish)).
**The installer is now the supported way to set a shard up**, and the operator guide leads with it.
`INSTALL.md`'s [Appendix A](INSTALL.md#appendix-a--installing-by-hand) remains supported rather than
deprecated — it is the path for a host that cannot run the binary, for an operator who wants to
place files themselves, and for developing on the bridge from a working tree.
**Phase 5 came before the cutover, not after it** (org lead, 2026-08-05). The earlier order — cut
the release, then polish — would have published a first release immediately superseded by the next,
and the release layout is exactly what Phase 5 changed. Both cutover gates were met before the
merge:
1. **Phase 5, packaging polish** (§5) — scope settled and built: **no `.deb` and no MSI** (§5.1 —
both would give the service, its unit and its user a second owner), **Linux `aarch64` for both
components** (§5.2), **a backup of what an upgrade overwrites** (§5.3), and the docs a first
release invalidates (§5.4).
2. **The Windows SCM half verified on a real host**, 2026-08-07. It was worth insisting on: the
first real `sc start` failed with **1053**, because the SCM waits ~30s for a handshake a plain
console program cannot perform. Fixed in the sidecar
([link#29](https://gitea.whitlocktech.com/RunicGateway/link/pulls/29), v1.2.0) and verified end
to end against a live service — 13/13 checks, start in 1s, `RUNNING`, `/health` served, service
log written, clean stop —
with [installer#16](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/16) making the
installer diagnose 1053 as a handshake rather than blaming the config. systemd registration was
verified separately on 2026-08-05 against a real privileged systemd container, and that run
likewise found a bug no unit test had.
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
Status: **Phase 0 complete.** Every prerequisite in another repo has landed, the installer repo
publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) now specifies the operator-facing run
— so *what* the installer installs and *what using it looks like* both exist ahead of the binary.
No installer code exists yet; **Phase 1 is next.** 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)).
| Phase 0 item | State |
|---|---|
| 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) |
| 0.2 `link` installable (data paths + `--print-config`) | ✅ Merged — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) (docs half [docs#84](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/84)); released as [`v1.1.0`](https://gitea.whitlocktech.com/RunicGateway/link/releases/tag/v1.1.0) |
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json) |
| 0.4 This file + `INSTALL.md` | ✅ Merged — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run |
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json) |
| 0.4 This file + `INSTALL.md` | 🟨 In review — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run |
| — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) |
---
@@ -79,7 +44,7 @@ release/start scripts. The installer never writes a launcher.
| Composition | **Published bundle manifest** (§7.1). CI names an exact, protocol-checked combination of component versions; the installer fetches it at run time and `--bundle <tag>` pins one. Component releases regenerate JSON, not the installer binary |
| 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 | **57.4 is the only supported version.** The patch tier's gate is content, not a version string: a patch applies where the lines it edits are still stock and is handed to the operator where they are not (§2.2.1). Forks and hand-edited trees are the norm in a public audience, so a non-57.4 tree is still *allowed* to attempt the tier — but **unsupported, untested and not guaranteed**, behind a loud banner, a defaulted-to-no prompt and its own opt-in flag (§2.2.2) |
| 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 |
---
@@ -131,102 +96,12 @@ most real shards are hand-modified. Therefore:
audit forwarding**.
- The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a
shard restart.
- Record applied patches in `install.json`, **and cache the `.patch` files** next to it
- 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). As built this caches every patch the tier *evaluated*, not only those that applied,
because the refusal message names that path as the file to apply by hand. **The cache outlives an
uninstall** — see Phase 4, where the report that would have been left pointing at a deleted
directory is what settled it.
- **Cache the pre-image of every file the tier edits**, under `patches/originals/`, mirroring its
path in the ServUO tree. It is written before the first edit and never overwritten, so a revert
can be verified byte-for-byte rather than reconstructed from a printed diff — which matters most
after a `region-match` apply, where the surrounding file was already the operator's. It stays out
of the ServUO tree, since uninstall has promised never to clean up in there.
- **A `.patch` does not carry everything the tier needs.** Which patches form one unit, which
companion `.cs` follows which, whether a core rebuild is required and what declining costs are
declared by the overlay release and read from its manifest — see §7.0.
#### 2.2.1 A whole-file hash mismatch is not a verdict — check the region
A file-level hash compare answers "is this entire file stock?", which is the wrong question. The
patches touch three small regions of three large files; an operator who added a custom command to
`Logging.cs` or a hook to `EventSink.cs` has changed the file's hash without going anywhere near the
lines the patch edits. Refusing on the file hash alone hands most real shards a manual patch job
they did not need. So the decision is made in three rungs, cheapest and safest first, and only the
last one gives up:
| Rung | Test | Outcome |
|---|---|---|
| **0 — already applied** | The hunk's *post*-patch text appears in the file | No-op, recorded as applied. Keeps re-runs idempotent |
| **1 — file is stock** | Whole-file hash matches the patch's pre-image (`index <old>..<new>` in the diff — `git hash-object` on the target reproduces it) | Apply verbatim with `git apply` |
| **2 — region is stock** | File differs, but every hunk's stock-side region is still byte-identical | Apply hunk-by-hunk at the matched offsets |
| **3 — region is modified** | Anything else | **Do not touch the file.** Print the path, the hunks and what is lost; the operator patches by hand |
Rung 2 is the semantic review, and it needs no new metadata: a unified diff already carries the
stock text of the region it edits — the context lines plus the `-` lines *are* the pre-image. For
each hunk the installer reconstructs that block and searches the target file for it, under these
rules:
- **Exact match, not fuzzy.** Only line-ending (CRLF/LF) and trailing-whitespace normalization is
allowed. No `patch --fuzz`, no context reduction: dropping context to force a match is precisely
how a patch lands in the wrong method.
- **Exactly one occurrence, or it fails.** Zero means the region moved or was edited. More than one
means the anchor is ambiguous and the installer cannot know which the author meant. Both are
rung 3.
- **Line numbers are advisory.** The hunk header's offsets are used only to prefer the nearest
candidate when reporting; the match itself is by content, since insertions above the region shift
every number below it.
- **All-or-nothing per patch file, and again per feature.** If one hunk of a patch reaches rung 3,
none of that patch's hunks are applied — a half-patched `EventSink.cs` compiles against a
companion `.cs` that expects the whole thing, and a partial apply is harder for an operator to
unpick than an untouched file. The same rule then applies across the patches of one feature: the
two vendor-sale patches are a unit (the event, the call site, and the subscriber that needs both),
so a patch that *could* have been placed is held back when a sibling cannot be — and the run says
that rather than reporting it as applied.
- **Rung 0 is checked first and is also all-or-nothing.** A file where some hunks are already
present and others are not is a hand-merge in progress, not an idempotent re-run — that is
rung 3.
#### 2.2.2 Non-57.4 is allowed, unsupported, and must say so loudly
The rung ladder replaces the blanket ServUO-version gate. The old rule skipped the entire tier on
anything other than stock 57.4 on the grounds that unverified diffs must not be applied to an
unknown tree — but forks are the norm (§1), so that rule skipped the tier for most of the audience.
Content matching gives a stronger guarantee than a version string does: on a non-57.4 tree, rung 1
is simply unavailable (its pre-image hash cannot be trusted), the tier goes straight to rung 2, and
a hunk lands only where the surrounding lines are still character-for-character the ones the patch
was written against.
**That is a mechanical safety guarantee about where text lands. It is not a support commitment, and
the installer must never let the two be confused.** Runic Gateway is designed, built and tested
against **stock ServUO 57.4**. On anything else the patch tier is **unsupported, untested, and not
guaranteed to work** — a hunk can match textually and still be wrong for a tree whose surrounding
behaviour has diverged, and neither the shard's silent script build (§2.1) nor the installer will
tell you that. So:
- **The disclaimer is unmissable, not a footnote.** On a non-57.4 tree the tier prints a banner
before it is even offered — that 57.4 is the only supported version, that the operator is on their
own here, and that a bad outcome may not surface until the shard is running.
- **It is off by default and takes an explicit, separate yes.** The interactive prompt defaults to
**no** on a non-57.4 tree, and `--patches` alone is **not** consent: an unattended run must pass
`--patches-unsupported-servuo` as well. A flag an operator had to look up cannot be hit by
accident in a script copied from somewhere else.
- **The label follows the install.** `install.json` records the detected version and the fact that
the tier ran unsupported; `doctor` shows that row on every subsequent run, not just at install
time; and the uninstall report carries it too. An operator who inherits this shard six months
later must be able to see it without being told.
- **It is the first thing quoted back in a bug report.** The tier's summary line names the detected
version, so a pasted install log answers "which ServUO?" before anyone asks.
The version is detected and reported everywhere; it just no longer *silently* decides. A refusal
becomes an informed choice, which is the point — but it stays visibly the operator's choice.
**What the installer records.** `install.json` stores, per patch, which rung applied it
(`stock-hash`, `region-match`, `already-present`) and the hunk offsets it matched. `doctor` and
`uninstall` report that: a `region-match` apply on a modified file is a different support story from
a clean apply to a stock tree, and the operator should be able to see which one they have without
re-deriving it.
Phase 4).
### 2.3 Config paths collide with what the sidecar actually reads
@@ -247,21 +122,13 @@ Under `C:\Program Files\` that fails or silently lands in VirtualStore. Phase 0.
half in the sidecar — a relative `[store].path` now resolves against the directory holding
`sidecar.toml`, so pinning the config alone is enough to put the database somewhere deterministic —
but the config path itself is still CWD-relative by default, and "deterministic" is not the same as
"where this install wants it". The service definition therefore always pins the **config** path:
"where this install wants it". The service definitions therefore still 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\`**
**How each is pinned differs by platform, and Phase 2 settled it that way deliberately.** Linux's
unit carries `Environment=UOLINK_CONFIG=` *and* `Environment=UOLINK_DB_PATH=`, because `/etc` and
`/var/lib` are different directories and both need naming. Windows passes the config as `--config`
inside the service's own `binPath`, and pins nothing else: config and data are both
`%ProgramData%\RunicGateway`, so the sidecar's own anchoring rule already puts the database exactly
where the table above says. The alternative on Windows is a **machine-wide** environment variable —
`sc.exe` offers no per-service one — which every process on the host would inherit and which would
outlive an uninstall. See [Phase 2 as built](#phase-2--uo-link-install-and-service).
### 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
@@ -292,11 +159,9 @@ operators.
- **`servuo-plugins` had no release workflow.** Only `link` did. "Pull latest repository" is replaced
by a release tarball, which had to be built first — Phase 0 item 1, now in review
([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)).
- **arm64 was not buildable today.** `link/release.yml` cross-compiled only
`x86_64-unknown-linux-gnu` and `x86_64-pc-windows-gnu`, so `platform_key()` refused every other
host by name. That was a property of the workflows, not of Rust — Phase 5 adds
`aarch64-unknown-linux-gnu` to both components in the order §5.2 sets out. There is no `.deb`, so
the cross toolchain is the whole cost.
- **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` lives 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. Phase 0 item 1 gives it
@@ -313,13 +178,12 @@ Components are published as Gitea release artifacts. Operators download from the
Runic Gateway Installer v1.0.0
├── runicgateway-installer-windows-x86_64.exe
├── runicgateway-installer-linux-x86_64
├── runicgateway-installer-linux-aarch64 (Phase 5)
└── SHA256SUMS
uo-link v1.1.0 (existing release, extended)
├── uo-link-sidecar-windows-x86_64.exe
├── uo-link-sidecar-linux-x86_64
├── uo-link-sidecar-linux-aarch64 (Phase 5)
├── runicgateway-link_<ver>_amd64.deb (Phase 5)
└── SHA256SUMS
servuo-plugins v<ver> (new release, Phase 0)
@@ -363,7 +227,7 @@ The docs must state this up front rather than let users discover it as a scary d
┌────────┴────────┐ ┌────────┴────────┐
▼ ▼ ▼ ▼
overlay sync patch tier (opt-in) binary install service registration
(never deletes) (per-region rungs) + config + data (systemd / Windows SCM)
(never deletes) (git apply + guard) + config + data (systemd / Windows SCM)
```
Each component keeps its own lifecycle. ServUO's existing startup process is untouched.
@@ -445,9 +309,7 @@ Repo work that must land before an installer can exist.
- **Bundles are committed to the installer repo, not published as releases** — see §7.1 for
where and why. That was the one genuinely open question here, and the deciding factor is that
this repo's *own* releases are the installer binaries. They were committed to `main` until
2026-08-05, when the first run that actually had a bundle to write found `main` protected;
they now live on a `bundles` branch (§7.1).
this repo's *own* releases are the installer binaries.
- **Gate 1 reads the sidecar's protocol from source at the release tag**, not from the binary.
`--print-config` (Phase 0.2) would answer authoritatively, but only for releases from `v1.1.0`
onward, and `--bundle <tag>` has to be able to recompose a bundle from an older pair. Reading
@@ -480,12 +342,12 @@ Repo work that must land before an installer can exist.
might exist; it is the specification of what the run asks, where it writes, what it prints, and
what the operator does next. Phase 14 implement it.
- **It was useful before the installer existed, and outlives it.** Appendix A is the same
deployment done by hand — bundle fetch, tarball verify + overlay copy, the optional patch tier,
`--print-config` provisioning, and a systemd unit / `sc create` service — composed from the
released artifacts' actual contents and the sidecar's config and CLI source rather than from
memory. That appendix doubles as **Phase 1's acceptance test**: walking it end to end on a real
shard is what proves the automated path has nothing left to discover.
- **It is useful before the installer exists.** Appendix A is the same deployment done by hand —
bundle fetch, tarball verify + overlay copy, the optional patch tier, `--print-config`
provisioning, and a systemd unit / `sc create` service — composed from the released artifacts'
actual contents and the sidecar's config and CLI source rather than from memory. That appendix
doubles as **Phase 1's acceptance test**: walking it end to end on a real shard is what proves
the automated path has nothing left to discover.
- **The installer does not install itself.** §5's `runicgateway doctor` sketch implied a name on
`PATH`; nothing places one there, and adding self-installation would give the tool a second
lifecycle to manage. The guide names the downloaded artifact, says to keep it, and shortens it
@@ -496,11 +358,19 @@ Repo work that must land before an installer can exist.
in §6's handoff has a non-interactive equivalent and an unattended install is expressible.
- **A modified `Bridge.cfg` must survive an update** — see Phase 1, where this changes the sync
rule inherited from `deploy.ps1`.
- **Remote-website deployments needed an answer.** `[web] bind` defaults to `127.0.0.1`, which
only works when the site runs on the shard host. The guide says to widen it, firewall it to
the website's address, and front it with TLS or a VPN off a trusted network — because the
token is always required but travels as a plain bearer token over HTTP. `[shard] bind` stays
on loopback, since that socket carries inbound commands *into* the game.
- **Remote-website deployments needed an answer, and it is a reverse proxy.** `[web] bind`
defaults to `127.0.0.1`, which only works when the site runs on the shard host. The guide's
recommended arrangement — already in production on a real domain — is to **leave the bind on
loopback** and put a TLS reverse proxy in front, giving the website the proxied `https://` /
`wss://` URLs in place of the pair the installer prints from the bind address. Four
requirements make that work and are stated with an nginx block that satisfies them: forward
the WebSocket upgrade (`/ws` is the whole live feed), pass headers through unmodified (auth is
`Authorization: Bearer`, and a stripped `X-UOLink-Version` silently skips the mismatch check),
do not buffer and allow long-lived connections (the sidecar pings every 30 s, so a ≥60 s read
timeout is safe), and do not log query strings (`?token=` is an accepted auth form). Widening
the bind and firewalling the port stays documented as the trusted-LAN alternative, not the
default, because on that path the token crosses the network in the clear. `[shard] bind` is
never proxied and never widened — that socket carries inbound commands *into* the game.
### Phase 1 — installer core
@@ -521,79 +391,6 @@ Repo work that must land before an installer can exist.
timestamp.
- Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing.
**As built** ([installer#4](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/4)) — the
crate at the repo root, `install` implemented end to end, `doctor`/`update`/`uninstall` parsed and
answered with the phase they arrive in rather than "unrecognized command". The decisions that were
not already settled above:
- **It lands on `edge`, not `main`.** `release.yml` publishes an installer binary on every push to
`main`, and its crate guard was written to arm "the moment Phase 1 lands the crate" — which would
have published a binary that deploys the overlay but cannot install the sidecar, contradicting
everything `INSTALL.md` promises a release does. Phases 1 and 2 land on `edge`; the `edge → main`
cutover cuts the first release. `pr-checks.yml` gates PRs into `edge` on the same rules, so the
branch where the work happens is not the ungated one. No workflow needed a temporary edit.
- **The run says what it did *not* do.** A Phase 1 `install` ends with an unmissable block naming
the sidecar as not installed, pointing at `INSTALL.md` A3/A4, and printing the bundle's binary URL
and SHA256 so a hand install matches the pair. `--patches` is the sharp edge here: it is accepted
(so the flag surface is the published one) but reports `REQUESTED BUT NOT APPLIED — no stock
ServUO file has been touched`. A `--patches` run that completed quietly would be read as a
patched shard.
- **The crate is a library plus a thin binary, and the library is not named after it.** Windows
applies UAC *installer detection* to unsigned executables whose file name contains `install`: it
demands elevation before the process starts, and a non-interactive session gets `os error 740`
instead of a program. That is survivable for the shipped binary — it needs Administrator anyway,
and `INSTALL.md` already says to run it from an elevated shell — but Cargo names test harnesses
after their target, so a target called `runicgateway_installer` makes `cargo test` **unrunnable on
Windows**, on the machine the shard smoke tests live on. The code therefore sits in a library
called `rgdeploy`, the binary target keeps its published name, and `[[bin]] test = false` stops
Cargo building a harness under it. Nothing an operator sees changes.
- **Dependencies chosen for the MinGW cross-build:** `ureq` (blocking HTTP over rustls/ring — no
OpenSSL to cross-compile, and no async runtime for a tool that makes four sequential requests),
`flate2` on its pure-Rust backend, `tar`, `sha2`, `serde`/`serde_json`, `chrono`, `anyhow`, and
`sysinfo` for the running-shard check.
- **The shard-running check matches by path, not by process name.** `deploy.ps1` can look for a
process called `ServUO` because it only runs on Windows; on Linux the same shard is `mono` or
`dotnet` with `ServUO.exe` as an argument, and a name match would answer "not running" for a live
shard — the one wrong answer that corrupts `Scripts.dll`. The installer requires a process whose
executable or command line names *both* the tree being deployed into and `ServUO.exe`, so a second
shard elsewhere on the host does not block this deploy, and the installer never matches itself.
- **ServUO's version is read from `Server/AssemblyInfo.cs`**, not from `ServUO.exe`'s PE metadata:
it is the same *source* tree the patch tier diffs against, needs no dependency, and works
identically on Linux. `57.4.0.0` and `57.4` are normalized to compare equal. An unreadable version
is reported as `unknown` and treated as **not** supported — an unreadable version is not evidence
of a good one — which is what Phase 3 will gate the tier on.
- **`install.json` records a state, not a verb.** Per-file entries are `deployed` or
`kept-operator-modified`, never `add`/`change`/`unchanged`. Recording the run's verb made the
record differ between a first run and an identical second one, which rewrote the file on every
run and broke "a second run writes nothing" in the least visible way available. What later
commands need is whose copy is in the tree, and that does not change because time passed.
- **The `Bridge.cfg` decision compares against the last hash the installer *deployed*, not the last
hash it *saw*.** Once a file has been kept, the record's on-disk hash is the operator's content —
so a rule phrased as "is the tree still what the record last saw?" matches on the very next run
and overwrites exactly the file it had just protected. A keep has to stay kept for as long as the
edit is there; a live three-run test covers it, because the bug only appears from the second run
on.
- **A prior record is only consulted when it names this tree.** A host whose `install.json` points
at a different ServUO root — a shard moved or rebuilt beside the old one — is treated as having no
prior deployment, which errs toward keeping the operator's file.
- **The download is verified twice, for two different reasons.** The tarball's SHA256 is checked
against the bundle while it is being written (the trust anchor — these artifacts are unsigned);
then every extracted file is re-hashed against the release's own `manifest.json`, which catches a
truncated extraction and is what makes the hashes copied into `install.json` worth trusting. The
manifest's `protocol` and `version` are also cross-checked against the bundle, so an artifact that
disagrees with the matrix that named it stops the run before anything is written.
- **`RUNICGATEWAY_STATE_DIR` relocates the installer's own state**, so a run can be tested without
root. Documented in `--help` rather than hidden: an undocumented variable that moves where a tool
writes is worse than a documented one, and `doctor` must honour the same value to find what
`install` wrote.
Verified on this machine against a real ServUO 57.4 tree (`--verify`, which reported the tree's
`Bridge.cfg` as operator-owned and 23 code files as changed) and end to end into a scratch tree:
24 files deployed, a second run reporting `unchanged` and leaving `install.json` untouched, an
edited `Bridge.cfg` kept across three further runs while a hand-edited `.cs` was overwritten each
time, a pinned `--bundle`, a missing bundle tag, and a refusal — pid and path named, exit 1 — with a
process running out of the tree.
### Phase 2 — uo-link install and service
- Linux: binary → `/usr/bin/runicgateway-link`, config → `/etc/runicgateway/sidecar.toml`, db →
@@ -606,171 +403,11 @@ process running out of the tree.
That both writes the config the service will read and returns the token to print, so the service
never starts against a config that does not exist yet.
**As built** ([installer#5](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/5)) —
`src/sidecar.rs` (binary, config, handoff) and `src/service.rs` (systemd, Windows SCM), wired into
the same `install` run. The decisions that were not already settled above:
- **Both platforms run the sidecar as a dedicated unprivileged identity.** Linux gets the system
user this section already specified; Windows gets a **virtual service account**
(`sc create … obj= "NT SERVICE\RunicGatewayLink"`), which the SCM creates itself and which has no
password. Plain `sc create` would have run it as `LocalSystem` — the most privileged local
identity there is, for a process that listens on two TCP ports while its Linux twin deliberately
does not run as root. The account only exists *after* `sc create`, which fixes the order of the
file permissions below.
- **`sidecar.toml` is locked down, because it holds the token.** Neither default location protects
it: `/etc` is world-readable and `%ProgramData%` grants `Users` read by inheritance, so an
unprivileged local account could read the shard's auth token out of a stock install. Linux gets
`chmod 600` plus `chown` to the service user; Windows gets `icacls /inheritance:r` down to SYSTEM
and Administrators **before** registration, then a read grant for the service account after it
exists. The database directory gets a separate write grant, since SQLite writes journal and WAL
files beside the database.
- **`--verify` runs no part of the sidecar half.** `--print-config` provisions — it writes the
config and mints a token — so a dry run that called it would create exactly the state it claims
not to. A `--verify` run reports what would be installed, reads no token, and prints no handoff.
It also **carries the existing `link` section of `install.json` through untouched**, so a dry run
on an installed host cannot make its service disappear from the record.
- **The installed binary's protocol version is checked against the bundle, and a mismatch stops the
run before the service is registered.** Gate 1 (§7.1) read that number from source at the release
tag; this is the same check applied to the binary that will actually answer the website. The
binary is left on disk — harmless without a service — rather than the run pretending to succeed.
- **`RUNICGATEWAY_STATE_DIR` now relocates the sidecar binary too, and suppresses service
registration.** Phase 1 left the binary path alone because nothing wrote it. A relocated run that
still dropped a binary into `/usr/bin` and registered a system service would be exactly the
half-in-the-real-system accident the variable exists to avoid — and there is no such thing as a
relocated systemd unit or Windows service. Such a run also leaves file permissions alone, because
hardening a scratch config against the only account that will ever read it just breaks the next
test run.
- **A host the installer cannot drive gets the recipe, not a failure or a weaker service.** No
systemd (`/run/systemd/system` absent — the correct test, since `systemctl` is present in plenty
of containers where PID 1 is not systemd), or a service user that cannot be created: the binary
and config are still installed, `install.json` records `service: null`, and the run prints the
exact unit text and commands. There is **no fallback to `User=root` or `LocalSystem`** — a service
quietly running with more privilege than its own documentation promises is worse than one that was
not registered. The printed Windows recipe states plainly whether the run locked the config down
or the operator still has to.
- **`install.json` never records the token.** The `link` section holds versions, the binary's hash,
the config and database paths, and the service's name, unit path and account. The token goes to
the terminal and to `sidecar.toml`, and the record is a support artifact people paste into bug
reports.
- **The service is stopped before its binary is replaced, and restarted rather than started
afterwards.** On Windows the file is locked while the service runs (and `sc stop` returns as soon
as the stop is *pending*, so the stop is polled, not slept on); on Linux the replacement is
permitted but leaves the old code serving until something restarts it. `systemctl start` on an
active unit is a no-op, which is precisely the wrong outcome after a replacement.
Verified on this machine end to end against a relocated layout: the bundle's Windows sidecar
downloaded and checksum-verified, `--print-config` provisioning a fresh config and returning a
token, the §6 handoff printed with the URLs composed from the host rather than the bind address, a
second run reporting `unchanged` / `already present` and leaving `install.json` byte-identical, a
`--verify` run over an installed host writing nothing and preserving the `link` section, and a
tampered binary detected by hash and replaced with no stray staging file left behind.
**Service registration itself stayed unverified until Phase 4** — a relocated run deliberately
skips it, `sc create` needs elevation, and systemd needs a Linux host. It has now been run for
real, on a privileged Debian 12 container with systemd as PID 1, installing into `/usr/bin`,
`/etc/runicgateway` and `/var/lib/runicgateway` as root: the unit is written and `enable`d, the
service comes up `active, enabled` as the unprivileged `runicgateway` user, `sidecar.toml` lands
`600` owned by it, the database is created under `/var/lib` (so the `UOLINK_DB_PATH` pin works),
`/health` answers protocol 3, and `uninstall` takes the service, the unit, the binary and the
account away again while leaving `sidecar.toml`, the database and every overlay file in the ServUO
tree untouched.
Doing that found one bug that only a real service host could show, fixed in
[installer#8](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/8): **`user_created` has
to be sticky.** `service::prepare` answers "did *this run* create the account", which is false from
the second run on, so recording it verbatim made the field describe the run rather than the state —
the same class as Phase 1's two live-run bugs. It rewrote `install.json` on an identical re-run,
and it made `uninstall` (which removes only an account it created) silently leave behind the very
user this tool had added. The record now inherits `true` from a prior record naming the same
account, and only that one. Windows never showed it because the SCM's virtual account is not
something the installer creates.
**Still unverified: the Windows SCM half.** `sc create` demands elevation, and this machine's
automation runs unelevated; the systemd half above is the platform that could be driven end to end.
### 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.
The rung ladder of §2.2.1 is the bulk of the work here: parse each `.patch` into hunks, reconstruct
each hunk's pre- and post-image blocks, and resolve the file through rungs 03 before writing
anything. The unsupported-version path (§2.2.2) is part of this phase, not a later polish — the
banner, the defaulted-to-no prompt, the `--patches-unsupported-servuo` flag, and the unsupported
marker carried into `install.json`, `doctor` and the uninstall report. Two pieces carry the risk and want direct tests — the hunk parser (headers, `\ No newline
at end of file`, CRLF files) and the uniqueness rule (a region that appears twice must fail, not
pick the first). Fixtures are cheap: the three stock 57.4 files, each with a hand edit far from the
patched region (must reach rung 2), an edit inside it (must reach rung 3), and an already-patched
copy (must reach rung 0).
**As built** ([installer#6](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/6), with the
metadata half in [servuo-plugins#10](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/10))
`src/diff.rs` (the parser), `src/patch.rs` (the ladder and the applier) and `src/tier.rs` (consent,
writing, reporting, recording), wired into the same `install` run between the overlay sync and the
sidecar. The decisions that were not already settled above:
- **The engine is fully native; `git` is never invoked.** §2.2.1 wrote rung 1 as "apply verbatim
with `git apply`", but §1 chose the release tarball precisely so there would be **no git on the
shard host**, and rung 2 needs a native applier regardless. One engine now serves both: rung 1
keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image,
computed as a git blob SHA1 in process — while the write goes through rung 2's code path. That
leaves one set of CRLF and whitespace behaviours to reason about instead of two, and a bug report
never has to say which engine ran. It is also not academic: the shipped `.patch` files are CRLF in
a Windows checkout while two of their three targets are LF, so `git apply` **refuses** patches
this places correctly.
- **What a `.patch` cannot say is declared by the release, with a built-in fallback.** Which patches
form one all-or-nothing unit, which companion `.cs` follows which, whether a **core** rebuild is
needed, and what declining costs are all things a diff does not carry. `servuo-plugins/patches/tier.json`
declares them and the release workflow folds them into `manifest.json` as `patch_tier` (§7.0), so
adding a patch regenerates release metadata rather than requiring an installer release — the same
rule §7.1 applies to the bundle. Overlay `v0.1.1` is in the current bundle and declares nothing,
so the installer carries a built-in description of exactly that release; a declared tier always
wins. A checked-in fixture of the release workflow's **own jq output** asserts the two descriptions
are identical, so the two repos cannot drift apart quietly — the failure mode otherwise is a tier
that is silently never offered.
- **All-or-nothing gained a second level.** §2.2.1 makes it per *patch file*; the tier is also
all-or-nothing per **feature**, because the two vendor-sale patches are one unit — `EventSink.cs`
grows the event, `PlayerVendorGumps.cs` raises it, and the companion subscribes to it. Applying
either alone yields a tree that does not compile or silently never emits. A patch that could have
been placed but was held back by a sibling says so in as many words; reporting it as applied is
the exact misreading this tier exists to prevent.
- **The pre-image of every patched file is cached**, under `<state>/patches/originals/`, mirroring
its path in the ServUO tree. The tier is the only part of the installer that edits a file the
operator owns, and this is what turns "here are the hunks we added" into a revert anyone can
verify — which matters most for a `region-match` apply, where the surrounding file was already
theirs. It lives in the state directory rather than beside the file it copies, because an
installer-owned file inside the ServUO tree is one `uninstall` has promised never to clean up. It
is written before the first edit and never overwritten, so it stays pre-tier however many times
`install` runs.
- **Every patch the tier *evaluated* is cached, not only the ones that applied** — a refinement of
§2.2's "cache the applied `.patch` files". The refusal message names that path as the file to
apply by hand (as §4 of `INSTALL.md` already illustrated), so caching only successes would point
an operator at a file the run had decided not to write.
- **Rung 0 reuses the previous record whole rather than re-deriving it.** The rung is the
support-relevant fact — how did this land? — and a later run re-deriving it answers
`already-present` for something that first landed as `region-match`. That flip rewrites
`install.json` on the second run of an identical install, which is the same class of bug as the
`Bridge.cfg` comparison in Phase 1: a record describing the run instead of the state. A tree
patched by hand per `INSTALL.md` Appendix A2 has no prior record, so there `already-present` is
correctly what gets minted.
- **Declining never erases what an earlier run applied**, and no longer claims a loss that is not
real. `--no-patches` and an unselected prompt both carry the previous `patches` section through
untouched, as `--verify` does — and the "Without it:" line now names only the features the record
does not already show as applied.
- **Withholding `--patches-unsupported-servuo` skips the tier loudly rather than failing the run.**
By that point the overlay is deployed and the sidecar is about to be installed; turning a completed
base install into exit 1 over a tier documented as optional would cost the operator more than the
tier is worth. Saying nothing would be the real failure, so it is reported where it happens.
Verified on this machine against the ServUO 57.4 tree at `C:\Users\colby\Desktop\ServUO`, across
four scratch roots built from its real files: a hand-patched tree (rung 0 on both vendor-sale
patches), a reverse-applied stock one (**rung 1 on the real `EventSink.cs`**, whose blob hash
reproduces the patch's declared `index d30788f` pre-image), a feature resolving at mixed rungs, a
tree with edits inside two patched regions (rung 3 — nothing written, the placeable sibling held
back, no companions copied, and all three patches cached anyway), and a non-57.4 tree both with and
without the extra consent flag. Three consecutive runs left `install.json` byte-identical, the
patched files unchanged, and the cached pre-image still pre-patch.
### Phase 4 — diagnostics and updates
`runicgateway doctor` — the command that makes the whole thing supportable:
@@ -778,7 +415,7 @@ patched files unchanged, and the cached pre-image still pre-patch.
```
✓ ServUO found /opt/ServUO (57.4)
✓ Overlay in sync 24 files, all hashes match install.json
⚠ Patch tier 1 of 3 applied (region-match) — vendor.sale unavailable
⚠ Patch tier 1 of 3 applied — vendor.sale unavailable
✓ uo-link installed 1.1.0
✓ Service running, enabled
✓ Sidecar reachable 127.0.0.1:8080 /health ok
@@ -813,246 +450,19 @@ a clever automatic revert risks silently eating their work. It removes and it re
| Action | Scope |
|---|---|
| Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` |
| Kept | `sidecar.toml`, `uo-link.db`, the cached patch set with its pre-patch originals, and (from Phase 5) the backups an upgrade took — `--purge` to drop them |
| 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 — with the rung that applied each one (§2.2.1), since a `region-match` apply means the surrounding file was already the operator's — for them to revert by hand |
| **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.
**As built** ([installer#7](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/7)) —
`src/doctor.rs`, `src/update.rs` and `src/uninstall.rs`, plus `service::observe`/`service::remove`
and a `Mode` on the install pipeline. The decisions that were not already settled above:
- **`update` is the `install` pipeline in a different mode, not a second implementation.** This
section describes it as "re-resolve the bundle, then move both components to it" — which is what
an `install` over an existing deployment already does, down to keeping a modified `Bridge.cfg`
and restarting the service after replacing its binary. A separate implementation would have given
the sync rules, the two protocol cross-checks and the record-carrying logic a second place to
disagree. What actually differs is four things: a prior record is **required** (an `update` on an
uninstalled host is a typo or a state directory the run cannot see — never a first install under
a verb that promises to preserve), the tree comes from that record rather than from detection (a
host with two shards must not have an update silently move to the other one), the tier's scope
narrows, and the close is a diff instead of a handoff.
- **`update` does not reprint the token, and does call out a protocol change.** The token has not
changed and the website already holds it; reprinting a secret nobody has to act on just puts it
in another scrollback. The protocol number is the one thing an update *can* change that the
website has to be told about — a stale value in Admin → Shard is answered `409` and looks to an
operator exactly like the shard going offline.
- **The tier under `update` re-resolves only what an earlier run applied, without asking again.**
Not a fresh offer: a shard that declined stays unpatched through every update, which is what
opt-in has to mean. Consent is not re-sought for what is already in the tree — including on an
unsupported ServUO, where `install` demands a second flag — because the record *is* the evidence
that the operator opted in, and re-prompting would make an unattended update impossible on
precisely the hosts that most need their patches re-checked when an overlay moves. New features
the release offers are named but not applied; `--patches` is how they are taken up. A feature the
record shows as applied that the release no longer declares keeps its record rather than being
dropped: its edits are still in the tree, and a record that forgot them would stop `uninstall`
printing hunks that are really there.
- **`doctor` asks the thing itself, and asks it the way the service does.** `--print-config` is run
under the same `UOLINK_DB_PATH` the unit pins, so the config and database it names are the ones
the *service* opens rather than the ones the binary would pick on its own — which is what §5's
sketch promised and a bare call would have got wrong on Linux. It is also run **only when the
config already exists**, because that flag provisions: a diagnosis must not create the state it
is reporting on.
- **`doctor` exits `1` when a row failed, and a `⚠` never causes that.** The rule makes it readable
from a monitoring script, and the split is what keeps the report worth reading: a stopped shard
is a `⚠` with the reason ("you have not started it"), while a *running* shard that has not dialed
in is the `✗` (§2.1's silent failure). Being offline is a `⚠` too — a shard host with no route to
Gitea is a supported way to run this, and failing a health check over it would report a working
deployment as broken. Both network calls take short timeouts for the same reason.
- **The patch row re-resolves each recorded patch against the tree.** The cached `.patch` makes it
possible offline, and the expected answer is rung 0. A core upgrade, a hand revert or a restored
backup silently removes the tier's edits, and nothing else in the report would notice.
- **The cached patch set and `patches/originals/` survive an uninstall** — a deviation from the
table above, which listed them as removed. The report that same command prints tells the operator
to diff their stock files against those originals; deleting them would have made the advice
impossible to follow within one command's output. They are the only offline record of what the
tier changed once the release tarball is gone, so `--purge` is what removes them, alongside the
config and the database. The report names every path it left behind.
- **`--yes` means yes on `uninstall`, not "take the default".** Everywhere else that flag answers an
offer the *run* made, so taking the safe default is right. Here the operator typed the destructive
verb; reading `--yes` as "no" would leave an unattended uninstall unable to express itself at all,
and a script that appears to succeed while removing nothing is the worse of the two failures. The
interactive prompt still defaults to **no**, after listing exactly what will and will not be
touched.
- **`uninstall` exits `1` for a step it could not carry out**, having done everything else. The
common case is a binary still locked by a sidecar somebody started by hand, so a permission error
on that file says so rather than sending the operator to look at ACLs. The Linux service account
is removed only when the record says this installer created it; Windows' virtual account goes with
the service.
- **The overlay listing flags files edited since deployment.** An operator deleting that list file
by file must not lose their own `Bridge.cfg` settings or a script edit without being told which
ones those are.
Verified on this machine against a scratch ServUO 57.4 tree built from the real files: a healthy
`doctor` (exit 0), one against a tree with a deleted overlay file, an edited one and a reverted
patch (all three found, exit 1), an `update --verify` that wrote nothing, a real `update` that
repaired all three and left `install.json` byte-identical, `uninstall` with and without `--purge`,
a second `uninstall`, a locked binary reported as a problem with exit 1, and `doctor`/`update` on a
host with no record. `fmt`/`clippy -D warnings`/tests were run for Linux in Docker as well as on the
Windows host, since only half of `service.rs` compiles on either.
### Phase 5 — packaging polish
The last work before the first release. It was sketched as four items — `.deb` packaging, Windows
MSI, arm64 cross build, optional automated backup before upgrade — and the org lead settled its scope
on 2026-08-05: **two of the four are dropped rather than deferred**, because what stops them is an
ownership conflict that does not improve with time, and two are built.
**It ran before the `edge → main` cutover rather than after it.** The original order assumed the
cutover would cut a v1 and packaging would follow as a v1.x — but this phase changes the *release
layout* (§3), so shipping first would have meant a first release superseded by the next one, and
operators who downloaded a bare binary being told to re-download a package. Deferring the cutover
cost nothing: nothing was published from `edge`, and the guide's Appendix A was the path meanwhile
(and remains supported now that it is no longer the default).
| Item | Decision | State |
|---|---|---|
| `.deb` for uo-link | **Dropped** (§5.1) | — |
| Windows MSI | **Dropped** (§5.1) | — |
| arm64 cross build | **Build** — Linux `aarch64`, both components (§5.2) | ✅ Merged — [installer#9](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/9), [link#26](https://gitea.whitlocktech.com/RunicGateway/link/pulls/26), [installer#10](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/10) |
| Backup before upgrade | **Build** — on by default, scoped to what a run overwrites (§5.3) | ✅ Merged — [installer#11](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/11) |
| Docs the release invalidates | **Do** (§5.4) | ✅ Done — `INSTALL.md` took the `aarch64` and backup content pre-cutover; the status rewrites landed after it |
#### 5.1 What ships is plain binaries: no `.deb`, no MSI
Both were proposed before Phases 2 and 4 existed. They now collide with the components those phases
made the installer own, and the collision is the deciding argument in each case.
- **A `.deb` under link's release would own `/usr/bin/runicgateway-link`, the systemd unit and the
service user** — the same three things `service.rs` writes, hardens and removes, and that
`install.json` records so `doctor` and `uninstall` can reason about them. Two owners for one file
is not a packaging detail: `uninstall` deleting a dpkg-owned binary leaves the package *installed*
and broken, an `apt upgrade` replacing that binary makes `doctor` report drift no operator caused,
and the unit text would live in two repos free to disagree about the account it runs as. The
variant that avoids all of that — a binary-only `.deb`, no unit, no user — buys apt-managed
upgrades of one file, which `update` already does from a **protocol-checked** bundle (§7.1). That
is the stronger of the two guarantees, so the package would be trading correctness for
familiarity.
- **An MSI contradicts a decision already locked**: the installer does not install itself (Phase 0.4
as built). It would also add a second uninstall path — Add/Remove Programs — beside the
`uninstall` verb, which owns state an MSI cannot see: `install.json`, the cached patch set and its
pre-images, and the ServUO-tree report that is printed rather than done (Phase 4). Unsigned, it
raises the same SmartScreen prompt a bare `.exe` does (§3), so it does not even buy the dialog it
looks like it should.
So §3's release layout loses its `.deb` line and gains no installer package. Nothing in Phases 14
changes — this item is a removal, which is why settling it is cheap and shipping it first would not
have been.
**What would reopen it.** If operators ask for `apt`- or `winget`-managed installs, the thing to
package is the sidecar as a standalone service *with the installer taught to detect and defer to a
package-managed one*. That is a design change to who owns the service, not packaging polish, and it
is out of scope here.
#### 5.2 arm64 — Linux `aarch64`, both components
§2.6 recorded arm64 as "not buildable today", which was true of the *workflows* rather than of Rust:
`link/release.yml` and `installer/release.yml` cross-compile `x86_64` Linux and Windows only, so
`platform_key()` (`src/bundle.rs`) refuses every other host by name. Ampere/Graviton instances and
Pi-class boxes are a realistic ServUO home, and the installer's dependency set was already chosen to
avoid OpenSSL and any C toolchain of its own (Phase 1 as built) — which is what makes this two build
steps per workflow rather than a toolchain project.
**Both components or neither.** An installer that runs on `aarch64` but resolves a bundle carrying no
`aarch64` sidecar has moved the failure later, not fixed it. Windows-on-arm and macOS stay unbuilt:
no supported MinGW target here for the first, and no shard host is the second.
**The order is forced by the bundle CI, which is strict on purpose** (Phase 0.3 as built): an
unrecognized `link` asset name is a hard failure, and `linux-x86_64` + `windows-x86_64` are asserted
present. So a link release carrying a new binary reddens the compose job unless it is taught the name
first — and requiring the name before link publishes it fails *every* bundle for as long as the gap
lasts. Hence:
| Step | Repo / branch | Change | Why it is this one first |
|---|---|---|---|
| 1 | `installer` `main` | `bundle.yml` maps `*-linux-aarch64``linux-aarch64`, and **does not** add it to the required list | The compose job runs from `main`; teaching it the name first means link's next release composes instead of failing |
| 2 | `link` `main` | `release.yml` adds `aarch64-unknown-linux-gnu`, cross-linked with `gcc-aarch64-linux-gnu` | Publishes the first `aarch64` sidecar; the nightly cron folds it into a bundle |
| 3 | `installer` `main` | `linux-aarch64` joins the required list | Only safe once a release actually carries it — from here a dropped target reddens CI instead of silently vanishing from every bundle |
| 4 | `installer` `edge` | `release.yml` adds the same target; `platform_key()` learns `("linux", "aarch64")` | The installer binary itself, on the branch that carries the crate |
`bundle.yml` is therefore edited on `main` only and never on `edge`, so the cutover merge has nothing
to conflict over.
**What building it found.** Neither crate cross-compiles with the arm64 *compiler* alone.
`gcc-aarch64-linux-gnu` only **recommends** `libc6-dev-arm64-cross`, and both release workflows
install with `--no-install-recommends` — so the C that each crate pulls in (bundled SQLite under
`sqlx` for the sidecar, `ring` under `ureq`'s rustls for the installer) dies on a missing
`bits/libc-header-start.h` while every Rust dependency builds fine. Both halves were reproduced in a
`rust:1-slim-bookworm` container, failure then fix, before either workflow was written.
#### 5.3 Backup before overwrite
**Scoped by what cannot be fetched again.** Not the sidecar binary or the overlay files — both are
re-downloadable and hash-named in the bundle. Not the database either: `link/sidecar/src/store.rs`
creates every table `IF NOT EXISTS` and every one of them holds shard state the sweeps repopulate, so
it is a cache with a schema rather than a record. What an `update` can destroy irrecoverably is:
1. **An operator's own edits to a deployed `.cs` file.** Phase 1 overwrites those unconditionally and
by design — `Bridge.cfg` is the single exception — so the one place the tool knowingly discards
work is the one place it should keep a copy.
2. **`sidecar.toml`**, whose token the website already holds. Mint a new one and the site's saved
config starts answering `409`/`401` with nothing on the sidecar to explain why (§2.4).
So the rule is **copy what this run is about to overwrite, plus `sidecar.toml`** — not a snapshot of
everything. A snapshot of all 24 overlay files would be mostly byte-identical to a release tarball
that is still downloadable, and the noise would bury the two files that matter.
- **Where:** `<state>/backups/<utc-timestamp>/`, each file mirroring its path in the tree it came
from, beside a `manifest.json` naming the source path, its SHA256, and the bundle moved from and
to. In the state directory, not the ServUO tree — the same rule the tier's `patches/originals/`
follows, and for the same reason: `uninstall` has promised never to clean up in there.
- **When: whenever the run is about to overwrite something**, whichever verb was typed. This
section first said "`update`, and `install` over an existing record", justified by a first install
overwriting nothing — **which is wrong**, and building it is what showed that. A tree deployed by
hand per [`INSTALL.md`](INSTALL.md) Appendix A2 — the path this project recommends while the
binary is unreleased — has `.cs` files the first `install` plans as `Change` and overwrites, with
no prior record anywhere to notice. The direct test covers that case, and a genuine first install
onto a clean tree still writes nothing, because there is nothing to copy. `--verify` writes none
either, for the reason it runs no part of the sidecar half (Phase 2): a dry run must not create
state.
- **`sidecar.toml` joins a backup that is already being taken; it is never the reason for one.**
Nothing in the installer rewrites it, so treating it as a trigger would leave a dated directory
behind after every no-op `update` — the empty-backup problem one step along. It is copied so that
a restored set of files arrives with the token that matches it.
- **`--no-backup`** opts out, for an operator with their own snapshotting.
- **Retention is three.** Older ones are pruned as new ones are written; an unbounded directory of
ServUO source copies on a shard host is its own support problem. `uninstall` keeps them and
`--purge` drops them, alongside the config, the database and the cached patch set — same rule as
Phase 4, and the same reason: they are the only offline record of what was there before.
- **The directory is created lazily and the manifest is written last**, so a directory carrying one
is a *complete* backup. Listing and pruning consider only those: a run interrupted mid-copy must
neither be mistaken for a backup nor be able to evict a good one by being newer than it. It is
left on disk for a human to look at rather than silently deleted.
- **Restore is printed, not done.** Consistent with the uninstall report, and for the identical
reason: the installer cannot know what has changed since, and a clever automatic restore over a
newer overlay eats work rather than saving it. `doctor` names the most recent backup and its
timestamp, so an operator asking "can I go back" does not have to know the layout to answer.
#### 5.4 Documentation the release layout invalidates
Not optional, and grouped here because a first release is the moment these are read for the first
time by someone who was not in the room:
- **`installer/README.md`'s status table** — the first thing a visitor to the repo reads, and it
described a tool that was neither finished nor released. Rewritten at the cutover.
- **`INSTALL.md`** gained the `aarch64` download lines, the backup behaviour and `--no-backup`
before the cutover, and its status banner afterwards: the installer is the path the guide leads
with, Appendix A the supported manual one.
- **This file:** §3 lost the `.deb` and gained the `aarch64` assets, §2.6's arm64 bullet is now
historical, and §7.1's example bundle grew the third asset key.
#### Cutover entry criteria — met, 2026-08-07
Both gates were satisfied before
[installer#17](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/17) merged: this phase,
and the **Windows SCM verification** (see the status header and
[Phase 2 as built](#phase-2--uo-link-install-and-service)). The second was not busywork — it failed
on its first real run with 1053 and needed a sidecar fix before it passed, exactly as running the
systemd half for real had turned up a bug unit tests could not. The bundle the release shipped
against was `2026.08.07`: link `v1.2.1`, overlay `v0.2.0`, both declaring protocol 3.
`.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.
---
@@ -1080,6 +490,13 @@ Every value in that block except the host and the site URL comes from one
`0.0.0.0`, which is not something to hand a website — so the installer composes the URLs from the
host it detects or prompts for, rather than echoing the bind address.
**It does not attempt to detect a reverse proxy**, which is the recommended arrangement for a
website on another host (`INSTALL.md` §5). Nothing visible from the sidecar's side says what fronts
it, so guessing would produce a confidently wrong `https://` URL. The two printed URLs always
describe the sidecar itself, and the guide tells the operator to paste their public `https://` /
`wss://` pair instead when there is a proxy. `--host` accepting a full origin later is a cheap
improvement if this proves annoying in practice.
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
@@ -1112,30 +529,14 @@ Shipped inside every `runicgateway-overlay-<ver>.tar.gz`, generated by that repo
"repo": "RunicGateway/servuo-plugins",
"protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"patch_tier": {
"features": [{
"name": "vendor-sale",
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
"lost": "no vendor.sale events",
"rebuild": "core",
"patches": [
{ "name": "playervendor-sale-eventsink", "file": "patches/playervendor-sale-eventsink.patch", "target": "Server/EventSink.cs" },
{ "name": "playervendor-sale-gump", "file": "patches/playervendor-sale-gump.patch", "target": "Scripts/Gumps/PlayerVendorGumps.cs" }
],
"companions": [
{ "file": "patches/BridgeVendorSale.cs", "install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs" }
]
}]
},
"files": { "overlay/Config/Bridge.cfg": "32718424…", "patches/…": "…" }
}
```
`version` and `commit` come from the release engine; `protocol` and the `servuo` block are read from
`servuo-plugins/overlay.toml`; `patch_tier` is folded in from `servuo-plugins/patches/tier.json`;
`files` is a SHA256 per shipped file.
`servuo-plugins/overlay.toml`; `files` is a SHA256 per shipped file.
Three of these carry weight beyond documentation:
Two of these carry weight beyond documentation:
- **`protocol` is a hand-maintained declaration, and has to be.** The plugin announces no version on
the wire and none is queryable before ServUO boots, so nothing in CI can derive it — which makes
@@ -1146,18 +547,6 @@ Three of these carry weight beyond documentation:
overlay moved on"** (§5, Phase 4). The installer copies these hashes into `install.json` at deploy
time; a later mismatch against *both* the manifest and `install.json` means upstream changed, a
mismatch against `install.json` alone means local edits.
- **`patch_tier` is everything a `.patch` cannot say about itself**, and is the reason the tier is
data rather than code. Which patches form one all-or-nothing unit, which companion `.cs` may only
be copied once that unit lands, whether the change needs a **core** solution rebuild or just the
dynamic script build, and what the operator loses by declining are none of them derivable from a
diff. Declaring them here means adding a patch regenerates release metadata rather than requiring
an installer release — the rule §7.1 already applies to the bundle. The maintainer-facing source
is `servuo-plugins/patches/tier.json`; the release workflow folds it in and removes the staged
copy, so the tarball carries exactly one statement of the table, and gates that every `.patch` is
described by exactly one feature, that every named patch and companion exists, and that each
declared `target` is the file its diff actually edits. Installers older than this key ignore it;
an installer newer than the overlay it is deploying falls back to a built-in description of the
release that predates it (see [Phase 3 as built](#phase-3--patch-tier-opt-in)).
`min_version` and `patches_verified_against` are separate on purpose. The base overlay only *adds*
files and is expected to work broadly; the patch tier diffs stock ServUO files and is verified
@@ -1178,7 +567,6 @@ resolving "latest", CI publishes a small manifest naming an exact, checked combi
"repo": "RunicGateway/link", "tag": "v1.1.0", "version": "1.1.0", "protocol": 3,
"assets": {
"linux-x86_64": { "name": "uo-link-sidecar-linux-x86_64", "url": "…", "sha256": "27d491ef…" },
"linux-aarch64": { "name": "uo-link-sidecar-linux-aarch64", "url": "…", "sha256": "…" },
"windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" }
}
},
@@ -1193,8 +581,7 @@ resolving "latest", CI publishes a small manifest naming an exact, checked combi
Note `link.assets` is a **map keyed by platform**, not the single `sha256` this section originally
sketched: link publishes a Linux binary and a Windows `.exe`, and the installer runs on both, so one
hash could only ever have described one of them. Phase 5's `linux-aarch64` (§5.2) is the first
addition that map was shaped for, and it costs the bundle nothing but a key. `schema` versions this document's shape and is
hash could only ever have described one of them. `schema` versions this document's shape and is
independent of `protocol` and of either component's release version — all three move separately.
The installer fetches the current bundle at run time; `--bundle <tag>` pins an older one for a
@@ -1217,44 +604,24 @@ Two gates run at compose time, both cheap and both worth it:
#### Where bundles are published
Committed to the installer repo, on a **`bundles` branch of their own** and at its root, so the
installer's fetch is a plain anonymous `GET` against a public repo — the shard host has no Gitea
credentials (§1):
Committed to the installer repo under `bundles/`, so the installer's fetch is a plain anonymous
`GET` against a public repo — the shard host has no Gitea credentials (§1):
```
current.json → …/RunicGateway/installer/raw/branch/bundles/current.json
bundle-<tag>.json → …/raw/branch/bundles/bundle-2026.08.04.json (--bundle)
bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json
bundles/bundle-<tag>.json → …/raw/branch/main/bundles/bundle-2026.08.04.json (--bundle)
```
Every bundle is kept forever, so `--bundle` stays reproducible. Tags are UTC dates; a second bundle
on the same day — a sidecar release in the morning and an overlay release in the afternoon is the
normal way that happens — becomes `2026.08.04.2`, so one tag always names exactly one matrix.
**A branch, not `main`, and that correction cost a day.** This section originally said bundles were
committed to `main` and "needs no new branch-protection exception: `release.yml`'s version-bump
commit already requires the CI user to be able to push to `main`". Both halves were wrong. `main` is
protected and declines a push from CI (`pre-receive hook declined`), and `release.yml` had never
pushed anything — its bump step has never executed in any repo carrying it, because an **empty
template expression written literally in one of its comments** makes the runner fail to build the
step and skip it *without failing the job*. The tags exist because Gitea's release API creates one
when it publishes. So the assumption that a working push path already existed was never tested by
anything.
Publishing to a branch of its own keeps every property the original choice was for — a reviewable
diff, a git history of the compat matrix, plain anonymous raw URLs, no credentials on the shard host
— and needs no exception at all. The alternative, whitelisting a scheduled job for pushes to the
default branch, buys nothing this does not.
**Not one Gitea release per bundle**, which was the obvious alternative. This repo's own releases
are the installer *binaries*, and `/releases/latest` returns whichever release is newest regardless
of kind — interleaving bundle releases would make "latest" intermittently resolve to a release
carrying no installer binary.
**The release workflows tag and never write to a branch**, for the same reason and settled at the
same time (org lead, 2026-08-05): the tag *is* the version, as `servuo-plugins` has always done it.
The version is still written into `Cargo.toml` before building — so a released binary self-reports
correctly — but is no longer committed back, and the next version is computed from the newest tag.
A first release must not depend on a write to a protected branch.
carrying no installer binary. Committing also yields a reviewable diff and a git history of the
compat matrix, and needs no new branch-protection exception: `release.yml`'s version-bump commit
already requires the CI user to be able to push to `main`.
### 7.2 What triggers a bundle
@@ -1313,51 +680,18 @@ mismatched pair from being published as a bundle — which is the mechanism that
## 8. Open questions
1. **Does the installer manage ServUO stop/start?** Currently it refuses while ServUO runs and tells
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.
2. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so
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?
Resolved and moved into §1 / §2.2 / §5: uninstall scope, and minimum ServUO version.
**Resolved — Windows service mechanism** (was question 1). Registration is `sc create` with
`obj= "NT SERVICE\RunicGatewayLink"` (plain `sc create` would run as `LocalSystem`, which the Linux
half pointedly does not do), `sc failure … actions= restart/5000` as the counterpart of systemd's
`Restart=on-failure` / `RestartSec=5`, and the config pinned in `binPath` rather than in a
machine-wide environment variable. No WinSW/NSSM shim: that would be a third binary to keep current.
**Corrected 2026-08-07 — the sidecar needs its own service mode after all.** This section previously
recorded that `sc create` against the *plain console binary* worked and needed no change to `link`.
It does not, and the first Windows run proved it: `sc start` failed with **1053** and the event log
read *"a timeout was reached (30000 milliseconds) while waiting for the … service to connect"*,
with `SERVICE_EXIT_CODE : 0` — the process had started fine and simply never spoke to the SCM.
The premise was a false symmetry with systemd. systemd supervises *any* foreground process; the
Windows SCM supervises only a process that calls `StartServiceCtrlDispatcher` within ~30 seconds and
then reports its own state transitions. There is no third option where `sc.exe` adopts an arbitrary
console executable — it is a service-aware binary or a shim, and the shim was already rejected.
So `link` gains a Windows service entry point (the `windows-service` crate, behind
`[target.'cfg(windows)'.dependencies]`). The objection that this puts Windows plumbing inside a
platform-agnostic component is answered by keeping it *only* at the edges: `app::run` is the whole
sidecar and is shared, while `windows.rs` and `unix.rs` do nothing but start it and tell it when to
stop. Nothing platform-specific reaches the shared path, and Cargo neither resolves nor builds the
Windows crates for Linux.
Consequences worth knowing:
- **One binary, no `--service` flag.** The dispatcher is tried first; failing with
`ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` (1063) means "not started by the SCM" and falls through
to a normal foreground run. `cargo run` and a hand-run diagnostic are unchanged.
- **A service has no stdout**, so in service mode the sidecar logs to a daily-rolled file beside its
config instead of into the void.
- **`Running` is reported only once the shard port is bound and the store is open**, so a bad config
fails the *start* rather than flapping Running → Stopped, and a failed run leaves a nonzero
`SERVICE_EXIT_CODE` behind rather than the misleading `0` above.
- **A sidecar older than v1.2.0 can never start as a service on Windows**, however good its config.
The installer says so by name when it sees 1053.
**Resolved — branch targeting for the new repo** (was question 4). The v3 cutover landed:
`servuo-plugins#6` merged, so that repo's `main` and `edge` agree at protocol 3. The release
workflow targets `main`, and the installer repo starts clean on `main`. §7.4's caution still applies

View File

@@ -1,67 +0,0 @@
# Runic Gateway installer — Project Tree
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
> the [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) repository, which
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
> by hand — changes will be overwritten by the next sync.
A snapshot of the tracked files in the repository (build output, dependencies, and other
git-ignored paths are excluded).
```text
installer/
├── .gitea/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── config.yaml
│ │ └── feature_request.md
│ ├── scripts/
│ │ └── gen_tree.py
│ ├── workflows/
│ │ ├── bundle.yml
│ │ ├── pr-checks.yml
│ │ ├── release.yml
│ │ └── sync-project-tree.yml
│ └── PULL_REQUEST_TEMPLATE.md
├── bundles/
│ └── README.md
├── src/
│ ├── backup.rs
│ ├── bundle.rs
│ ├── cli.rs
│ ├── diff.rs
│ ├── doctor.rs
│ ├── install.rs
│ ├── lib.rs
│ ├── main.rs
│ ├── net.rs
│ ├── overlay.rs
│ ├── patch.rs
│ ├── paths.rs
│ ├── record.rs
│ ├── service.rs
│ ├── servuo.rs
│ ├── sidecar.rs
│ ├── tier.rs
│ ├── ui.rs
│ ├── uninstall.rs
│ ├── update.rs
│ └── util.rs
├── tests/
│ ├── fixtures/
│ │ ├── commandlogging-event.patch
│ │ ├── patch_tier.json
│ │ ├── playervendor-sale-eventsink.patch
│ │ ├── playervendor-sale-gump.patch
│ │ └── published-bundle.json
│ └── real_patches.rs
├── .gitignore
├── Cargo.lock
├── Cargo.toml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── LICENSE.md
├── README.md
└── SECURITY.md
```

View File

@@ -25,16 +25,13 @@ link/
│ └── PULL_REQUEST_TEMPLATE.md
├── sidecar/
│ ├── src/
│ │ ├── app.rs
│ │ ├── cli.rs
│ │ ├── config.rs
│ │ ├── main.rs
│ │ ├── rpc.rs
│ │ ├── shard.rs
│ │ ├── store.rs
│ │ ── unix.rs
│ │ ├── web.rs
│ │ └── windows.rs
│ │ ── web.rs
│ ├── .gitignore
│ ├── Cargo.lock
│ ├── Cargo.toml

View File

@@ -1,12 +1,5 @@
# uo-link
> **Historical snapshot**, from before the bridge was split into
> [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) (sidecar) and
> [`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins)
> (plugin). Kept for the architecture notes below. **To set a shard up, use
> [installer/INSTALL.md](../installer/INSTALL.md)** — `deploy.ps1` as described here is a developer
> tool, not the operator path.
ServUO ⇄ Rust sidecar bridge. The shard emits newline-delimited JSON over a loopback TCP socket; the sidecar owns the WebSocket the website consumes.
```

View File

@@ -1,539 +0,0 @@
# Admin-Configurable Theming & Navigation
> Build contract for runtime-configurable theme, brand assets, and navigation.
> Derived from the design doc *Spec: Admin-Configurable Theming & Navigation*,
> **corrected to match the current codebase** and with the open questions resolved.
> Same workflow as the hero editor: design → phased build → verify.
## 1. Goal
Let the site admin customize, at runtime with no rebuild or redeploy:
1. **Visual theme** — colors, fonts (from a curated Google Fonts shortlist), and
corner radius / shadow depth — via three presets or per-group custom overrides.
2. **Brand assets** — logo, hero image, favicon — uploaded to override the
`BRAND_*` env defaults.
3. **Navigation** — reorder, relabel, and show/hide items in the public site nav,
admin sidebar, and player portal nav, via drag-and-drop.
All three follow the `settings.model.js` pattern already used for `hero_layout`:
a JSON value stored under a settings key, exposed through `getPublic()` where
needed, edited from an admin view, applied at runtime.
## 2. Core principle: `BRAND_*` env stays the default, always
[`server/src/config/brand.js`](../../website/server/src/config/brand.js) is the
existing single source of instance identity, read once at startup from env with
baked-in Runic Gateway defaults. The app ships as one prebuilt image and each
instance re-skins itself via env. **This feature must not disturb that.**
Every new setting is an *override layer*, never a replacement:
- An instance where the admin has not touched these settings renders
**identically to today**, driven entirely by `BRAND_*` and the current
`theme.css` `:root`.
- Saving one setting makes that setting — and only that setting — take
precedence. Untouched settings keep following env.
- This holds **per field**, not per feature. A custom accent with untouched
fonts means the accent comes from the DB and the fonts still come from
`--serif`/`--display`/`--sans` as `theme.css` defines them.
- "Admin-set" means **a DB row exists for that key**. Absence of the row — not an
empty or false value — is what triggers the env/CSS fallback. An admin who
explicitly picks a preset that happens to equal the shipped default has still
set it, and it is stored and honored as explicit.
- **No migration writes defaults into the settings table.** New and existing
installs both start with zero rows for these keys; that absence *is* the
"use env default" state.
## 3. Locked decisions
| # | Decision |
|---|---|
| Brand contract | **`getPublic().brand` returns effective values** (override → env). The Android app and Discord embeds track admin theming for free — see §4.5 |
| Structural tokens | **Radius + shadow depth only.** `spacingUnit` and `borderWeight` are **cut**, not deferred — see §4.6 |
| Radius token values | **Seeded at today's real values** (four tokens, not three), so the promotion step is a true no-op — see §4.7 |
| Presets in v1 | **Three dark presets** — Runic Gateway, Modern, Fantasy. Parchment (light) is Phase 9 — see §4.8 |
| Fonts | **Curated shortlist, dropdown-only**, 4 options per role, 8 web families in **one** `css2?` request — see §5 |
| Raw custom CSS | **Out of scope entirely** — not deferred. Materially different risk profile (overlay/clickjacking tricks, tracking pixels via `background: url(...)`); would need its own feature and its own review |
| Live preview | Out of scope for v1 |
| Reduced-motion toggle | Out of scope for v1 |
| Nav override power | **`label`, `order`, `hidden`, and (admin nav only) `group`.** Never `to`, `roles`, or `feature` — see §7 |
| Reset to defaults | **Deletes the settings row.** Never writes a stored copy of the defaults |
| Favicon uploads | **PNG only.** No `.ico` — see §4.10 |
## 4. Corrections to the design doc (current-code reality)
The design doc is structurally sound; the token architecture, the
override-on-top-of-env principle, the nav-override security framing, and the
reuse of `imageUpload.js` all match reality. These are the points where it does
not, listed worst-first. §4.14.5 are blocking; §4.64.11 are scope corrections.
### 4.1 There is no way to delete a setting
The entire "Reset to defaults deletes the row" principle — which all five new
keys rely on, and which the doc lists as an acceptance criterion — has no
implementation.
[`settings.db.js`](../../website/server/src/model/settings/settings.db.js)
exposes `get` / `getAll` / `set` / `seedDefault` only, and the admin API is
`PUT /admin/settings` taking a key/value object
([`admin.controller.js:499`](../../website/server/src/router/v1/admin/admin.controller.js)).
**Fix:** add `settingsDb.remove(key)` and a `DELETE /api/v1/admin/settings/:key`
route with an explicit key allowlist (the five new keys plus `hero_layout_draft`).
Admin-only, same gate as the existing settings routes. Deleting a key that does
not exist is a success, not a 404 — "reset" is idempotent.
### 4.2 Non-admins cannot read their own nav overrides
The doc says `nav_admin` / `nav_player` are admin-only settings "fetched by the
authenticated `AdminLayout` / `PlayerPortalLayout`." But `GET /admin/settings` is
gated `requireRole('admin')`
([`settings.router.js:18,28`](../../website/server/src/router/v1/admin/settings.router.js)),
while `AdminLayout` renders for **editors and moderators** and
`PlayerPortalLayout` renders for **players**. Those users have no endpoint from
which to read the key, so their nav would silently never apply the override.
**Fix:** new `GET /api/v1/settings/nav`, `isLoggedIn` only, returning
`{ nav_admin, nav_player }`. Not in `PUBLIC_KEYS` — an anonymous visitor has no
use for either, and the admin nav's labels leak the shape of the admin surface.
### 4.3 `renderIndexHtml` runs once at boot, not per request
[`app.js:207`](../../website/server/src/app.js) reads and templates `index.html`
at module load and serves that one string for every SPA route forever. The doc
describes overriding `logo`/`favicon` as "an async settings read inside a
currently-synchronous-feeling builder" — it is actually a lifecycle change, not
just an `await`.
**Fix:** keep the rendered shell cached in a module-level variable, render it
lazily on first request, and invalidate on any successful write to
`brand_assets`. Two hard requirements:
- A DB fault must never fail the page — on a read error, fall back to the
env-only shell (the current behavior).
- The shell must stay a single cached string in the steady state. Do not do a
settings read per page view.
### 4.4 Settings values are strings, not objects
`settings.value` is `TEXT`
([`schema.sql:126`](../../website/server/db/schema.sql)) and JSON-valued keys are
stored `JSON.stringify`'d and parsed client-side — see `parseLayout` in
[`heroLayout.js:58`](../../website/client/src/lib/heroLayout.js). The doc's
`settings.brand_assets?.hero` and `settings.nav_public` read as if they arrive
parsed. They do not.
**Fix:** one shared `parseJsonSetting(str, validator)` helper, used by every
consumer. A malformed or wrong-shaped value is treated as **absent** (falls back
to env/code default), never as an error and never as a partial object. This is
the same fail-safe posture `parseLayout` already takes.
### 4.5 The Android app and Discord embeds are silently excluded
`getPublic().brand` is a **documented cross-repo contract**, not an internal
detail. [`publicBrand.test.js:30`](../../website/server/test/publicBrand.test.js)
locks its field list, and the Android app's `BrandDto` seeds the entire Material
theme from `brand.accent` (`MainActivity.kt:72``RunicGatewayTheme`), with
`logo` / `hero` / `favicon` fields alongside it. `brand.accentInt` — derived once
at boot — is what Discord embeds color themselves with.
If theme and asset overrides live only in the new keys, an admin changes the
accent on the website and **the phone app and the Discord bot keep the old one**.
**Fix (locked):** resolve the *effective* values server-side in
`getPublic()`'s brand block
([`settings.model.js:130-141`](../../website/server/src/model/settings/settings.model.js)):
```js
accent: themeVisual?.colors?.accent ?? brand.accent
logo: brandAssets?.logo ?? brand.logo
hero: brandAssets?.hero ?? brand.hero
favicon: brandAssets?.favicon ?? brand.favicon
```
The web client needs **no change** for this — its existing
`setProperty('--accent', brand.accent)` line
([`SiteContext.jsx:30-32`](../../website/client/src/contexts/SiteContext.jsx))
simply receives a better value. Consequences to handle:
- `brand.accentInt` must be **recomputed from the effective accent** per request
rather than read from the boot-time constant, or Discord embeds drift.
- `publicBrand.test.js` gains cases: no rows → env values unchanged (the existing
assertions must still pass verbatim); `theme_visual` accent set → effective
accent returned; `brand_assets.favicon` set → favicon overridden while `logo`
and `hero` still come from env.
- The Android app needs **no change** to pick up accent/assets. Whether it should
also honor the full preset (radius, fonts) is a separate question for
`docs/android/PLAN.md`, out of scope here.
### 4.6 `spacingUnit` and `borderWeight` are not variable renames
The doc treats these as the same mechanism as color. They are not:
- **Spacing.** `theme.css` contains **zero** `calc()`-based spacings (the 5
`calc()` uses are all `width: min(…, calc(100% - 32px))` page shells). Every
padding is a hand-written non-multiple — `7px 14px`, `12px 26px`, `11px 14px`,
`13px 14px`. A density token that actually moves density means rewriting ~40
declarations into `calc(var(--space-unit) * n)`, and most of the app's real
spacing is inline JSX the token cannot reach anyway.
- **Border weight.** 39 hand-written `1px` borders, several of which are
*semantic* accents that must not scale with a density slider — `.note`'s 3px
left rule, `.page-quote`'s 3px, `.pb-tab`'s 2px active underline.
**Decision:** both are **cut from v1** and do not appear in the admin form.
Colors, fonts, radius and shadow depth cover "brand feel" cleanly; these two do
not, and shipping them as no-op fields would be worse than not shipping them.
### 4.7 Six radii cannot round-trip through three tokens
The doc's preset blocks set `--radius-card: 8px`, but the actual values in
`theme.css` are 14×`8px`, 4×`999px`, 4×`10px`, 1×`12px`, 1×`7px`, 1×`6px`. `.card`
and `.panel` are **10px** today and `.panel-flat` is **12px**. Adopting the doc's
three tokens verbatim would restyle every existing instance — including ones that
never touch the feature — which contradicts the acceptance criterion directly
above it.
**Fix (locked):** four tokens seeded at today's real values, so the promotion step
is genuinely a no-op:
```css
:root {
--radius-pill: 999px; /* .btn, .pill, .badge, .wiki-tag */
--radius-panel: 12px; /* .panel-flat */
--radius-card: 10px; /* .card, .panel */
--radius-input: 8px; /* .input, .textarea, .select, .btn-sq, .note, .rte, .prose img */
}
```
The 7px (`.rte-btn`) and 6px (`.rte-linkmenu-item`) values stay literals — they are
interior editor chrome, not brand surface. The preset blocks in §6 carry corrected
`--radius-card` values accordingly.
### 4.8 Parchment is a light-mode port, not a preset
`theme.css` carries 28 `rgba()` literals that assume a dark background — `.pill`'s
`rgba(11,22,48,0.5)` fill, `.note`'s background, all seven `.badge-*` fills, the
diff add/del colors, `.moon`'s radial gradient, `#dbe2ea` prose strong — plus the
hero overlay stacks `rgba(11,15,20,…)` hardcoded in `heroLayout.js` and four route
files, plus `rgba(9,13,18,0.86)` inline in `SiteHeader.jsx:55`. None of that
responds to a `[data-theme]` variable block; Parchment would inherit dark chrome
on a light background and look broken.
**Decision:** three dark presets in v1. Parchment becomes **Phase 9**, scoped as a
light-mode port with its own contrast pass across every component.
### 4.9 The hero already has a third override layer
`hero_layout.background.image_url` **already** beats `brand.hero`
([`heroLayout.js:39-50`](../../website/client/src/lib/heroLayout.js)). The real
resolution order is:
```
hero_layout.background.image_url → brand_assets.hero → BRAND_HERO → /assets/img/runic-emblem.png
```
The doc's two-link chain omits the existing top link. The admin UI must say so
explicitly, or "I uploaded a hero and the portal ignored it" becomes a bug report
against a working system.
### 4.10 Favicon `.ico` is not possible without weakening the upload path
`MIME_EXT` in
[`imageUpload.js:24-30`](../../website/server/src/router/v1/admin/imageUpload.js)
has no `image/x-icon` or `image/vnd.microsoft.icon` entry, and the stored
extension is derived from that map — which is exactly the property that makes the
upload path safe. The doc floats "`.ico`/`.png` only" for favicons; the `.ico`
half would mean adding a new file type to `/uploads`.
**Decision:** **PNG only** for favicons. `<link rel="icon">` accepts PNG in every
browser this app supports, and the allowlist is left untouched. A tighter size cap
than the shared 8 MB limit is applied at the route, not in the shared multer
config.
### 4.11 Smaller notes
- **CSP is already fine.** [`config/csp.js:50-51`](../../website/server/src/config/csp.js)
already allows `https://fonts.googleapis.com` in `style-src` and
`https://fonts.gstatic.com` in `font-src`. The font shortlist needs no CSP
change — which is worth stating, because widening CSP for a cosmetic feature
would not be worth it.
- **Do not touch the footer badge.**
[`SiteFooter.jsx:19`](../../website/client/src/components/SiteFooter.jsx) is the
hardcoded "powered by Runic Gateway" emblem. It is deliberately not the instance
logo and must not follow `brand_assets.logo`.
- **Nav labels do not reach the portal hero.** `hero_layout`'s
`default-quick-links` element duplicates News / Screenshots / Five on Friday /
Newsletter / About as its own buttons. Renaming those in the nav editor will not
rename them on the portal; they are edited in the hero editor.
- **The nav editor must refuse to hide its own entry.** Not a lockout — hiding is
presentation-only and the URL still resolves — but recovering by typing a URL is
a bad enough experience to be worth one guard.
- **Process, per `CLAUDE.md`.** Every server-side phase requires
`npm run swagger`, `npm run routes:manifest` (`routeManifest.test.js` fails
otherwise), and a matching edit to
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md). None of this is in the design doc.
## 5. Fonts: curated Google Fonts, not free text
`index.html` already loads Cinzel from Google Fonts, so this extends an existing,
already-trusted pattern rather than introducing a new one.
**The dropdown's value — not free text — is what is stored.** Each option's value
*is* the full CSS `font-family` stack exactly as it will be applied, so the client
does zero string-building from admin input and `theme_visual` stays a closed set of
known-safe values.
### 5.1 The shortlist
| Role | Option | Stored stack |
|---|---|---|
| **Serif body** | EB Garamond — strongest fantasy/historic | `'EB Garamond', Georgia, serif` |
| | Merriweather — excellent readability | `Merriweather, Georgia, serif` |
| | Playfair Display — elegant/editorial | `'Playfair Display', Georgia, serif` |
| | IM Fell English — strongest old-world/UO flavor | `'IM Fell English', Georgia, serif` |
| **Display heading** | Cinzel — current Runic Gateway identity | `Cinzel, Georgia, serif` |
| | Playfair Display — elegant alternative | `'Playfair Display', Georgia, serif` |
| | EB Garamond — softer/classic | `'EB Garamond', Georgia, serif` |
| | IM Fell English — very strong fantasy | `'IM Fell English', Georgia, serif` |
| **Sans UI** | Inter — default modern UI choice | `Inter, Arial, sans-serif` |
| | Work Sans — slightly more character | `'Work Sans', Arial, sans-serif` |
| | Source Sans 3 — extremely readable | `'Source Sans 3', Arial, sans-serif` |
| | Arial — safe fallback/system option | `'Helvetica Neue', Arial, sans-serif` |
Two properties fall out of this list and are worth keeping:
- **Arial is the zero-cost option** — its stack is byte-identical to today's
`--sans`, so it needs no webfont at all and doubles as the current default.
- **Twelve slots, eight web families.** Playfair Display, EB Garamond and IM Fell
English each serve two roles.
### 5.2 Loading
One combined request, not eight — Google Fonts accepts multiple `family=`
parameters per URL, and the font *binaries* are only fetched when a family is
actually applied:
```html
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
```
Static, in `index.html`, alongside the existing `preconnect` hints — a Google
Fonts URL is **never** built from admin input at runtime.
**Weight coverage gotcha:** IM Fell English ships **400 and italic only — no
bold.** `.display` and `.h1` use `font-weight: 600`, and `.btn` / `.eyebrow` /
`.badge` use 600700, so choosing it yields browser-synthesized faux-bold. That is
acceptable for the display role (it is the authentic look) but is a reason not to
present it as a recommended body face.
## 6. Storage
Five new keys. `theme_visual`, `brand_assets` and `nav_public` join `PUBLIC_KEYS`;
`nav_admin` and `nav_player` are served by the authenticated endpoint from §4.2.
All are JSON strings, absent by default.
### 6.1 `theme_visual`
```json
{ "preset": "runic-gateway", "custom": null }
```
or, when the admin picks Custom:
```json
{
"preset": "custom",
"custom": {
"colors": { "bg": "#0e1318", "bgDeep": "#0b0f14", "panelA": "#192231", "panelB": "#141a21",
"accent": "#7f99bd", "accentBright": "#cdd9e8", "ink": "#eef3f8", "text": "#c4cdd8" },
"structure": { "radiusPill": "999px", "radiusPanel": "12px", "radiusCard": "10px",
"radiusInput": "8px", "shadowDepth": "0 14px 34px rgba(0,0,0,0.3)" },
"fonts": { "serif": "'EB Garamond', Georgia, serif",
"display": "Cinzel, Georgia, serif",
"sans": "Inter, Arial, sans-serif" }
}
}
```
`colors` / `structure` / `fonts` are independently overridable groups — a custom
accent without touching radius or fonts is expected. A group or field the admin
never touched falls back to whatever preset or `:root` value is active. **Never
null a field out to "clear" it** — remove it from the object.
### 6.2 Preset blocks
`:root` (no `data-theme` attribute set at all) stays the **Runic Gateway** default
— today's actual values — so an instance with no `theme_visual` row renders
exactly as it does now. `runic-gateway` is *also* declared as a named preset so
that switching back to it after trying another is the same code path.
```css
[data-theme="runic-gateway"] {
--bg: #0e1318; --bg-deep: #0b0f14; --panel-a: #192231; --panel-b: #141a21;
--accent: #7f99bd; --accent-bright: #cdd9e8; --ink: #eef3f8; --text: #c4cdd8;
--radius-pill: 999px; --radius-panel: 12px; --radius-card: 10px; --radius-input: 8px;
--serif: Georgia, "Times New Roman", serif;
--display: Cinzel, Georgia, serif;
--sans: "Helvetica Neue", Arial, sans-serif;
}
/* Modern — flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy. */
[data-theme="modern"] {
--bg: #101114; --bg-deep: #0a0a0c; --panel-a: #1c1d22; --panel-b: #17181c;
--accent: #4f8ef7; --accent-bright: #a8c8ff; --ink: #f2f3f5; --text: #b8bcc4;
--radius-pill: 8px; --radius-panel: 8px; --radius-card: 6px; --radius-input: 6px;
--serif: Inter, Arial, sans-serif;
--display: 'Work Sans', Arial, sans-serif;
--sans: Inter, Arial, sans-serif;
}
/* Fantasy — warmer, higher contrast, carved corners; leans into UO harder. */
[data-theme="fantasy"] {
--bg: #1a120b; --bg-deep: #120c07; --panel-a: #2c1f14; --panel-b: #241a10;
--accent: #c9973f; --accent-bright: #e8c374; --ink: #f3e8d4; --text: #d3bfa0;
--radius-pill: 4px; --radius-panel: 3px; --radius-card: 2px; --radius-input: 2px;
--serif: 'EB Garamond', Georgia, serif;
--display: Cinzel, Georgia, serif;
--sans: 'EB Garamond', Georgia, serif;
}
```
**Derived-token rule (do not break this):** `--panel-grad` and `--shadow-card` must
stay expressed *in terms of* the other variables, never written as a literal
gradient in a preset block. If `--panel-grad` is ever hardcoded, a future light
preset silently inherits a dark gradient and looks broken. Likewise `--mode-live`
and `--mode-maint` (the status dots) are **semantic** — green means live — and stay
fixed across all presets rather than being themed.
### 6.3 `brand_assets`
```json
{ "logo": null, "hero": null, "favicon": null }
```
Each field, once set, holds the stored upload URL (`/uploads/1234-abcd.png`) — the
same shape `POST /admin/uploads` already returns. A `null` or absent field falls
back to `brand.logo` / `brand.hero` / `brand.favicon`; uploading a logo does not
force the admin to also pick a hero.
### 6.4 `nav_public` / `nav_admin` / `nav_player`
Keyed by the item's existing `to`:
```json
{
"/admin/posts": { "label": "Blog Posts", "order": 10 },
"/admin/settings": { "hidden": true },
"/admin/moderation": { "order": 5, "group": "Content" }
}
```
Any field absent for a given `to` falls back to the code default — label from
`NAV`, natural array order, `hidden: false`, original group. **Unknown `to` values
(not present in the current code's base array) are ignored, not stored and later
honored**, so removing a route in code can never leave a dangling override that
does something unexpected.
## 7. Navigation: hard constraint
The override system can **only** affect `label`, `order`, `hidden`, and — admin
nav only — `group` (which *existing* titled section an item sits under).
It **cannot**:
- introduce a `to` that is not already in the corresponding hardcoded `NAV` array;
- change or remove an item's `roles` (admin nav) or `feature` (public nav) gate;
- un-hide an item for a viewer whose role or feature check would otherwise fail.
The existing filters in
[`SiteHeader.jsx:43`](../../website/client/src/components/SiteHeader.jsx) and
[`AdminLayout.jsx:155-164`](../../website/client/src/routes/admin/AdminLayout.jsx)
run **after** the override merge, unchanged, and remain the actual security
boundary. The override layer is presentation-only. This is the same
"server-enforced gate, client-side is only about not advertising a dead end"
principle already documented in `SiteHeader.jsx`'s comments, and this feature must
not weaken it.
Two existing behaviors the merge must not disturb:
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
redirects them out of anything else. Overrides apply before that filter, so a
moderator can still end up with a legitimately short sidebar — but the redirect
effect must keep working untouched.
- **Empty groups.** `AdminLayout` drops groups whose items all filtered out. An
override that hides every item in a group must produce no orphaned header.
### 7.1 Merge util
New shared pure module, `client/src/lib/navOverrides.js`:
```js
function applyNavOverrides(baseNav, overrides) {
// baseNav: the existing hardcoded array / grouped array — remains the source
// of truth for `to`, `roles`, `feature`, `icon`, `end`
// overrides: the parsed settings JSON, or null when the admin never touched it
// returns: a new array of the same shape with label/order/hidden/group applied
}
```
`overrides` absent → return `baseNav` unchanged. This is the "respect defaults"
path and is the single most important case to test.
## 8. Build phases
Each phase is independently shippable and leaves the site rendering identically to
today until the admin acts.
| Phase | Work |
|---|---|
| **0 — Settings-store groundwork** | `settingsDb.remove()`; `DELETE /admin/settings/:key` with key allowlist; `GET /settings/nav` (§4.2); `parseJsonSetting()` helper; register the five keys; three into `PUBLIC_KEYS`. Swagger + route-manifest regen |
| **1 — `navOverrides.js` + tests** | The pure merge util, unit-tested in isolation. **The one piece with real correctness risk** |
| **2 — Radius/shadow token groundwork** | Promote the literals in `theme.css` to the four tokens of §4.7, values unchanged. Verify zero visual diff before any admin UI exists |
| **3 — Theme engine** | Three preset blocks, the combined Google Fonts link, `SiteContext` extension, and the effective-value resolution in `getPublic().brand` (§4.5) |
| **4 — Admin theme UI** | `/admin/appearance` view + route in `App.jsx` + `NAV`/`TITLES` entries in `AdminLayout.jsx` |
| **5 — Brand assets** | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the three shells; `heroImage` chain extension |
| **6 — Public nav wiring** | `SiteHeader.jsx``nav_public`. Lowest risk of the three: no roles, no groups |
| **7 — Nav builder UI** | `NavEditor.jsx` with `@dnd-kit` (new dependency), **Public tab only** |
| **8 — Admin + Player nav** | Wire the remaining two layouts, add the remaining two tabs, once the public pattern is validated in use |
| **9 — Parchment (optional)** | Light-mode port per §4.8 — its own contrast pass across every component |
Phases 02 are one PR pair (website + docs), 34 a second, 5 a third, 68 a
fourth.
### 8.1 Admin builder UI notes
- Tabbed control for the three navs; drag-and-drop reorderable list.
- **The palette is filtered to the editing admin's own visible items** — the base
array run through *their* role/feature check — so an admin cannot drag in, and
therefore can never accidentally expose, an item they cannot already see
themselves. A deliberate UX guardrail on top of the merge-time enforcement.
- Per item: label input with a "reset to default" that clears the override, an eye
toggle for `hidden`, and on the Admin tab a group dropdown limited to the fixed
set of titles already in `NAV`.
- "Reset to defaults" per nav **deletes the row** (§4.1), never saves `{}`.
## 9. Acceptance criteria
- Fresh instance, no admin action: colors, fonts, radii, brand assets and all
three navs render byte-for-byte as today, driven by `BRAND_*` and the current
hardcoded `theme.css` / `NAV` arrays.
- After Phase 2 and before any admin UI exists, the rendered site is visually
identical — the token promotion is a true no-op.
- Setting `theme_visual.custom.colors` alone changes colors only; radius, fonts,
assets and nav are unaffected.
- Font dropdowns only ever produce values from the §5.1 shortlist. No admin input
is concatenated into a `font-family` string or a Google Fonts URL at runtime.
- Setting only `brand_assets.favicon` changes the served favicon only — the OG
image and hero backgrounds still resolve from `brand.js` env values.
- With no `brand_assets` row, the served HTML shell is **byte-identical** to
today's. Covered by a server-side test in `publicBrand.test.js`.
- Uploaded assets go through the existing `imageUpload.js` mimetype allowlist. No
second upload path with weaker validation.
- `getPublic().brand` with no new rows returns exactly what it returns today —
the existing `publicBrand.test.js` assertions pass verbatim.
- An admin cannot, through the nav builder, cause any user to see a nav item their
role/feature gate would otherwise hide. Verified by overriding `hidden: false`
on a role-gated item as a lower-privileged test admin and confirming the filter
still hides it.
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
a stored copy of the defaults.