Two decisions from the org lead, recorded in the design of record. Phase 5 (packaging polish) now runs 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 itself: shipping first would mean a first release that is immediately superseded, and operators who downloaded a bare binary being told to re-download a package. Deferring costs nothing — nothing is published from `edge`, and INSTALL.md's Appendix A is the supported path meanwhile. The cutover therefore has two entry criteria, stated in the status header and at Phase 5: packaging polish, and the Windows SCM half being verified on a real host. The second is called out explicitly because `sc create`, the virtual service account, the failure actions and the token-file ACL have still never been executed anywhere — and running the systemd half for real is precisely what turned up a bug no unit test had. Nothing should be released while the only untested code is the half that registers a service. Co-Authored-By: Claude <noreply@anthropic.com>
80 KiB
Runic Gateway Installer — plan
Status: Phases 1 to 4 built, on edge. Phase 0's prerequisites all landed, the installer repo
publishes the bundle manifest, and INSTALL.md specified the operator-facing run
before the binary existed. The crate now implements the installer core (bundle resolution, ServUO
detection and validation, the overlay sync, install.json — Phase 1 as
built), the sidecar half (binary, config, service, token handoff —
Phase 2 as built), the patch tier (the rung ladder, the
unsupported-version path, the cached patch set — Phase 3 as built),
and the day-two commands doctor, update and uninstall (Phase 4 as
built). All four are on the edge branch, not main, so no
half-capable binary is released; the binary on edge now does everything INSTALL.md describes.
Phase 5 comes before the cutover, not after it (org lead, 2026-08-05). The earlier order — cut
the release, then polish — would publish a first release that is immediately superseded, and the
release layout is exactly what Phase 5 changes. So the edge → main cutover is now gated on two
things:
- Phase 5, packaging polish (§5) — the last work before a first release, rather than the first work after it.
- The Windows SCM half being verified on a real host. systemd registration has now been run for
real (see Phase 2 as built), and doing it found a bug no
amount of unit testing had.
sc create, the virtual service account, the failure actions and the token-file ACL have still never been executed — that path needs one elevated Windows run, and nothing should be released while the only untested code is the half that registers a service.
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).
| Phase 0 item | State |
|---|---|
0.1 servuo-plugins release workflow |
✅ Merged — servuo-plugins#7 + #8; first overlay release is v0.1.1 |
0.2 link installable (data paths + --print-config) |
✅ Merged — link#24 (docs half docs#84); released as v1.1.0 |
| 0.3 Bundle CI in the installer repo | ✅ Merged — installer#3, plus the dispatch step in each component (link#25, servuo-plugins#9). First bundle: 2026.08.04 |
0.4 This file + INSTALL.md |
✅ Merged — docs#87. INSTALL.md is the operator guide, written before the binary because it is the specification of the run |
| — Repo bootstrap (governance + CI) | ✅ RunicGateway/installer created; workflows merged (installer#1, #2) |
1. Purpose
Take a stock ServUO installation and configure it for Runic Gateway with minimal manual steps, while keeping the components separated and independently maintainable.
The installer handles environment detection, ServUO overlay deployment, the optional stock-file patch tier, uo-link installation and service registration, version tracking, diagnostics, and updates from Gitea releases.
It is a deployment tool, not a hosted bootstrapper. There is no curl | bash, no installer
service, and no hosted bootstrap script. Artifacts are downloaded from a Gitea release page and run.
It does not replace ServUO startup behavior. ServUO keeps running through its existing release/start scripts. The installer never writes a launcher.
Decisions locked
| Question | Decision |
|---|---|
| Audience | Public — any ServUO operator, not just shards we run |
| Code signing | Unsigned. SHA256SUMS is the trust anchor; SmartScreen/Gatekeeper warnings are expected and documented, as with most self-hosted tooling |
| Language | Rust — single static binary per OS, reuses the cross-compile pattern already proven in link/.gitea/workflows/release.yml |
| Plugin source | Release tarball artifact — no git and no Gitea credentials on the shard host |
| 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) |
| Uninstall | Never touches the ServUO tree. Removes uo-link and its service entry, then prints the overlay files to delete and the patch hunks to revert. Reverting is the operator's call |
2. Corrections to the original overview
These are not wording nits — each one changes what the installer has to do.
2.1 There is no RunicGateway.dll and no Plugins/ directory
The plugin ships as C# source and ServUO compiles it at boot. The real deployable is
servuo-plugins/overlay/, which mirrors the server root:
overlay/
├── Config/Bridge.cfg
└── Scripts/
├── Scripts.csproj # Phase 0 — whole-file overwrite of a stock file
└── Custom/Bridge/*.cs # 22 files
So the plugin step is a hash-compare file sync, not a DLL drop — mechanically easier than the
overview assumed. The sting is that a successful copy does not mean a working bridge. Per
link/SHARD_PREREQS.md, ScriptCompiler.Compile() shells out to dotnet build, prints the output,
ignores the exit code, and reloads the existing Scripts.dll. A broken script build is
invisible: the shard boots clean on stale code. Diagnostics must therefore verify post-boot state,
never treat "files copied" as success.
2.2 Stock ServUO files are modified — by an optional tier
servuo-plugins/patches/ holds unified diffs against stock ServUO 57.4, plus two .cs files that
can only be copied after their patch lands (they reference symbols the patch introduces):
| Patch | Target | Companion file | Rebuild required |
|---|---|---|---|
playervendor-sale-eventsink.patch |
Server/EventSink.cs |
BridgeVendorSale.cs |
Core — dotnet build ServUO.sln; the dynamic script build is not enough |
playervendor-sale-gump.patch |
Scripts/Gumps/PlayerVendorGumps.cs |
(same unit as above) | script build |
commandlogging-event.patch |
Scripts/Commands/Logging.cs |
BridgeModerationAudit.cs |
script build |
Plus overlay/Scripts/Scripts.csproj, which overwrites a stock file (Phase 0 — it fixes the silent
ServUO build bug above).
This is the hardest part of the installer. git apply against a hand-modified shard will fail, and
most real shards are hand-modified. Therefore:
- The patch tier is opt-in and skippable. The base install must complete without it.
- Always dry-run (
git apply --check) before applying, and report per-patch. - When skipped or failed, say plainly what is lost: no
vendor.saleevents, no in-game moderation audit forwarding. - The
EventSink.cspatch must warn loudly that a core solution rebuild is required, not just a shard restart. - Record applied patches in
install.json, and cache the.patchfiles 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 aregion-matchapply, 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
.patchdoes not carry everything the tier needs. Which patches form one unit, which companion.csfollows 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.cscompiles against a companion.csthat 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
--patchesalone is not consent: an unattended run must pass--patches-unsupported-servuoas 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.jsonrecords the detected version and the fact that the tier ran unsupported;doctorshows 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.
2.3 Config paths collide with what the sidecar actually reads
The sidecar reads $UOLINK_CONFIG, else sidecar.toml in the working directory
(link/sidecar/src/config.rs), with keys [shard].bind, [web].bind, [web].auth_token,
[store].path. The overview proposed a config.toml with [updates], [link], [servuo] — keys
the sidecar cannot read.
Two files, two owners:
| File | Owner | Contents |
|---|---|---|
/etc/runicgateway/sidecar.toml |
uo-link | The sidecar's own schema, unchanged. Service sets UOLINK_CONFIG to this path |
/etc/runicgateway/install.json |
installer | Deployed versions, file hashes, applied patches, ServUO path, timestamps |
Working-directory trap: the sidecar wrote both sidecar.toml and uo-link.db relative to CWD.
Under C:\Program Files\ that fails or silently lands in VirtualStore. Phase 0.2 fixed the second
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:
- 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.
2.4 The token handoff was missing entirely
The whole point is the website reaching the sidecar, and today that is manual and undocumented in the install flow: the sidecar generates a token on first run and logs it, then a human pastes base URL, WS URL, token, and protocol version into Admin → Shard, where it is AES-GCM encrypted and becomes write-only. This is the largest "I installed it and nothing happened" failure mode.
The installer closes it by printing a copy-paste block at the end of a successful run — see §6.
Phase 0.2 supplied the missing half of that: uo-link-sidecar --print-config provisions the config
if absent and prints the resolved settings — token, both binds, ws_path, protocol version, db
path — as JSON. The installer reads the block it prints out of that one call. It never parses the
log, which was the alternative and would have made the handoff depend on a log format that is not
a contract.
2.5 deploy.ps1 cannot be the cross-platform deployer
It is PowerShell-only; a Linux ServUO host running .NET typically has no pwsh. It also hard-throws
when the ServUO process is running — correct behavior, and the installer must inherit it (detect and
refuse, rather than corrupt a live Scripts.dll). The installer reimplements the sync natively; it
is a short hash-compare-and-copy that never deletes.
deploy.ps1 stays in servuo-plugins as the developer-facing tool. The installer is for
operators.
2.6 Prerequisites the overview assumed away
servuo-pluginshad no release workflow. Onlylinkdid. "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).- arm64 is not buildable today.
link/release.ymlcross-compiles onlyx86_64-unknown-linux-gnuandx86_64-pc-windows-gnu. An arm64.debneeds another cross toolchain. - The compat matrix has no home.
PROTOCOL_VERSIONlives inlink/sidecar/src/main.rs. The sidecar publishes it viaX-UOLink-Versionand/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 a home:servuo-plugins/overlay.toml, declared into the overlay manifest. See §7.0 / §7.1.
3. Distribution model
Components are published as Gitea release artifacts. Operators download from the release page
(browser, curl/wget, or scp to the server) and run the binary.
Runic Gateway Installer v1.0.0
├── runicgateway-installer-windows-x86_64.exe
├── runicgateway-installer-linux-x86_64
└── SHA256SUMS
uo-link v1.1.0 (existing release, extended)
├── uo-link-sidecar-windows-x86_64.exe
├── uo-link-sidecar-linux-x86_64
├── runicgateway-link_<ver>_amd64.deb (Phase 5)
└── SHA256SUMS
servuo-plugins v<ver> (new release, Phase 0)
├── runicgateway-overlay-<ver>.tar.gz # overlay/ + patches/ + manifest.json
└── SHA256SUMS
Binding those together is the bundle manifest (§7.1) — published by the installer repo's CI, not by any component, and the thing the installer actually resolves against.
scp runicgateway-installer-linux-x86_64 user@server:/tmp/
chmod +x runicgateway-installer-linux-x86_64
sudo ./runicgateway-installer-linux-x86_64
Unsigned-binary posture
Because releases are unsigned, trust is anchored on checksums and the operator's own verification. The docs must state this up front rather than let users discover it as a scary dialog:
- Every release publishes
SHA256SUMS; the install docs lead with the verification command for both OSes. - Windows will show a SmartScreen "unrecognized app" prompt. Documented, with the exact click path.
- The installer verifies the SHA256 of everything it downloads (overlay tarball, sidecar binary)
against the release's
SHA256SUMSand refuses on mismatch. Self-verification is not optional just because the installer itself is unsigned. - Revisit signing if it ever becomes affordable; the release layout should not have to change.
4. Component architecture
Runic Gateway Installer (Rust, one binary per OS)
│
┌───────────────┴────────────────┐
▼ ▼
ServUO integration uo-link
│ │
┌────────┴────────┐ ┌────────┴────────┐
▼ ▼ ▼ ▼
overlay sync patch tier (opt-in) binary install service registration
(never deletes) (per-region rungs) + config + data (systemd / Windows SCM)
Each component keeps its own lifecycle. ServUO's existing startup process is untouched.
5. Phases
Phase 0 — prerequisites (no installer code)
Repo work that must land before an installer can exist.
-
servuo-plugins: add.gitea/workflows/release.yml. Retarget the release engine half oflink/release.yml(its header comment explicitly anticipates this — the plan/release steps consume only{version, changelog, artifacts}). The adapter half producesrunicgateway-overlay-<ver>.tar.gzcontainingoverlay/,patches/, and amanifest.json(version, commit, per-file SHA256, declared protocol version, minimum ServUO version).As built (servuo-plugins#7), with three deviations from
link's copy that each fell out of the repo rather than being chosen:- No build gates, structural gates instead. Nothing in that repo can be compiled without
ServUO reference assemblies, so CI asserts what it honestly can:
Bridge.cfgand the Bridge scripts present,Scripts.csprojpresent (its absence ships code that never compiles while ServUO reports success — §2.1), every.patchparseable viagit apply --stat, and each patch's companion.cspresent. - No bump commit, so no push to
main.linkwrites the version intoCargo.tomlbecause the binary embeds it; the tarball embeds nothing but the generated manifest, so the tag is the version. That workflow needs no branch-protection exception. overlay.tomlat the repo root holds the declaredprotocoland the ServUO compatibility values, read by CI into the manifest. It exists because the number needs one maintained home — see §7 for why the plugin cannot simply be asked.
The tarball uses a fixed top-level directory,
runicgateway-overlay/, not a versioned one: the installer looks foroverlay/,patches/andmanifest.jsonat known paths rather than parsing the version it is trying to read. Member order, mtime and ownership are pinned, so a given tree yields a byte-identical tarball and its checksum moves only when its contents do. - No build gates, structural gates instead. Nothing in that repo can be compiled without
ServUO reference assemblies, so CI asserts what it honestly can:
-
link: make the sidecar installable. Confirm/settle default data paths, and add a way to read back config non-interactively (e.g.--print-configemitting JSON: bind addresses, token, protocol version, db path) so the installer does not have to scrape logs for the token.As built (link#24) — the sidecar had no CLI at all before this, so the shape was chosen rather than inherited:
- Four flags, hand-rolled:
--print-config,--config <PATH>,--version,--help. No argument-parsing crate — it would be larger than the code it replaced — and deliberately no flags that duplicate a config key, sosidecar.tomlstays the single place settings live. An unrecognized argument exits2; silently ignoring a typo'd flag would start a sidecar that is not the one the installer asked for. --print-configperforms first-run setup rather than only reporting. It runs the same load path a normal start does, so a missing config file is written and a blank token is generated and saved. That collapses "provision the sidecar" and "find out its token" into one non-interactive call — which is exactly the sequence §6 needs.config_createdandtoken_generatedsay whether this run did either, because the values alone cannot distinguish a fresh install from a re-read of an existing one, and a re-run must not report a token as newly minted.- The document is the whole of stdout. The log subscriber writes to stdout, so it is not
started in this mode.
ws_pathis emitted from the same constant the route is registered with, so the installer's WebSocket URL cannot drift from the server's. - Relative
[store].pathnow anchors to the config file's directory, not the CWD — see §2.3, which this half-closes on the sidecar side. Absolute paths are used as written; parent directories are created;:memory:andfile:URIs are left alone. - The db path is handed to sqlx as a path, not a
sqlite://URL. The URL spelling is parsed as one: it percent-decodes the path and splits it on?, so an installed path containing%20opened a different file than the operator named. - No platform data directories are compiled in. That is the "settle" half of this item, and
the answer is that the installer owns layout (§2.3) and pins
UOLINK_CONFIG/UOLINK_DB_PATHin the service definition. Baking/etcand%ProgramData%defaults into the binary would give the same paths two owners and breakcargo runin a working tree.
- Four flags, hand-rolled:
-
Bundle CI in the installer repo (§7). Compose job (read both repos' latest releases → run the two gates → publish
bundle.json), the nightly cron, and the dispatch step appended to each component's release workflow. This must exist before Phase 1 is useful, since the installer resolves what to install from the bundle.As built (installer#3, link#25, servuo-plugins#9) —
installer/.gitea/workflows/bundle.yml, with the decisions §7 had left open:- 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.
- 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 fromv1.1.0onward, and--bundle <tag>has to be able to recompose a bundle from an older pair. Readingsidecar/src/main.rsat the tag the release was built from works uniformly, needs no execution of a downloaded artifact, and does not provision a throwaway config whose auth token would then be sitting in a CI log. A constant that has moved or been renamed is a hard failure — treating "could not read" as "matches" is exactly how a mismatched pair would ship. - Gate 2 records the hash CI computed itself, after verifying the download against the
publishing repo's
SHA256SUMS. It also asserts the reverse direction — an asset with noSHA256SUMSentry — becausesha256sum -csilently passes over a file the sums file does not mention, which would put an unverified artifact in the bundle. - Release metadata is read anonymously, on purpose: those are exactly the requests the shipped installer makes on a host with no Gitea credentials, so a repo flipped to private fails CI here instead of on an operator's machine.
- An unrecognized asset name is a hard failure. link's binaries are mapped onto platform keys by suffix; adding a target (aarch64, macOS) to its release workflow therefore reddens this job rather than silently omitting the new binary from every bundle.
- A run that changes nothing writes nothing — the comparison excludes
bundleandgenerated, which are metadata about the run. Without that the nightly cron would commit a dated duplicate of the same matrix every morning.
The workflow's compose steps were run against the live releases before merge, producing the first bundle (
2026.08.04: linkv1.1.0+ overlayv0.1.1, protocol 3), which is committed so the manifest exists ahead of the binary that reads it. -
docs: this file, plusdocs/installer/INSTALL.md(the operator-facing guide) once the shape is settled.As built (
INSTALL.md) — written before the binary on purpose. Everything it installs is already released (items 1–3), so the guide is not speculation about a tool that might exist; it is the specification of what the run asks, where it writes, what it prints, and what the operator does next. Phase 1–4 implement it.- 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-configprovisioning, and a systemd unit /sc createservice — 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 doctorsketch implied a name onPATH; 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 in later examples. - The flag surface got fixed here, because a guide cannot describe a run in the abstract:
--verify,--bundle,--purgewere already named by §5/§7;--servuo,--patches/--no-patches,--host,--site-urland--yesare the remainder, chosen so every prompt in §6's handoff has a non-interactive equivalent and an unattended install is expressible. - A modified
Bridge.cfgmust survive an update — see Phase 1, where this changes the sync rule inherited fromdeploy.ps1. - Remote-website deployments needed an answer.
[web] binddefaults to127.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] bindstays on loopback, since that socket carries inbound commands into the game.
- 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,
Phase 1 — installer core
- ServUO root detection and validation (
ServUO.exe,Scripts/,Config/), with version detection and an explicit refusal when the ServUO process is running. - Overlay sync: fetch tarball → verify SHA256 → hash-compare against the server tree → add/change,
never delete. Port of
deploy.ps1semantics including its-Verifydry run (--verify). - One deviation from
deploy.ps1: an operator-modifiedConfig/Bridge.cfgis reported, not overwritten.deploy.ps1overwrites every file whose hash differs, which is right for a developer redeploying their own tree and wrong for an operator who has setLinkUrl,PublicConnectAddressand sweep intervals — anupdatewould silently revert the shard's entire configuration.install.jsonrecords the hash deployed, so the installer can distinguish "the operator edited this" from "the overlay moved on" (§7.0) and act only on the second. The rule is specific toBridge.cfg: it is the only file in the overlay that is meant to be edited in place, and it carries no code, so a stale copy cannot break the build. Every.csfile andScripts.csprojstill overwrite unconditionally. - Write
install.json: component, version, source commit, per-file hashes, applied patches, timestamp. - Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing.
As built (installer#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, notmain.release.ymlpublishes an installer binary on every push tomain, 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 everythingINSTALL.mdpromises a release does. Phases 1 and 2 land onedge; theedge → maincutover cuts the first release.pr-checks.ymlgates PRs intoedgeon 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
installends with an unmissable block naming the sidecar as not installed, pointing atINSTALL.mdA3/A4, and printing the bundle's binary URL and SHA256 so a hand install matches the pair.--patchesis the sharp edge here: it is accepted (so the flag surface is the published one) but reportsREQUESTED BUT NOT APPLIED — no stock ServUO file has been touched. A--patchesrun 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 getsos error 740instead of a program. That is survivable for the shipped binary — it needs Administrator anyway, andINSTALL.mdalready says to run it from an elevated shell — but Cargo names test harnesses after their target, so a target calledrunicgateway_installermakescargo testunrunnable on Windows, on the machine the shard smoke tests live on. The code therefore sits in a library calledrgdeploy, the binary target keeps its published name, and[[bin]] test = falsestops 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),flate2on its pure-Rust backend,tar,sha2,serde/serde_json,chrono,anyhow, andsysinfofor the running-shard check. - The shard-running check matches by path, not by process name.
deploy.ps1can look for a process calledServUObecause it only runs on Windows; on Linux the same shard ismonoordotnetwithServUO.exeas an argument, and a name match would answer "not running" for a live shard — the one wrong answer that corruptsScripts.dll. The installer requires a process whose executable or command line names both the tree being deployed into andServUO.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 fromServUO.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.0and57.4are normalized to compare equal. An unreadable version is reported asunknownand 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.jsonrecords a state, not a verb. Per-file entries aredeployedorkept-operator-modified, neveradd/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.cfgdecision 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.jsonpoints 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 intoinstall.jsonworth trusting. The manifest'sprotocolandversionare 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_DIRrelocates the installer's own state, so a run can be tested without root. Documented in--helprather than hidden: an undocumented variable that moves where a tool writes is worse than a documented one, anddoctormust honour the same value to find whatinstallwrote.
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 →/var/lib/runicgateway/, systemd unit with a dedicated user,enable+start. - Windows:
%ProgramFiles%\RunicGateway\, data in%ProgramData%\RunicGateway\, service registration with automatic start and restart-on-failure. - Both:
UOLINK_CONFIGandUOLINK_DB_PATHpinned in the service definition (§2.3). - Token surfacing (§6): run the installed binary once as
uo-link-sidecar --print-config --config <the pinned path>before registering the service. 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) —
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. Plainsc createwould have run it asLocalSystem— 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 aftersc create, which fixes the order of the file permissions below. sidecar.tomlis locked down, because it holds the token. Neither default location protects it:/etcis world-readable and%ProgramData%grantsUsersread by inheritance, so an unprivileged local account could read the shard's auth token out of a stock install. Linux getschmod 600pluschownto the service user; Windows getsicacls /inheritance:rdown 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.--verifyruns no part of the sidecar half.--print-configprovisions — 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--verifyrun reports what would be installed, reads no token, and prints no handoff. It also carries the existinglinksection ofinstall.jsonthrough 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_DIRnow 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/binand 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/systemabsent — the correct test, sincesystemctlis 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.jsonrecordsservice: null, and the run prints the exact unit text and commands. There is no fallback toUser=rootorLocalSystem— 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.jsonnever records the token. Thelinksection 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 tosidecar.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 stopreturns 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 starton 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 enabled, 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: 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 0–3 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, with the
metadata half in servuo-plugins#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;
gitis never invoked. §2.2.1 wrote rung 1 as "apply verbatim withgit 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'sindexpre-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.patchfiles are CRLF in a Windows checkout while two of their three targets are LF, sogit applyrefuses patches this places correctly. - What a
.patchcannot say is declared by the release, with a built-in fallback. Which patches form one all-or-nothing unit, which companion.csfollows which, whether a core rebuild is needed, and what declining costs are all things a diff does not carry.servuo-plugins/patches/tier.jsondeclares them and the release workflow folds them intomanifest.jsonaspatch_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. Overlayv0.1.1is 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.csgrows the event,PlayerVendorGumps.csraises 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 aregion-matchapply, 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 oneuninstallhas promised never to clean up. It is written before the first edit and never overwritten, so it stays pre-tier however many timesinstallruns. - Every patch the tier evaluated is cached, not only the ones that applied — a refinement of
§2.2's "cache the applied
.patchfiles". The refusal message names that path as the file to apply by hand (as §4 ofINSTALL.mdalready 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-presentfor something that first landed asregion-match. That flip rewritesinstall.jsonon the second run of an identical install, which is the same class of bug as theBridge.cfgcomparison in Phase 1: a record describing the run instead of the state. A tree patched by hand perINSTALL.mdAppendix A2 has no prior record, so therealready-presentis correctly what gets minted. - Declining never erases what an earlier run applied, and no longer claims a loss that is not
real.
--no-patchesand an unselected prompt both carry the previouspatchessection through untouched, as--verifydoes — and the "Without it:" line now names only the features the record does not already show as applied. - Withholding
--patches-unsupported-servuoskips 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:
✓ 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
✓ 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 shard has dialed in since boot
The last check matters most: it is the only thing that distinguishes "files copied" from "the bridge actually works" (§2.1).
Three of those rows are answered by the sidecar's own CLI rather than by inspecting the filesystem:
--version prints uo-link-sidecar <ver> (protocol <n>), and --print-config gives the config and
db paths the installed service resolves — so doctor reports what the binary would actually do,
not what install.json believes it was told to do. The protocol row compares that number against
the overlay manifest's declared one (§7.0).
runicgateway update — resolves the current bundle (§7.1), then acts asymmetrically by component,
deliberately:
- uo-link: compare the bundle's version against what is installed → download → verify checksum → replace binary → restart service.
- plugin overlay: download the bundle's overlay tarball → verify → re-sync (leaving a modified
Bridge.cfgalone — Phase 1) → record commit → tell the operator ServUO must restart (the installer does not restart the shard).
Because both come from one bundle, an update always moves to a combination whose protocol versions were checked together, rather than to two independently-latest artifacts that may disagree.
runicgateway uninstall — removes only what it exclusively owns, and never edits the ServUO
tree. The installer cannot know what the operator has changed in those files since deployment, so
a clever automatic revert risks silently eating their work. It removes and it reports:
| Action | Scope |
|---|---|
| Removed | uo-link binary, its service entry (systemd unit / Windows service), install.json |
| Kept | sidecar.toml, uo-link.db, and the cached patch set with its pre-patch originals (--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 |
The printed report is also written to a file, so it survives the terminal scrollback of a long uninstall.
As built (installer#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:
updateis theinstallpipeline 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 aninstallover an existing deployment already does, down to keeping a modifiedBridge.cfgand 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 (anupdateon 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.updatedoes 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 answered409and looks to an operator exactly like the shard going offline.- The tier under
updatere-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, whereinstalldemands 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;--patchesis 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 stopuninstallprinting hunks that are really there. doctorasks the thing itself, and asks it the way the service does.--print-configis run under the sameUOLINK_DB_PATHthe 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.doctorexits1when 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
.patchmakes 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--purgeis what removes them, alongside the config and the database. The report names every path it left behind. --yesmeans yes onuninstall, 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--yesas "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.uninstallexits1for 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.cfgsettings 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
.deb packaging, Windows MSI, arm64 cross build, and optional automated backup before upgrade.
Deliberately last of the build phases: v1 can register services directly (sc create / a written
systemd unit) and ship plain binaries. Nothing in Phases 1–4 should have to change to add these.
It now runs before the edge → main cutover rather than after it (org lead, 2026-08-05). 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 mean a first release that is
superseded by the next one, and operators who downloaded a bare binary being told to re-download a
package. Deferring the cutover costs nothing: nothing is published from edge, and the guide's
Appendix A is the supported path meanwhile.
The two entry criteria for the cutover are therefore this phase and the outstanding Windows SCM
verification (see the status header and Phase 2 as built).
The second is not busywork: running the systemd half for real found a bug that unit tests could not,
and sc create remains the only code in the crate that has never executed.
6. Token handoff (the end of a successful run)
Runic Gateway is installed.
One manual step remains — connect the website to this sidecar:
Base URL http://<this-host>:8080
WebSocket URL ws://<this-host>:8080/ws
Protocol version 3
Auth token 4f9c... (also in /etc/runicgateway/sidecar.toml)
Paste these into Admin → Shard on your Runic Gateway site:
https://<your-site>/admin/shard
The token is write-only once saved — the site will never show it back to you.
Every value in that block except the host and the site URL comes from one
uo-link-sidecar --print-config call (§2.4): web.auth_token, protocol, and web.bind +
web.ws_path for the two URLs. Only the host is substituted — web.bind is frequently
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.
The installer prompts for the site URL only to build that link; it never contacts the website. A future "installer registers itself with the website" flow (claim code + authenticated endpoint) is explicitly out of scope — it is real backend work in a security-sensitive area and can be added later without changing anything here.
The printed token is a secret in transit: --print-config output must go to the operator's
terminal and the config file, never into an installer log file or a support bundle.
7. Version tracking, the bundle, and release orchestration
Three components version independently, bound by a protocol contract:
- sidecar —
PROTOCOL_VERSIONinlink/sidecar/src/main.rs, exposed on/healthand asX-UOLink-Versionon every response; a mismatch is rejected409. - website — stores an expected protocol version in
uoLinkConfig(admin-managed). - plugin overlay — has no queryable version before ServUO boots. The overlay release
manifest.jsondeclares it, andinstall.jsonrecords what was deployed.
7.0 The overlay manifest
Shipped inside every runicgateway-overlay-<ver>.tar.gz, generated by that repo's release workflow:
{
"component": "servuo-plugins-overlay",
"version": "0.1.0",
"commit": "968b526…",
"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.
Three of these carry weight beyond documentation:
protocolis 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 this line the only thing §7.1's gate 1 has to compare the sidecar against. The duty is stated inoverlay.tomland in that repo's README: bump it in the same PR that changes the emitters, the waylinkbumpsPROTOCOL_VERSION.filesis what makesdoctorable to tell "the operator edited a deployed file" from "the overlay moved on" (§5, Phase 4). The installer copies these hashes intoinstall.jsonat deploy time; a later mismatch against both the manifest andinstall.jsonmeans upstream changed, a mismatch againstinstall.jsonalone means local edits.patch_tieris everything a.patchcannot say about itself, and is the reason the tier is data rather than code. Which patches form one all-or-nothing unit, which companion.csmay 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 isservuo-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.patchis described by exactly one feature, that every named patch and companion exists, and that each declaredtargetis 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).
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
against exactly one version (§2.2).
7.1 The bundle manifest
The bundle is the compat matrix. Rather than the installer hardcoding versions or blindly resolving "latest", CI publishes a small manifest naming an exact, checked combination:
{
"schema": 1,
"bundle": "2026.08.04",
"generated": "2026-08-04T16:07:13Z",
"protocol": 3,
"link": {
"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…" },
"windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" }
}
},
"overlay": {
"repo": "RunicGateway/servuo-plugins", "tag": "v0.1.1", "version": "0.1.1",
"commit": "3a52abb…", "protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"asset": { "name": "runicgateway-overlay-0.1.1.tar.gz", "url": "…", "sha256": "75dc6d6c…" }
}
}
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. 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
reproducible install. Because the bundle is data, a new link release regenerates ~30 lines of
JSON and leaves the installer binary untouched — operators do not re-download the installer to
pick up a sidecar patch, and the installer does not accumulate releases whose code is byte-identical.
Two gates run at compose time, both cheap and both worth it:
- The sidecar's
PROTOCOL_VERSIONmust equal the overlay manifest's declared protocol version. This is the check that catches anedge/mainprotocol mismatch before it reaches an operator. The two halves are read from different places because they are different: the overlay's frommanifest.jsoninside the tarball (the only statement of it that exists — §7.0), the sidecar's fromsidecar/src/main.rsat the release tag (see Phase 0 item 3 for why not from the binary). - Every referenced asset must exist and its SHA256 must match the publishing repo's
SHA256SUMS. The hash recorded in the bundle is the one CI computed from the asset it downloaded, after that check — and the installer verifies every download against it. These artifacts are deliberately unsigned (§3), so the checksum is the whole trust anchor; a hash copied from a file nobody verified would make the chain decorative.
Where bundles are published
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):
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.
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. 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
| Trigger | Why |
|---|---|
link publishes a release |
Its release job POSTs to the installer repo's workflow-dispatch endpoint as its final step |
servuo-plugins publishes a release |
Same. Phase 0 item 1 gave it the release workflow; the dispatch step was left as a marked TODO until there was something to dispatch, and landed with the bundle CI it calls (item 3) — a step that 404s on every release is worse than no step |
| Nightly cron on the installer repo | Recomputes from whatever the latest releases actually are, so a missed or failed dispatch self-heals instead of silently pinning operators to a stale sidecar |
repository_dispatch is deliberately avoided — support for it is uncertain on this Gitea version,
whereas dispatching an existing workflow_dispatch workflow via the API works today.
A failed dispatch is a warning, never a failed release. By the time that step runs the component
release is published and correct; failing the job would misreport it. This also keeps the dispatch
from becoming a new hard credential requirement — REGISTRY_TOKEN having write on the installer
repo is a nicety, and without it the nightly cron picks the release up anyway. A dropped dispatch
costs latency, not correctness, which is the whole reason the cron exists.
7.3 Stale-overlay handling: dispatch, don't wait
Each component self-releases on merge to its own main, using the same conventional-commit
engine. Note that "updated since the last release" must mean releasable commits — the engine sets
RELEASE=false when nothing but docs:/chore: has landed, so a docs typo correctly does not
cut an overlay release, and the bundle keeps using the existing one.
The compose job's copy of that rule additionally excludes merge commits, whose subject is
Merge pull request '<the real subject>'. Without that, every squash-free merge of a feat: branch
would be counted twice, and worse, a merge of a docs: branch whose title happens to quote a
fix: would be read as releasable — re-dispatching, every night, a release workflow that correctly
declines to run.
So by the time the installer's CI looks, the release normally already exists. If it finds
servuo-plugins main ahead of its latest release with releasable commits, it:
- fires that repo's release workflow via workflow-dispatch and does not wait for it,
- composes this bundle from the assets that exist right now,
- writes a loud warning into the job summary.
The new overlay release lands minutes later on its own and the nightly cron folds it into the next bundle. This gets the automation without the flaky part: dispatching another repo's workflow is fine — that workflow still runs its own gates — but polling it is not, because Gitea's dispatch endpoint returns no run handle, so the job would have to guess which run is its own and hold a runner idle meanwhile. The warning exists so a genuinely broken release workflow surfaces once rather than being silently retriggered every night forever.
7.4 Open risk
Settled as of the v3 cutover. Protocol work landed on edge branches and the edge → main
cutover has now merged, so main speaks protocol 3 consistently across the repos. The rule it
motivated stands regardless and is not a temporary measure: the installer hardcodes no protocol
version anywhere. It reads what the artifacts declare, and §7.1's gate 1 is what stops a
mismatched pair from being published as a bundle — which is the mechanism that will matter at the
next protocol bump, not just this one. See docs/link/v3.md.
8. Open questions
- 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.
- 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). sc create against the plain console
binary, as recommended: it works on a stock host, ships nothing extra, and needs no change to
link. A WinSW/NSSM shim would be a third binary to keep current, and a native --service mode
using the windows-service crate would put Windows service plumbing inside a component whose whole
job is being platform-agnostic. Restart semantics turned out to be adequate —
sc failure … actions= restart/5000 is the direct counterpart of systemd's Restart=on-failure /
RestartSec=5. What the recommendation did not anticipate is the service identity: plain
sc create runs as LocalSystem, so Phase 2 registers with obj= "NT SERVICE\RunicGatewayLink"
instead (see Phase 2 as built).
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
in principle — the installer hardcodes no protocol version, it reads what the artifacts declare —
but the specific edge/main disagreement that motivated it is gone.
9. Administrator experience
Before:
find plugins → copy files → edit ServUO → download bridge → start bridge
→ configure startup → find the token → troubleshoot paths
After:
download artifact → verify checksum → run installer → select ServUO directory
→ install components → paste 4 values into Admin → Shard → start ServUO normally