The first release run tagged the repo and then failed, leaving v0.1.0 with no release behind it and no way to ever get one. REGISTRY_USER and REGISTRY_TOKEN are not configured on this repo, but the tag push SUCCEEDED anyway: actions/checkout leaves an `http.<host>.extraheader` credential in the local git config, so `git remote set-url` to a URL with empty credentials still authenticated through that leftover header. The release API call had no such fallback and returned 401 (visible in the run log as `REGISTRY_USER:` / `REGISTRY_TOKEN:` with empty values). So the run got exactly far enough to do the one thing that is hard to undo. Worse, that state was self-perpetuating. The plan step treated any existing tag as "nothing to release", so every subsequent push to main would see v0.1.0, set RELEASE=false, and stand down — the release would never appear, and no amount of re-running would fix it. Two fixes: A credential preflight, before anything is built or pushed, gated on the run intending to publish so a docs:/chore:-only merge still passes on a repo without secrets. It names the missing secrets and the scope they need, rather than failing at whichever step happens to use them first. Orphan-tag recovery. The plan step now asks the API whether a release exists for the tag: 200 means stand down, 404 means an earlier run died after tagging, so reuse the tag and publish the release it is missing. This deliberately overrides the RELEASE=false the bump logic just decided — with the tag already in place there are no releasable commits after it, which is precisely why the stuck state could not clear itself. Anything other than 200/404 (network failure, bad token) is refused rather than guessed, because assuming "no release" would republish over a good one. The tag step reuses an existing tag instead of failing on `git tag`, and a recovery run's changelog summarizes what the tag contains (previous-tag..this-tag) instead of the empty range after it. Once REGISTRY_USER / REGISTRY_TOKEN are set, the next push to main will finish the release that the first run started — v0.1.0, from the same commit it already points at. Verified against the live repo state: the plan step now reports release=true reuse_tag=true for the orphaned v0.1.0 and renders the correct changelog; a tag that does have a release (checked against link's v0.3.0) still stands down; a fresh repo still takes the seed path; and the preflight fails loudly on empty secrets and passes on populated ones. Co-Authored-By: Claude <noreply@anthropic.com>
Runic Gateway — ServUO Plugin
The C# ServUO side of the Runic Gateway bridge. The shard emits newline-delimited JSON over a loopback TCP socket to the Rust sidecar (RunicGateway/link), which owns the WebSocket the website consumes.
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
>>> THIS REPO <<< (RunicGateway/link)
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.
Documentation
All project documentation lives in the central RunicGateway/docs repo,
under link/ (design docs,
integration guide, protocol spec, research — with full history preserved).
Layout
| Path | What |
|---|---|
overlay/ |
Mirrors the ServUO server root. Everything here — and only this — copies over an install. |
patches/ |
Unified diffs against stock ServUO for files we must modify rather than add. |
tools/ |
Never deployed. Test scaffolding (C# probes + PowerShell stub sidecars) and anything else that must not reach a server. |
deploy.ps1 |
Copies overlay/ into a server root. -Verify diffs instead of writing. |
overlay.toml |
Release metadata: the wire-protocol version this overlay speaks, and its ServUO compatibility. Read by CI into the release manifest — see Releases. |
.gitea/workflows/release.yml |
Publishes runicgateway-overlay-<ver>.tar.gz on every merge to main. |
| INTEGRATION.md | Website integration guide — the WebSocket feed, REST endpoints, auth, event catalog, and examples. |
| PLAN.md | Implementation plan, measured performance budget, and the full data catalog. |
| RESEARCH.md | Original source-level research. Partly superseded — see the corrections table in PLAN.md §8. |
| SHARD_PREREQS.md | Repairs the target shard needed before any of this could load. |
Anything under overlay/ is authoritative. Do not edit files in the server tree directly — edit here and deploy.
Sidecar & deployment
The Rust sidecar is the other half of the bridge and lives in RunicGateway/link. The two are deployed together but built independently:
- This plugin is deployed as source —
deploy.ps1copiesoverlay/into the ServUO server root, and ServUO compiles it at boot (Scripts.csproj; see Phase 0). There is no CI build — it cannot be compiled standalone without the ServUO reference assemblies. CI does publish a source tarball for the installer to fetch; see Releases. - The sidecar is a standalone Rust binary, released from its own repo.
The only coupling is the loopback JSON protocol (the shard dials out to the sidecar on
127.0.0.1). Compatibility is a protocol concern, not a build-order one: keep the event/command
catalog in sync across the two repos (canonical spec: PLAN.md
§5/§7 and INTEGRATION.md).
A wedged or absent sidecar cannot stall the shard, so the plugin can be deployed before, after, or
without the sidecar running.
Deploy
.\deploy.ps1 -ServerPath <servuo> -Verify # show what would change
.\deploy.ps1 -ServerPath <servuo> # write
deploy.ps1 is the developer-facing tool and stays that way. Operators get the
Runic Gateway installer, which does the
same sync cross-platform from the release tarball below.
Releases
Every merge to main that carries a releasable conventional commit (feat:, fix:, perf:, or a
breaking change — a docs:/chore:-only merge deliberately cuts nothing) publishes a Gitea release:
runicgateway-overlay-<ver>.tar.gz
└── runicgateway-overlay/
├── manifest.json
├── overlay/ # exactly what deploy.ps1 would copy
└── patches/ # the opt-in stock-file diffs + their companion sources
SHA256SUMS
This is a source tarball, not a build — nothing here is compiled. It exists so the installer can deploy the plugin onto a shard host that has no git and no Gitea credentials.
manifest.json is what makes the tarball self-describing:
{
"component": "servuo-plugins-overlay",
"version": "0.1.0",
"commit": "968b526…",
"protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"files": { "overlay/Config/Bridge.cfg": "32718424…", … }
}
protocolcomes fromoverlay.tomland is the plugin half of the compatibility contract. The plugin announces no version on the wire and none is queryable before ServUO boots, so this declaration is the only way the installer can check it against the sidecar'sPROTOCOL_VERSIONbefore an operator installs the pair. When the protocol changes, bump it in the same PR that changes the emitters.filescarries a SHA256 per shipped file, so a deployment can later tell "an operator edited this" from "the overlay moved on".
The version is derived from git tags — there is no version to maintain by hand and no bump commit,
so this workflow never pushes to main.
The tarball is byte-reproducible for a given tree (tar --sort=name, pinned mtime and ownership), so
its checksum changes only when its contents do.
Status
| Phase | State |
|---|---|
0 — build fix (Scripts.csproj) |
done, verified end-to-end |
1 — transport (BridgeLink) |
done, acceptance in PLAN.md §11 |
2 — event streams (BridgeEvents) |
done, acceptance in PLAN.md §12 |
3 — sweeps (BridgeSweeps) |
done, acceptance in PLAN.md §13 |
4 — request/response (BridgeRequests) |
done, acceptance in PLAN.md §14 |
5 — [link account linking (BridgeAccountLink) |
done, acceptance in PLAN.md §15 |
6 — town-crier inbound (BridgeTownCrier) |
done, acceptance in PLAN.md §16 |
7 — PlayerVendorSale core event (patches/ + BridgeVendorSale) |
done, acceptance in PLAN.md §17 |
Every phase on the ServUO side is complete. Phases 0–6 are drop-in (overlay/); Phase 7 is the one
core change, shipped as patches/.
Cheat-detection signals are not a separate phase — they are folded into the streams above:
cheat.fastwalk, audit.set, audit.command, and vendor.sale (buyer + owner for laundering detection).
Phase 0 — what it fixes
ScriptCompiler.Compile() runs dotnet build Scripts/Scripts.csproj -c Release, prints the output, and never checks the exit code, then Assembly.LoadFrom("Scripts.dll") and returns true. Because that build passed no Platform, MSBuild defaulted to AnyCPU, and Scripts.csproj gated both OutputPath and DefineConstants on Configuration|Platform == Release|x64. So:
- the DLL landed in
Scripts/bin/Release/while the core loadsScripts.dllfrom the base directory, and TRACE;NEWTIMERS;ServUOwent undefined, so XmlSpawner compiled its non-ServUO branches.
Runtime script compilation therefore had no effect, silently. overlay/Scripts/Scripts.csproj conditions both property groups on Configuration alone.
Server.csproj is deliberately left alone: nothing under Server/ uses those symbols, and giving it OutputPath=..\ would make the boot-time build try to overwrite the running ServUO.exe.
The plugin (Phase 1)
overlay/Scripts/Custom/Bridge/:
| File | Responsibility |
|---|---|
BridgeConfig.cs |
Reads Config/Bridge.cfg in Configure(), before World.Load. |
BridgeJson.cs |
Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via JavaScriptSerializer. |
BridgeLink.cs |
The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. |
BridgeBoot.cs |
Lifecycle, inbound dispatch, [bridge status|reload|ping]. |
BridgeEvents.cs |
EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. |
BridgeSweeps.cs |
Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. |
BridgeProfile.cs |
Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. |
BridgeRequests.cs |
Inbound request handlers (Phase 4): char.request, account.roster, vendor.snapshot, with bridge.error replies. |
BridgeAccountLink.cs |
[link account linking (Phase 5): one-time code, link.confirm, WebsiteUserId account tag. |
BridgeTownCrier.cs |
Town-crier news (Phase 6): inbound towncrier.add / remove into the global crier list, with abuse caps. |
Emit() is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. A wedged or absent sidecar cannot stall the shard, and that is the property everything else depends on.
Testing
tools/stub_sidecar.ps1 is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch server.hello arrive. It survives a just-killed instance (SO_REUSEADDR) and won't die on a transient error.
.\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log
tools/stub_sidecar_request.ps1 additionally sends inbound requests (char.request, account.roster, vendor.snapshot, plus an error case) right after the shard connects, and logs the replies — the harness used to validate Phase 4.
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar (RunicGateway/link) replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
tools/scaffolding/ holds the world seeder and the performance probe. Neither is deployed — deploy.ps1 only copies overlay/. They produced the budget in PLAN.md §1. See tools/scaffolding/README.md.
License
Runic Gateway is free software, licensed under the GNU General Public License v3.0 or later — see LICENSE.md.
Copyright (C) 2026 Runic Gateway
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version. It is distributed WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
Contributions are welcome — please read CONTRIBUTING.md (note the AI-usage disclosure requirement) and our Code of Conduct. Report vulnerabilities privately per SECURITY.md.