Files
Rust-Plugins/README.md
wtclaude 497b7c4f64
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m35s
feat(bridge): server id from the environment, and the first release workflow (phase 18)
Module-rust phase 18, step 3 of docs/modules/rust/PLAN.md §34.2.7.

The environment seam (D150): when the plugin writes its default config and
RUSTLINK_SERVER_ID holds a valid id (the site's ^[a-z0-9][a-z0-9-]{0,63}$),
the file's ServerId is that value. It is read nowhere else: after that write
the file is canonical and the site's lock applies. An invalid value is logged
and ignored. This is how the egg names a server; not a message change, so the
protocol stays at 12.

The release (D145, §34.2.1): release.yml reuses servuo-plugins' release engine
unchanged (tag-only, secrets preflighted, orphan tags recovered and swept, the
installer's bundle.yml dispatched at the end). The adapter re-runs
checkPlugin.js and packages runicgateway-rust-plugin-<version>.tar.gz with a
framework-neutral layout: RunicGateway.cs, its [Info] version stamped to the
release, and a manifest.json folding in overlay.toml.

overlay.toml gains min_carbon_version = "2.0.259", the build R19 was proven on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 22:54:38 -05:00

123 lines
5.5 KiB
Markdown

# Rust-Plugins
The **in-game half** of the Runic Gateway bridge for [Rust](https://rust.facepunch.com/): one Oxide
plugin that dials out to a [rust-link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link)
sidecar and speaks newline-delimited JSON over it.
It is the mirror of
[`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins), which
does the same job for Ultima Online — and it inherits that plugin's threading contract wholesale,
because the reason for it is the same on both games.
## The threading contract
Everything else in this repo depends on these three:
- **`Emit` is called from the main thread. It formats nothing, blocks on nothing, and touches no
socket.** It enqueues and returns. A slow, wedged, or absent sidecar cannot stall the game.
- **One link thread owns the socket.** It connects, drains the queue, and reconnects with backoff.
A single writer keeps event ordering intact.
- **A reader thread parses inbound lines and hands each to the main thread** via
`Interface.Oxide.NextTick`. The reader touches no Unity object, no `BasePlayer` and no `ConVar` —
every one of those is main-thread-only, and reading one from the reader is the kind of bug that
presents as a crash somewhere else entirely.
The outbound queue is **bounded, drop-oldest**. On overflow the oldest record goes and is counted,
because telemetry is worth less than the server's memory.
## Loopback is the trust boundary
There is no token on the game link. The plugin and the sidecar share a host, and the sidecar binds
`127.0.0.1` — that is the authentication. Pointing `Host` at anything routable puts an
unauthenticated command channel on the network.
## Installing it
```
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
```
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
```json
{
"Host": "127.0.0.1",
"Port": 7799,
"QueueCap": 5000,
"ServerId": "main"
}
```
`ServerId` is this server's stable identity across wipes and restarts, as the website knows it. It
is deliberately **not** derived from the hostname: an operator renames a server for a season, and
the site must not lose its history for it.
**When that first config is written**, and only then, a valid `RUSTLINK_SERVER_ID` in the server's
environment becomes its `ServerId` (an invalid one is logged and ignored). That is how the
Pterodactyl egg names a server, since an egg can set a variable but cannot reliably write a
framework's config directory. After that the file is canonical and the website locks the value, so
changing the variable later changes nothing.
On Carbon the same file goes to `carbon/plugins/` and its config to `carbon/configs/`.
That is the developer's loop. An operator uses the
[installer](https://gitea.whitlocktech.com/RunicGateway/installer) (`--game rust`) or the Pterodactyl
egg, both of which deploy a **released** plugin and install the sidecar beside it.
### Releases
Every merge to `main` that carries a `feat`, `fix` or `perf` commit cuts a release
(`.gitea/workflows/release.yml`): `runicgateway-rust-plugin-<version>.tar.gz` and `SHA256SUMS`. The
tarball holds one directory, `runicgateway-rust-plugin/`, with `RunicGateway.cs` (its `[Info]`
version stamped to the release) and a `manifest.json` folding in `overlay.toml`: the protocol, the
Oxide and Carbon floors, and `requires_plugins`. The layout names no framework; the installer and
the egg decide where the file goes. A release then asks the installer repo to recompose its Rust
bundle.
### Prerequisites
The bridge itself needs nothing but Oxide. The features that follow it read three third-party
plugins an operator installs from uMod — `Kits`, `PopupNotifications` and `ZoneManager`. They are
listed in `overlay.toml` so the installer's `doctor` can report a missing one by name rather than
leaving the site quietly short of a feature.
Clans on the website come from Rust's **own** clan system, not from the uMod `Clans` plugin, which
is optional. The two share no data: a server running the plugin has two unrelated clan systems, and
only the game's own clans become Teams on the site.
## Diagnosing it
```
rg.link
```
from the server console or over RCON. It reports the link's own counters:
```
protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
connects=1 writeErrors=0 bootId=boot-20260915T194502Z
```
This is the first thing to ask for when the website says a server is offline — it separates "the
plugin is not loaded", "the plugin cannot reach the sidecar" and "the website cannot reach the
sidecar", which look identical from the site.
**`bootId` identifies the server PROCESS, not the plugin load.** It is the process start time, so
`oxide.reload RunicGateway` does not change it. That matters more than it looks: the website watches
this value to tell a game restart (everything an event put in the world is gone) from a bridge
reconnect (nothing is lost), and a plugin reload is the second kind.
## The protocol is a contract
`ProtocolVersion` in the plugin and `protocol` in `overlay.toml` must agree with the sidecar's
`PROTOCOL_VERSION` and the module's own constant. The installer refuses to pair an overlay and a
sidecar that disagree, so a bump landing in one repo and not the others fails to compose rather than
half-deploying.
The canonical spec is
[`docs/rust-link/PROTOCOL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md).
## Licence
GPL-3.0-or-later. See [LICENSE.md](LICENSE.md).