Section 14 records R21's first rig, made with the application key and driven
with the client key. Both work; neither can do the other's job, and there is
no egg-write endpoint at all, so a published egg is a JSON file a human
imports.
The tier-2 loop was run rather than described: phase 1's real RunicGateway.cs
pushed from the working tree, oxide.reload through the client API, the edit
observed in Oxide's own log, source restored. About ten seconds end to end.
Three things worked that were not certain to - files/write creates missing
parents, a plugin placed before Oxide exists survives Oxide arriving, and the
plugin compiles and loads on Linux, which no previous phase had established.
Two findings that change decisions. The image installs the framework on EVERY
boot, Carbon from the rolling production_build tag and Oxide from
releases/latest, so a restart is a framework upgrade and neither is pinnable
through the egg. And R20's "wrapper launches the sidecar then RustDedicated"
does not survive Carbon: the entrypoint prepends LD_PRELOAD to the whole
startup string, so the preload would land on the sidecar and the server would
start cleanly, report nothing, and be silently unmodded.
R22 (org lead): the sidecar's configuration moves into the egg's variables.
Nearly free - rust-link already reads all five keys from the environment with
env-over-file-over-defaults precedence - but the game bind must not be
operator-editable, the web bind must derive from an allocation, and the db
path must not be able to agree with REMOVE_FILES.
Also corrects an earlier claim in this branch. The client key listing zero
servers and includes returning empty were both CORRECT; the servers were
being deleted between reads. A differential diagnosis across two API calls
assumes the state did not move between them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
The token file now carries both keys. The client key authenticates as the
admin owner and then lists zero servers, on every listing type and for both
the short identifier and the full UUID, so tier 2 of R21's deployment loop is
not usable yet. Reinstall and SFTP are unaffected.
Records the diagnosis rather than only the symptom, including one trap worth
not repeating: includes are independently broken on this panel, so
servers?include=user returns an empty list where the same route without the
include returns six. Nothing built on an include is evidence about ownership.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Three new decisions of record, and a new reference for the second modding
framework.
R19 - the bridge plugin is framework-agnostic from now, not ported later.
Carbon is not a fork of Oxide but a separate loader shipping an Oxide
compatibility layer, so one .cs in the Oxide.Plugins namespace serves both,
with #if CARBON only where the APIs genuinely differ. Three existing
decisions take an amendment and none is reversed: R18's config walk roots at
Interface.Oxide.ConfigDirectory rather than a literal oxide/config (Carbon
uses carbon/configs AND lets an operator relocate every directory from the
command line); R2's permission store is Protobuf or SQLite on Carbon, which
permanently closes the file-reading shortcut it never planned to take, while
the PermissionExists pre-check survives because Carbon's bool return is the
one thing we cannot read portably; R4's doctor asks which framework rather
than whether Oxide, and gets a weaker "current enough" claim because Carbon
ships rolling release tags.
R20 - a Pterodactyl egg is a third supported deployment path beside the
installer and the hand install, derived from the community "Rust Autowipe"
egg, which already carries a FRAMEWORK variable offering vanilla/carbon/oxide.
The sidecar runs inside the game's container, which is what lets D2 stand
unchanged: a container's 127.0.0.1 is genuinely private, so the game link
stays loopback and stays unauthenticated. Lands in phase 18 beside the
installer.
R21 - both rigs move to the Pterodactyl panel, because Oxide and Carbon
cannot coexist in one install and so a single server cannot prove R19. Also
retires the wipe-day maintenance that dominated section 4, and makes the rig
Linux where every prior finding came from Windows and Mono.
New: modules/rust/CARBON.md, the difference list - file layout, the
permission store, the c. commands, 30 Carbon-only hooks, and 13 uMod names
Carbon's catalogue omits (at least two of which look like renames). Sourced
from Carbon's own published metadata and source at main, and labelled
throughout as not yet proven on a live Carbon server.
One outstanding request, recorded in section 3: the panel token on disk is an
application key and Pterodactyl puts files, power and console on the client
API, so iteration needs a ptlc_ key only the account holder can mint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 20:59:23 -05:00
3 changed files with 811 additions and 58 deletions
Facepunch's vanilla one and patches in memory through Harmony. One install runs one framework, so
**one rig cannot prove both** — which is why [`PLAN.md`](PLAN.md) R21 moves the rigs to
Pterodactyl and runs two.
- **Carbon migrates an Oxide install on first boot** — it copies config, data, lang, user and group
files across and relocates `Oxide.Ext.*.dll` out of `RustDedicated_Data/Managed`. Useful for an
operator; a hazard for a test rig, because a Carbon rig built by converting an Oxide one starts
with the Oxide one's state and proves less than a clean install.
- **Carbon self-updates and its releases are rolling tags**, not versioned ones:
`production_build` (v2.0.259 at the time of writing, 2026-09-06), plus `edge_build`,
`experimental_build` and per-branch Rust builds. Oxide publishes an incrementing build number.
**So "which Carbon is this" is not answerable the way "which Oxide is this" is**, and R4's
`doctor` prerequisite check has to accept that — it can establish *that* Carbon is installed and
report the build it reports, but "current enough" is a weaker claim on Carbon than on Oxide.
- **Carbon patches hooks only when a plugin subscribes**, so an unsubscribed hook costs nothing.
That rewards the selective subscription R17 already requires for ZoneManager's chatty zone
transitions, on Carbon more than on Oxide.
---
## 9. What this costs us, in one table
| Decision | Change |
|---|---|
| **R2** permissions | None to the design. The store is API-only on Carbon *by construction* rather than by choice, and `PermissionExists` stays the check because the useful return value is Carbon-only |
| **R4** installer | `doctor` detects *which* framework, not *whether Oxide*; the payload drops into `PluginDirectory`; "current enough" is weaker on Carbon (rolling tags) |
| **R18** config editor | Roots come from `Interface.Oxide.ConfigDirectory` / `DataDirectory`, never literals; the reload is resolved through the framework, not by composing `oxide.reload` |
| **R6/R17** base mods | Unchanged — Kits, Clans, PopupNotifications and ZoneManager are Oxide plugins and Oxide plugins run on Carbon |
| **§9** event leases | Keys come from the game's convars; Carbon's own convars are out of the catalogue unless advertised conditionally |
| Everything else | Unchanged |
The honest summary: **Carbon costs three amendments and one extra rig, not a second codebase.**
**Status:**approved in outline 2026-09-15, not started. **Eighteen decisions of record, no open
questions.** Audited against the whole contract, not just the game-facing chapters (§7); the event and
engagement catalogues are §9 and §10; §11 is a second pass over `MODULE_API.md` itself.
**Status:**phases 0 and 1 done, 2026-09-15. **Twenty-two decisions of record, no open questions.** Audited against the whole contract, not just the game-facing chapters
(§7); the event and engagement catalogues are §9 and §10; §11 is a second pass over `MODULE_API.md`
itself. **R19–R22 (2026-09-15) added a second modding framework, a Pterodactyl egg, moved the rigs off
the workstation, and put the sidecar's configuration in the egg** — see [`CARBON.md`](CARBON.md) for
the framework reference and §14 for the rig as built.
The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it.
This is the document that builds it. Where the two disagree, this one is later and wins — but the dry
@@ -675,9 +677,216 @@ open to everybody, so granting a permission for it rewards nobody with anything.
kit dropdown must surface which kits are permission-gated and refuse — or at minimum warn loudly —
on one that is not. That is a real refusal with a real reason, and exactly what R3's envelope is for.
### R19 — the plugin is framework-agnostic: Oxide **and** Carbon, from now rather than later
**Decided 2026-09-15 (org lead).** Modded Rust runs on two frameworks, not one, and `module-rust`
supports both from the phase it first reads anything — not as a port after phase 18. The bridge
plugin stays **one `.cs` file in the `Oxide.Plugins` namespace deriving from `RustPlugin`**, which is
also Carbon's own documented first example, with `#if CARBON` used only where the APIs genuinely
differ.
**This is affordable because the divergence is concentrated, not spread.** Carbon is not a fork of
Oxide; it is a separate loader shipping an Oxide compatibility layer, and at the level a plugin sees
the two are the same API. [`CARBON.md`](CARBON.md) is the reference — where it came from, what was
read, and the honest note that **none of it has yet run on a live Carbon server.**
Three existing decisions take an amendment, and no decision is reversed:
- **R18 — paths come from the framework, never from a literal.** Carbon's config directory is
`carbon/configs` (plural) and its data directory `carbon/data`, *and every one of Carbon's
directories is relocatable from the command line* (`-carbon.configdir`, `-carbon.datadir`,
`-carbon.rootdir`, and nine more). So the recursive walk is rooted at
`Interface.Oxide.ConfigDirectory` and the directory it refuses to walk is
`Interface.Oxide.DataDirectory`. Carbon reimplements both accessors; a hardcoded `oxide/config/`
is wrong on Carbon *and* on an Oxide server whose operator moved things. The reasoning behind R18
is untouched — only how the two roots are obtained.
- **R2 — the store was never readable and now it is unreadable by construction.** Oxide persists
permissions as JSON (`oxide/data/oxide.users.data`); Carbon persists them as **Protobuf or
SQLite**, switchable at run time. R2 always planned to read the API, so nothing changes — but the
file-reading shortcut is now permanently closed, which is worth saying once. §12.2's
`PermissionExists` pre-check also survives intact: Carbon's `GrantUserPermission` returns `bool`
where Oxide's returns `void`, so **the framework that would have told us whether the write landed
is the one we cannot portably listen to.**
- **R4 — `doctor` asks *which* framework, not *whether Oxide*.** The payload drops into
`PluginDirectory` either way. One thing gets weaker: **Carbon's releases are rolling tags**
(`production_build`, `edge_build`), not an incrementing build number, so "current enough" is a
claim `doctor` can make about Oxide and can only approximate about Carbon.
**What this decision explicitly refuses.** Carbon publishes 30 hooks Oxide does not, including an
`OnCarbon*` family mirroring its admin module's every moderation action — a tempting staff-audit
feed, and precisely the thing that would quietly make Carbon required. **No Carbon-only hook and no
Carbon-only convar enters a catalogue** unless it has an Oxide answer first, or is advertised
conditionally on the connected server's framework as a deliberate decision. Likewise Carbon's native
`Carbon.Plugins` / `CarbonPlugin` shape is not used: it is the single choice that would make the
source Carbon-only.
**The one thing to hold loosely.** Thirteen hook names our uMod mirror carries are absent from
Carbon's published catalogue ([`CARBON.md`](CARBON.md) §6). At least two look like renames rather
than holes, and none is in a phase today. The protection is the one §6 already requires for a
different reason — *the plugin logs which of its expected hooks have fired at least once* — which
answers this on either framework without trusting either catalogue.
### R20 — a Pterodactyl egg is a Rust-Link deliverable, with the sidecar inside the game container
**Decided 2026-09-15 (org lead).** Most Rust servers are rented, and most rented Rust servers run on
a Pterodactyl panel. So alongside the installer (R4) and the hand install, **a published Pterodactyl
egg is the third supported way the shard side reaches an operator** — and it has to work *in the same
manner* as the other two, not as a degraded variant.
The egg is derived from the community **"Rust Autowipe"** egg, taken as the known-good base, and it
keeps everything that egg already gets right: the steamcmd install script, the wipe-day
`REGEN_SERVER` / `REMOVE_FILES` mechanism, the Rust+ `APP_PORT`, and — the reason it is the right
base — a **`FRAMEWORK` variable already offering `vanilla | carbon | oxide`**. The operator picks the
framework at deploy time, which is R19's justification restated as a deployment fact: we do not get
to choose.
**The sidecar runs inside the game's own container, and that is the load-bearing part.** A
Pterodactyl server gets its own network namespace, so `127.0.0.1` inside it is genuinely private —
which means **D2 survives untouched**: the game link stays loopback and stays unauthenticated,
because loopback *is* the authentication. The startup command becomes a small wrapper that launches
`rust-link-sidecar` and then `RustDedicated`.
The alternative — a second Pterodactyl server running the sidecar — was rejected for exactly that
reason. Two containers have no shared loopback, so it would force a token and a routable bind onto
the game link. That is the case argued at D2 and overruled; it is not reopened here.
Four things the egg must get right, each of which is a way to get it wrong:
- **A second allocation for `[web].bind`.** The sidecar's HTTP/WS side is the half the website
reaches, so it binds to the container's assigned address on an allocation the panel hands out —
not to loopback. The token is what guards it, exactly as on a hand install.
- **The sidecar's database must never appear in `REMOVE_FILES`.** That variable is the wipe
mechanism, and R12 keeps **all-time rollups across wipes**. A sidecar store swept on wipe day is
the one failure that looks like success: the server comes back, the site repopulates, and every
player's history is silently gone.
- **Stop means stop the game.** The egg's stop command is `quit`, addressed to RustDedicated. The
wrapper has to let the sidecar go down with it rather than outliving it or holding the container
open.
- **The plugin and the sidecar come from a release, never from a copy.** The install script fetches
the pinned pair the same way the installer resolves a bundle — which makes the egg the third
consumer of the protocol-pairing check, not an exception to it.
**It lands in phase 18, beside the installer**, because phase 18 is already "how the shard side
reaches an operator", and one story told twice is how two stories drift apart.
### R21 — both Rust rigs move to Pterodactyl, because one install cannot prove two frameworks
**Decided 2026-09-15 (org lead).** Oxide and Carbon **cannot coexist in one install** — Oxide ships
a patched `Assembly-CSharp.dll` and Carbon requires Facepunch's vanilla one. So R19 cannot be proven
on `D:\rust`, or on any single server, at all.
Both rigs move to the existing Pterodactyl panel at **192.168.0.12** (node `Main`): one server with
`FRAMEWORK=oxide`, one with `FRAMEWORK=carbon`, on the same egg. They are started and stopped as
needed rather than both left running.
This replaces `D:\rust` as the rig of record, and it buys more than parity:
- **The egg gets exercised by every phase**, not only by phase 18. R20's deliverable stops being a
thing written once at the end against a panel nobody has used.
- **It ends the wipe-day maintenance that dominated §4.** `start.bat`'s steamcmd argument ordering,
re-extracting Oxide after every `app_update`, checking `Assembly-CSharp.dll`'s byte size to tell a
half-done Oxide install from a working one — all of that becomes the panel's job, through
reinstall.
- **It is a Linux rig.** Every previous finding came from Windows and Mono; phase 1 spent real time
on a Mono-specific NUL-padded `SocketException.Message`. Production Rust servers are Linux, so the
rig moving there makes findings more representative, and makes any remaining Windows-only
behaviour something we notice rather than depend on.
**A Carbon rig must be a clean install, not a converted one.** Carbon migrates an Oxide install on
first boot — it copies config, data, lang and permission files across. A Carbon rig made by
converting the Oxide rig would start out holding the Oxide rig's state, and would prove less than a
fresh one.
**The access, and the two kinds of key it takes.**`RunicGateway/pterodactyl_claude_api_token` holds
both, one per line: an **application** key (`ptla_…`), which creates and configures servers,
allocations and users and reads eggs but **cannot touch files, power or console**; and a **client**
key (`ptlc_…`), which is where Pterodactyl puts exactly those. An application key is rejected
outright by `/api/client/**` and cannot be widened — they are two credentials, not two scopes of
one. So the deployment loop is three tiers, matched to what each is for:
| What | How | Why that one |
|---|---|---|
| **A release artefact** — the pinned plugin + sidecar pair | The egg's own install script, re-run by a panel **reinstall** | It is the path we ship. Exercising it on the rig is acceptance testing for free |
| **Working-tree iteration** — an uncommitted `.cs` under test | A **client** API key (`ptlc_…`): `files/write`, then `command` to reload | The Pterodactyl analogue of `servuo-plugins/deploy.ps1`, and it carries the same caveat: **if something only works when the push script copies it, it does not ship** |
| **Bulk or binary** — sidecar builds, world files | SFTP on the node, port 2022 | Where the client API's per-file write is the wrong shape |
**Both keys exist and both were exercised on 2026-09-15** — the token file holds them as
`application:` and `user:` lines, and §14 records the rig they built together. The push script itself
lives in **`Rust-Plugins`**, mirroring where `deploy.ps1` lives for ServUO.
### R22 — the sidecar is configured from the egg's variables, not from a file the operator edits
**Decided 2026-09-15 (org lead).** What normally lives in `sidecar.toml` moves into the Rust egg's
variables, so an operator on Pterodactyl configures the sidecar in the panel alongside the game's own
settings rather than opening a file manager to edit TOML. One configuration surface, in the place
they are already looking.
**This is nearly free, because the sidecar already does it.**`rust-link`'s `config.rs` documents its
precedence as *environment overrides file overrides defaults* and already reads all five keys from
the environment: `RUSTLINK_GAME_BIND`, `RUSTLINK_SERVER_ID`, `RUSTLINK_WEB_BIND`,
`RUSTLINK_WEB_TOKEN`, `RUSTLINK_DB_PATH` (plus `RUSTLINK_CONFIG` for the file's own path).
Pterodactyl exposes every egg variable to the container as an environment variable, so the mapping is
one-to-one and **no second configuration mechanism is introduced** — the file stays canonical, the
environment overrides it, the egg sets the environment, and the installer (R4) keeps writing the file
exactly as it does now.
Which gives the two halves of the shard side two different config surfaces, deliberately:
| | Configured from | Mechanism |
|---|---|---|
| The **plugin** | the website, Admin → the R18 config editor | D3: it reads `oxide/config/RunicGateway.json`, so it is inside R18 for free |
| The **sidecar** | the panel, as egg variables | R22: `RUSTLINK_*` in the container environment |
That split is right rather than merely convenient. The plugin is configured by the thing it talks to;
the sidecar is configured by the thing that starts it, and on a panel the operator has no shell.
**Three things the variable set has to get right**, each of which is a way to hand somebody a footgun:
- **`RUSTLINK_GAME_BIND` is not operator-editable.** D2 makes loopback the authentication on the game
link; a panel field that accepts `0.0.0.0:7799` is a web form that puts an unauthenticated command
channel on the network. It is set by the egg and marked neither viewable nor editable — the same
posture R18 takes toward the plugin's own `Host`/`Port`, for the same reason.
- **`RUSTLINK_WEB_BIND` is derived from an allocation, not typed.** It has to match the port the panel
actually handed out, exactly as the egg already derives `QUERY_PORT` and `RCON_PORT`. A free-text
bind is a bind that silently does not match the allocation, and the failure is the website never
connecting with nothing in any log to say why.
- **`RUSTLINK_DB_PATH` must point somewhere `REMOVE_FILES` never sweeps.** Already named in R20 and
restated here because this is the decision that makes the path an operator-visible field: the wipe
list and the database path become two settings on the same screen, and they must not be able to
agree.
**The token is the one place the ergonomics are not automatic.** Today the sidecar generates a token
when it finds none and persists it to its config file, which is what makes it secure out of the box;
`--print-config` is how an operator reads it back. A panel variable cannot be filled in by the
program that generates it, so the choices are: ship an empty default and let the sidecar generate and
persist as it does now, with the operator reading it out of the panel's file manager once; or make
the operator paste one in. The existing precedence already supports both — a set variable wins, an
empty one falls through to generation — so this is a default to choose when the egg is built, not a
mechanism to design. **Whichever is chosen, note that a Pterodactyl variable is visible to anyone
with panel access to that server and appears in the container environment**, which is a different
exposure from a `0600` file and should be stated in the operator guide rather than discovered.
**Lands in phase 18 with the rest of R20's egg.**
## 3. Open questions
**None.**Both questions this section carried were closed on 2026-09-15.
**None.**Every question this section carried was closed on 2026-09-15, and so was the one open
*request*: the token file now holds both keys, and **both were exercised end to end on 2026-09-15**
(§14).
One correction belongs here rather than being quietly dropped, because the shape of the mistake is
the reusable part. This section briefly recorded that the client key "authenticates and then lists
zero servers", and built a diagnosis on top of it — including a claim that *includes are broken on
this panel*, because `/api/application/servers?include=user` returned an empty list where the same
route without the include had returned six.
**Both claims were wrong, and they were wrong the same way.** The servers were being deleted while
the probing was happening, so two reads minutes apart were reads of two different worlds. The empty
client list was correct. The empty include was correct. Nothing was broken.
The lesson is not "check twice"; it is that **a differential diagnosis across two API calls silently
assumes the state did not move between them**, and on a live panel somebody else is also holding the
controls. Once a server existed, every one of those calls answered correctly on the first try.
*Clans in the base set while the Team provider reads first-party* was confirmed as the intended
reading: complementary, not in conflict — the plugin is installed for alliances and clan chat, the
@@ -689,48 +898,62 @@ online. The persisted pending-grant queue that question was weighing is not need
## 4. The test rig
`D:\rust` on the org lead's workstation. **Brought current in phase 0 (see §12):** build
**25230300**, Oxide **2.0.7716**, a fresh procedural world (seed 1234, size 4000) generated for this
wipe, and all four base plugins loaded. Its Oxide permission store holds a `default` and an `admin`
group with one admin user, so R2's mechanism was exercised on day one and works.
**Two servers on the Pterodactyl panel at `192.168.0.12`, one per framework (R21).** They replace
`D:\rust`, which was the rig for phases 0 and 1 and whose findings are still recorded in §12 and §13.
A wipe keeps `server/server1/cfg/`. That directory holds `users.cfg`, and `users.cfg` holds the
`ownerid` line — delete the whole identity directory and you silently remove the operator's own
→ Error! App '258550' state is 0x486 after update job.
```
**What the panel changes about how work reaches a rig.** The token at
`RunicGateway/pterodactyl_claude_api_token` is an *application* key: it manages servers, allocations
and users, and it **cannot write a file, press a button or run a console command** — Pterodactyl puts
those on the client API. R21's table has the three tiers; the short version is *release artefacts
arrive by reinstall, iteration needs a client key, bulk goes over SFTP on port 2022.*
So the flag was discarded, the update ran against steamcmd's own directory, and the job errored out
every single time. It updated **no** directory, ever — which is the actual reason `D:\rust` fell a
wipe behind, and why `C:\rustserver` sits at the *same* stale buildid rather than a newer one.
Now reads `+force_install_dir d:\rust\ +login anonymous +app_update 258550 +quit`; the pristine
original is kept at `start.bat.bak` and the path-only fix at `start.bat.broken-order-20260915`.
With the ordering right, the run is a delta and takes minutes.
**Two facts about this panel that are easy to trip over:**
- **Re-extract Oxide after every `app_update`.** Updating the server and re-installing Oxide together
is the standard operator routine, not a discovery — Oxide ships a *patched* `Assembly-CSharp.dll`
and a Steam update restores Facepunch's. Recorded here only for the mechanical detail: the update
does **not** remove `Oxide.Core.dll` and friends, so a half-done install still *looks* Oxided while
loading no plugins and raising no hook. Check the size rather than the directory — on build
25230300 vanilla is 9,758,544 bytes and Oxide 2.0.7716's is 9,953,280.
- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere.** Oxide ships a patched
`Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,953,280, so copying it
over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it.
-`/api/client/**` returns **403 `AccessDeniedHttpException`** for the application key — a clear
error, but only if you are expecting it. It is not a permissions grant that can be widened.
- The application API has **no egg-write endpoint at all** (`/api/application/eggs` is a 404; eggs
are read through `/api/application/nests/{nest}/eggs`). Importing a new egg version is an admin-UI
or `php artisan` operation, so the egg's release artefact is a JSON file a human imports — which is
also exactly how an operator will consume it.
**Rust force-wipes on the first Thursday of the month and Oxide is rebuilt to match**, so "is the
rig current" is a recurring question, not a one-time setup step. Every phase that touches the plugin
re-checks it.
### What moving off the workstation retires
Everything below was true of `D:\rust` and is kept only because it explains findings in §12. **None
of it is maintenance any more** — the panel's reinstall does the same work correctly.
- **`start.bat` never updated anything.** steamcmd requires `+force_install_dir`**before**`+login`
and the script had it after, so the flag was discarded, the update ran against steamcmd's own
directory, and the job errored every single time (`Error! App '258550' state is 0x486`). That — not
the path, which was the first and wrong diagnosis — is why the rig fell a wipe behind.
- **Re-extract Oxide after every `app_update`.** Oxide ships a *patched*`Assembly-CSharp.dll` and a
Steam update restores Facepunch's, but the update does **not** remove `Oxide.Core.dll` and friends
— so a half-done install still *looks* Oxided while loading no plugins and raising no hook. The
tell was file size: on build 25230300, vanilla 9,758,544 bytes against Oxide 2.0.7716's 9,953,280.
- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere** — its
`Assembly-CSharp.dll` is 6,842,880 bytes, a hard downgrade over a live install.
- **A wipe keeps `server/server1/cfg/`**, which holds `users.cfg` and therefore the `ownerid` line.
Delete the whole identity directory and you silently remove the operator's own ownership along with
the map. This one still applies — it is the game's shape, not the host's, and it is why the egg's
`REMOVE_FILES` list is worth reading carefully rather than trusting.
**Rust force-wipes on the first Thursday of the month, and both frameworks rebuild to match.** "Is
the rig current" stays a recurring question rather than a setup step; what changed is that the answer
is now a reinstall rather than a sequence of manual steps that can half-succeed.
## 5. The phases
@@ -744,6 +967,12 @@ are events, whose catalogue is **§9**. 14 is the map. 18 is how any of it reach
not us. The Android legs (5, 8, 11, 15) each trail the
website surface they consume by one phase, per R10.
**R19 and R21 do not add a phase — they change what "done" means for several.** Both rigs exist from
phase 3 onward, so from phase 3 a criterion is met when it is met **on both frameworks**, and a
finding that holds on only one is a finding either way. Phase 2's release artefacts and phase 18's
egg are the two places the second framework is visible in the deliverable rather than only in the
proving.
Each phase ends with its findings written down, as every workstream here does.
| # | Phase | Repos | Done when |
@@ -751,12 +980,12 @@ Each phase ends with its findings written down, as every workstream here does.
| 0 | **The rig.** ✅ **Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end and both zone transitions observed live with a player connected. **Both criteria met** | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.** ✅ **Done 2026-09-15 — as built and findings in §13.** Plugin, sidecar and module all exist and all three were exercised against the live rig; three org-lead decisions (§13.0), five defects only a running server found (§13.3), and a correction to §11.3 (§13.2). **Both criteria met** | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
| 2 | **Packaging and release.**`release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
| 3 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history |
| 3 | **The read path, on both frameworks.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
| 4 | **The first pages.** Server list as the landing page, `/rust/servers/:id` beneath it, killfeed, leaderboard; nav rows; the UI kit (`PublicLayout``shell`, `PageHeader` props); `capabilities`; the `site.footer.status` slot (R13) | Module-Rust | The site renders the last thing each server said while every server is off |
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe |
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `oxide/config/` (never `oxide/data/`), generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. The `PermissionExists` pre-check stays the mechanism on **both** frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot`× 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
@@ -767,8 +996,8 @@ Each phase ends with its findings written down, as every workstream here does.
| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates |
| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel |
| 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included |
| 18 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied |
| 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change); and the Integration-kit question R2 raised | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way |
| 18 | **The installer** (R4)**and the Pterodactyl egg** (R20) — the two halves of "how the shard side reaches an operator", built together so one story is not told twice. `--game servuo\|rust`, the bundle payload as a variant, a**framework** prerequisite check in `doctor` (which one, not whether Oxide — R19), the protocol pairing refusal carried over; the egg derived from "Rust Autowipe" with the sidecar inside the game container, a second allocation for `[web].bind`, **the sidecar configured from egg variables** (R22), the sidecar store held out of `REMOVE_FILES`, and its install script fetching the same pinned pair the installer resolves | installer + Rust-Link + docs | An operator sets a Rust server up with the released binary and nothing hand-copied; **and** a second operator imports the egg, deploys, and reaches the same place — on either framework |
| 19 | **Docs, kit feedback, cutover.**`docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change, and Pterodactyl is a hosting claim the site can now make); the Integration-kit question R2 raised; and whether the kit owes a reader anything about **supporting two mod frameworks at once** (R19) — a shape it has no chapter for either | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way |
### Why the lease comes before the reward action
@@ -825,15 +1054,31 @@ than being quietly lost.
never called, silently, with no warning at load — the single most common way a Rust plugin does
nothing. The plugin must log which of its expected hooks have fired at least once, so a hook
Facepunch renamed on a wipe is visible rather than mysterious. See [`README.md`](README.md) §2.
**R19 gives that mechanism a second job:** it is also the only trustworthy answer to "does this
hook exist on Carbon", since two published catalogues disagreeing is evidence about the catalogues
and not about the frameworks.
- **The cheapest way to make Carbon required is to do it by accident.** Carbon's extra 30 hooks, its
23 extra convars and its `bool`-returning permission API are each individually useful, individually
small, and collectively a framework lock-in nobody decided on. R19's refusal is written down
because it will be re-argued, once per convenience.
- **Two rigs is twice the state that can be quietly wrong.** A finding proven on the Oxide rig and
assumed on the Carbon one is exactly the failure this project keeps finding in source-read claims.
From phase 3, "done" means done on both, and a phase that could only check one says so.
- **A convar that applies cleanly and does nothing.** Most game config is read once at boot and
cached; applying it later succeeds, reads back correctly, and changes nothing. Every lease key gets
verified live — apply, observe in the running game, restore — before it is advertised. The UO
module surveyed 156 config reads and found roughly eight that were live.
- **The wipe cadence is the schedule.** A monthly force wipe moves the hook list, rebuilds Oxide, and
invalidates every ledgered resource. Phases that end near one should expect to re-verify rather
than assume.
- **`start.bat`'s RCON password is `letmein` in plaintext with `rcon.web 1`.** Acceptable on a
loopback dev rig, and it must never be the shape anything published copies.
- **The wipe cadence is the schedule.** A monthly force wipe moves the hook list, rebuilds both
frameworks, and invalidates every ledgered resource. Phases that end near one should expect to
re-verify rather than assume. **Carbon's self-updating and rolling release tags mean the Carbon rig
may move under us between two runs on the same day**, where an Oxide build number at least says so.
- **The old rig's RCON password was `letmein` in plaintext with `rcon.web 1`.** Acceptable on a
loopback dev rig behind a home firewall, and it must never be the shape anything published copies —
which now matters more, because the panel rigs are reachable on a LAN address and **the egg is a
published artefact that people will copy defaults out of.**
- **The panel is the rig and the deliverable at once.** Convenient, and a way to prove the wrong
thing: a rig hand-tuned through the panel UI stops testing the egg. Anything a rig needs belongs in
the egg or in the push script, never only in a server's saved configuration.
## 7. Contract coverage audit
@@ -1507,6 +1752,199 @@ permission gate, because every plugin's check short-circuits without a `BasePlay
account bypasses most of them non-uniformly. A second, non-admin Steam account has to be arranged
before phase 7 — it is the one prerequisite this rig cannot satisfy on its own.
## 14. The Pterodactyl rig as built, 2026-09-15
R21's first server exists, made with the application key and driven with the client key. **Both
credentials work; neither can do the other's job.** What follows is what building it actually taught,
commits this module to supporting Oxide and Carbon both, and that file records only where the two
differ.
## The mirror
@@ -15,6 +18,7 @@ Everything below was **scraped verbatim from uMod on 2026-09-15**.
| [`DEFINITIONS.md`](DEFINITIONS.md) | **What things are called.** 678 items (short name, id, display name) and 2,590 workshop skin ids across 104 items. |
| [`OPERATING.md`](OPERATING.md) | **How it gets run.** The 6 operator pages — installing Oxide on a server, then installing, configuring and permissioning plugins. |
| [`agent/`](agent/README.md) | The same facts in **machine shape** — TSV and JSONL, ~46% of the tokens. Generated in the same pass, so it cannot drift. |
| [`CARBON.md`](CARBON.md) | **The other framework.** Where Carbon diverges from Oxide and nowhere else — file layout, the permission store, the `c.` commands, 30 Carbon-only hooks and 13 uMod names its catalogue omits. Sourced from Carbon's own metadata and source, **not yet proven on a live Carbon server.** |
**The one file here that is ours:** [`PLAN.md`](PLAN.md) — the schedule and the decisions of record
for actually building `module-rust`. Everything else in this directory is copied from uMod; that one
@@ -40,8 +44,12 @@ The dry run's central structural fact is the thing this reference serves:
> A ServUO shard is C# **source** the operator compiles into their own server, so our bridge plugin
> can be anything we want. **A Rust server is a binary nobody outside Facepunch patches.** The only
> way in is a mod — specifically an **Oxide plugin**, since Oxide/uMod is what modded Rust servers
> run — hooking the game's own events.
> way in is a mod — hooking the game's own events through a modding framework.
The dry run named that framework as Oxide, and **R19 corrected it: there are two.** Carbon runs an
Oxide compatibility layer, so one plugin serves both and the ceiling below is the same ceiling —
but *which* framework an operator installed is their choice, not ours. [`CARBON.md`](CARBON.md) is
the difference list.
Two consequences, and they are the two halves of this directory:
@@ -50,8 +58,8 @@ Two consequences, and they are the two halves of this directory:
those 477 hooks (or from a game type one of them hands you), the bridge cannot report it. That
makes it the input to the Rust sidecar's event catalogue — the analogue of
[`docs/link/PLAN.md`](../../link/PLAN.md) §5 on the UO side.
2.**We are a guest in someone else's plugin framework.**Our plugin is compiled, loaded, permissioned
and configured by Oxide, on Oxide's terms. [`OXIDE_API.md`](OXIDE_API.md) is that rulebook, and
2.**We are a guest in someone else's plugin framework**— and we do not get to pick which one. Our
plugin is compiled, loaded, permissioned and configured by Oxide or by Carbon, on its terms. [`OXIDE_API.md`](OXIDE_API.md) is that rulebook, and
[`OPERATING.md`](OPERATING.md) is what the server owner has to do — which is the surface our
deployment story has to sit on, the way
[`installer/INSTALL.md`](../../installer/INSTALL.md) sits on top of ServUO.
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.