wtclaude e041089080
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
feat(bridge): protocol 4 — the permission mirror, and the first command that writes
R2, the game half. The website is the author of record for groups and grants;
this plugin makes the framework's own permission store match what it is sent.

**One verb, and the plugin does the diffing (D32).** `perm.sync` carries the
whole set the site authors for this server. The plugin compares it against the
live store and writes only what differs, then answers with a report: what it
applied, what was already right, the names it could not resolve, the memberships
waiting on a first connection, and every holder the site did not author.
`perm.catalogue` is the other half — a live read of what the loaded plugins have
registered, which is the option source the authoring form is built from.

**The first command whose work is unbounded, so it is drained rather than run.**
A community with two thousand linked players sends thousands of operations in one
frame, and applying them in the tick the frame arrives is a freeze an operator
will blame on the game. A sync compiles into a list of single-store operations
and drains two hundred at a time on a timer. That is §5's rule — nothing the far
side sends may cost the main thread an unbounded amount of work — applied to the
inbound half for the first time.

**The pre-check §12.2 rule 1 exists for.** `GrantUserPermission` returns void,
throws nothing and logs nothing for a name no plugin registered, so every grant
naming an unloaded plugin's permission would disappear without a trace. The plugin
checks `PermissionExists` first and reports the name as unresolved; it does NOT
register the name itself (D33), because that fabricates a permission the operator
never installed.

**`UserHasPermission` cannot answer the question the diff asks.** It is true for a
member of a group that carries the permission, so a direct grant would look
already applied the moment a group covered it — and a revoke already done.
`GetPermissionUsers` reads the user's own set, which is what a direct grant is.

Six permission hooks join the read path, all void, all in `ExpectedHooks`. They
are not the drift record — only the site's desired set can say whether a change is
foreign — they are a reason to reconcile sooner, and they are suppressed while
this plugin is writing, or every sync would announce itself as drift.

`rg.perms` reports the last report from the console, which is the fastest way to
tell "no plugin registered that name" from "the store has never seen that player".

Protocol bumped to 4 here and in overlay.toml, in the same change as the emitters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 18:27:08 -05:00
2026-09-15 19:53:13 -05:00
2026-09-15 19:53:13 -05:00
2026-09-15 19:53:13 -05:00
2026-09-15 19:53:13 -05:00

Rust-Plugins

The in-game half of the Runic Gateway bridge for Rust: one Oxide plugin that dials out to a rust-link sidecar and speaks newline-delimited JSON over it.

It is the mirror of 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:

{
  "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.

That is the developer's loop. An operator uses the installer, which syncs the released overlay tarball and installs the sidecar alongside it.

Prerequisites

The bridge itself needs nothing but Oxide. The features that follow it read four third-party plugins an operator installs from uMod — Clans, 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.

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.

Licence

GPL-3.0-or-later. See LICENSE.md.

Description
No description provided
Readme 7.5 MiB
v0.3.1 Latest
2026-10-30 04:31:54 -05:00
Languages
C# 97.7%
JavaScript 2.3%