Claude 13b6fc02a4 feat(asset-bridge): the shard's own files stop needing a shared filesystem (Phase 7)
The spawn atlas was the one place the platform's rule -- only the sidecar
bridges the shard -- was broken, and it was broken by the component that faces
the internet: SPAWN_ATLAS.md required the website to read the ServUO tree off a
bind mount or a shared volume. This serves those files over the loopback link
instead (docs/link/v8.md 10).

The measurement came first and changed the shape. 10 said the shard would serve
`tree/<label>` -> bytes; against a stock 57.4 tree it cannot. Spawns/trammel.xml
is 4.03 MB, the sidecar discards any inbound line over 1 MiB, and that file as
one base64 row is 5.4 MiB -- it would be dropped, time out, and be re-requested
forever with no error anywhere. Two files on a STOCK tree are in that state.

So a file crosses as 512 KiB chunks, each gzipped: tree/Spawns/trammel.xml/c0
and so on, which is 5's depth scheme doing the same job it does for
body/400/a0/f0 and needing no protocol change to do it. The chunk is the bound
and the compression is only the saving -- nothing guarantees an operator's files
compress, so the ceiling has to hold when they do not, and a 512 KiB chunk that
refuses to compress is still ~683 KiB of base64, inside the wire cap that
AssetBatchBytes' deliberate factor of two leaves room for.

It is a `tree` FAMILY on assets.fetch rather than 14's separate tree.* commands:
phase 5 had already learned that the command is the transport and the family is
a property of the key, and assets.manifest is generalised here the same way.
That reuses the single slot, the paging envelope, the key ceiling and the
mid-import guard -- and leaves `link` with nothing to do for the third phase
running.

But it gets its OWN consent, Bridge.TreeEnabled. AssetsEnabled is an operator
agreeing the website may read their EA-licensed UO client; this is the shard's
own configuration, which they wrote, and which the public bestiary is built
from. One switch could not express both, and the thing that would silently
disappear for an operator who declined the first is their spawn atlas. So the
consent check moved into the family lookup, and assets.sources answers whenever
either plane is on, reporting `families` filtered to what is actually enabled --
which is how a tree-only shard's website discovers there is anything to ask for.

Two defects found, and which harness found which is the part worth keeping:

  - An empty `catalog` is not an absent one. `expected != null` refused every
    fetch from a caller that sent "", with a sentence naming no catalog at all.
    Found by an offline probe that passed one by accident.
  - GZipStream writes NOTHING for zero bytes of input -- the header is emitted
    lazily, so a stream opened and closed without a write yields a zero-length
    buffer rather than the 20-byte empty member. Stock ServUO ships two empty
    decoration files, so this broke every import off an untouched tree. The
    offline probe reassembled all 141 files and reported success, because .NET's
    own decompressor reads an empty stream as empty data and the chunk's
    declared length (0) and hash (of nothing) both agreed. Only the live walk,
    through a reader on another runtime, disagreed.

Measured end to end against a live shard, the real sidecar and the website's own
reader: 141 files, 11,895,427 bytes, 158 chunks, 3 pages, 1.33 MB on the wire,
512 ms; every file byte-identical to disk; the atlas built over the bridge
identical to the one built off it. A drift check is the manifest alone -- 32 KB,
~70 ms, no file bytes.

The label set is this shard's, never the caller's: a fetch resolves against the
set the shard itself enumerated, and tree/../../Scripts/..., Config/Bridge.cfg
and Saves/Accounts/accounts.xml are all answered `absent` before a path is built
out of them.

Protocol stays 8 and EXTRACTOR_VERSION stays 3 -- this family derives nothing,
it forwards an operator's own file unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 02:00:22 -05:00

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 Developer tool — copies overlay/ from this working tree into a server root. -Verify diffs instead of writing. Operators use the installer; see Deploy.
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 — by the installer, in one run — but built independently:

  • This plugin is deployed as source: overlay/ is copied 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 publishes a source tarball, which is what the installer fetches and syncs; see Releases.
  • The sidecar is a standalone Rust binary, released from its own repo and installed from that release.

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

On a shard, use the Runic Gateway installer. One binary syncs this overlay from the release tarball below, offers the patch tier, installs the uo-link sidecar as a service, and prints the values your website needs — cross-platform, with a doctor afterwards to tell a copied file from a working bridge:

sudo ./runicgateway-installer-linux-x86_64 install

Guide: installer/INSTALL.md. To place the overlay yourself instead — a host that cannot run the binary, or you want to see every file land — Appendix A2 is the same copy done by hand, and stays supported.

deploy.ps1 — the developer path

deploy.ps1 deploys from a working tree, which is what you want while writing plugin code and is the one thing the installer cannot do (it deploys from a release):

.\deploy.ps1 -ServerPath <servuo> -Verify   # show what would change
.\deploy.ps1 -ServerPath <servuo>           # write

It is Windows-only and stays developer-facing; it never installs the sidecar, registers a service, or checks the protocol pairing. Nothing shipped to an operator depends on it.

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…",  }
}
  • protocol comes from overlay.toml and 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's PROTOCOL_VERSION before an operator installs the pair. When the protocol changes, bump it in the same PR that changes the emitters.
  • files carries 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 06 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 loads Scripts.dll from the base directory, and
  • TRACE;NEWTIMERS;ServUO went 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.

Description
No description provided
Readme 1.2 MiB
v1.3.0 Latest
2026-09-14 23:09:54 +00:00
Languages
C# 96.4%
PowerShell 3.6%