Merge pull request 'docs(modules): module-rust supports Carbon too, ships a Pterodactyl egg, and the rigs move to the panel' (#253) from docs/rust-carbon-and-pterodactyl into main

Reviewed-on: #253
This commit is contained in:
2026-09-16 02:54:23 +00:00
3 changed files with 943 additions and 58 deletions

378
modules/rust/CARBON.md Normal file
View File

@@ -0,0 +1,378 @@
# Carbon — the second modding framework, and where it differs from Oxide
**Carbon** is the other framework modded Rust servers run. It is not a fork of Oxide and it does not
load Oxide; it is a separate loader that ships an **Oxide compatibility layer** — the `Oxide.Core`,
`Oxide.Plugins` and `Oxide.Game.Rust` namespaces, reimplemented — so that a plugin written for Oxide
compiles and runs unchanged.
This document exists because [`PLAN.md`](PLAN.md) **R19** commits `module-rust` to supporting both.
It records the places the two frameworks are *not* the same, because those are the only places our
code has to care. Everything not listed here is identical by construction.
> **Provenance.** Facts below were taken on **2026-09-15** from Carbon's own published metadata —
> `api.carbonmod.gg/meta/carbon/{hooks,commands,convars,switches}.json` — and from the
> [`CarbonCommunity/Carbon`](https://github.com/CarbonCommunity/Carbon) source at `main`, with the
> narrative pages at [carbonmod.gg](https://carbonmod.gg) as the prose source. Carbon is upstream
> and wins any disagreement, exactly as uMod does for [`OXIDE_API.md`](OXIDE_API.md). Nothing here
> is a Runic Gateway contract.
>
> **Verified on a live Carbon server on 2026-09-15** — Carbon **2.0.259.0** `[2026.09.03.0]` on
> Linux, the `rust-carbon` rig (PLAN.md §14.5). Three of the four load-bearing claims held. **One was
> wrong, and it was wrong about Oxide as well as Carbon** — see §4. Corrected in place; §10 is the
> scorecard.
---
## 1. The one-sentence version
**A plugin in the `Oxide.Plugins` namespace deriving from `RustPlugin` is Carbon's own documented
first example**, so the bridge plugin is one `.cs` file that serves both frameworks. What diverges is
not the plugin API but **the things around it**: where files live, how the permission store is
persisted, what the console commands are called, and which extra hooks exist.
```csharp
// Carbon's own "first plugin" page shows this, unchanged from Oxide:
namespace Oxide.Plugins;
[Info("MyPlugin", "<author>", "1.0.0")]
public class MyPlugin : RustPlugin
{
private void OnServerInitialized() => Puts("Hello world!");
}
```
Carbon also offers a native shape — `namespace Carbon.Plugins` / `CarbonPlugin` — which we do not
use and should not: it is the one choice that would make the source Carbon-only.
---
## 2. Telling the two apart
### At compile time — `#if CARBON`
Carbon feeds the Roslyn compiler a set of conditional-compilation symbols. **Oxide defines no
equivalent**, so `#if CARBON` / `#if !CARBON` is the portable framework branch, and an Oxide
compiler simply evaluates the unknown symbol as false.
| Symbol | Meaning |
|---|---|
| `CARBON` | The framework is Carbon |
| `RUST` | The game is Rust |
| `OXIDE_PUBLICIZED` | Compiled against publicised Oxide assemblies |
| `WIN`, `UNIX` | Host operating system |
| `STAGING`, `AUX01`, `AUX02` | Rust branch |
| `RUST_ABV_<v>`, `RUST_BLW_<v>`, `RUST_IS_<v>` | Rust protocol above / below / exactly `<v>` |
| `CARBON_ABV_<YYYY_MM_DD>` | Carbon protocol above a date |
This works because the bridge plugin ships as **source** and is compiled by whichever framework
loaded it. It would not work for a precompiled DLL — a reason, among others, not to ship one.
**Confirmed on the live rig**: `carbon/config.json` reports
`"ConditionalCompilationSymbols": ["CARBON", "RUST", "OXIDE_PUBLICIZED"]`, and the list is an
operator-editable setting (`c.addconditional` adds to it), so treat the three above as the ones
present by default rather than the ones guaranteed.
### At run time
`#if` is decided when the file is compiled, which is what we want for API differences. Where a
*runtime* answer is needed — reporting which framework a server runs, in a `server.hello` say — ask
for the type rather than the file layout: `Carbon.Community` exists only under Carbon.
---
## 3. Where the files live — **the divergence that reaches the most decisions**
| Oxide | Carbon |
|---|---|
| `oxide/plugins/` | `carbon/plugins/` |
| `oxide/config/` | `carbon/configs/`**plural** |
| `oxide/data/` | `carbon/data/` |
| `oxide/lang/` | `carbon/lang/` |
| `oxide/logs/` | `carbon/logs/` |
| `oxide/extensions/`, plus `Oxide.Ext.*.dll` in `RustDedicated_Data/Managed` | `carbon/extensions/` only |
| — | `carbon/modules/`, `carbon/harmony/`, `carbon/developer/` |
**And none of those paths is fixed.** Carbon takes a command-line override for every single
directory — `-carbon.rootdir`, `-carbon.configdir`, `-carbon.datadir`, `-carbon.scriptdir`,
`-carbon.langdir`, `-carbon.logdir`, `-carbon.extdir`, `-carbon.moduledir`, `-carbon.modifierdir`,
`-carbon.profiledir`, `-carbon.carbonconfigdir`, `-carbon.sqlpermsdb`, `-harmonydir`. An operator
who has moved one is not doing anything unsupported.
**So the rule is: never compose a config or data path.** Carbon reimplements Oxide's own directory
accessors and populates them from its resolver:
```csharp
Interface.Oxide.ConfigDirectory // oxide/config or carbon/configs or wherever -carbon.configdir points
Interface.Oxide.DataDirectory
Interface.Oxide.PluginDirectory
Interface.Oxide.LangDirectory
Interface.Oxide.LogDirectory
Interface.Oxide.ExtensionDirectory
Interface.Oxide.RootDirectory
Interface.Oxide.InstanceDirectory
```
(`Carbon.Common/src/Oxide/OxideMod.cs` assigns each from `Defines.Get*Folder()`; `Interface.cs`
logs all eight at boot.) Asking the framework is both shorter and correct; hardcoding `oxide/config`
is wrong on Carbon and wrong on an Oxide server whose operator moved things.
**This is a direct amendment to R18.** The config editor's recursive walk is rooted at
`ConfigDirectory`, not at a literal `oxide/config/`; the directory it must refuse to walk is
`DataDirectory`, not a literal `oxide/data/`. The reasoning behind R18 is untouched — only the way
the two roots are obtained.
---
## 4. Permissions — same API, same format, different directory
Every member R2 depends on exists with the same name and the same argument shape
(`Carbon.Common/src/Oxide/Libraries/Permissions.cs`): `RegisterPermission`, `PermissionExists`,
`GrantUserPermission`, `RevokeUserPermission`, `GrantGroupPermission`, `RevokeGroupPermission`,
`CreateGroup`, `RemoveGroup`, `AddUserGroup`, `RemoveUserGroup`, `UserHasPermission`,
`GroupHasPermission`, `GetUserGroups`, `GetUserPermissions`, `GetGroupPermissions`,
`GetPermissionUsers`, `GetPermissionGroups`, `GetGroups`, `GetUsersInGroup`, `SetGroupParent`.
Two differences, and they pull in opposite directions.
**All of the member names above were confirmed present on the live Carbon rig**, which loaded and ran
our plugin against them unchanged.
**The return type differs, and the portable answer is the one we already chose.** Carbon's
`GrantUserPermission` returns `bool`; Oxide's returns `void` — which is
[§12.2](PLAN.md#122-four-rules-the-r2-permission-push-must-obey)'s finding, that a grant naming an
unregistered permission silently does nothing. Calling it as a statement compiles on both, so the
source stays single. But **the bool cannot be read portably**, so the `PermissionExists` pre-check
stays the mechanism on both frameworks rather than being replaced by a return value on one. Carbon
is the framework that *would* have told us, and we still cannot listen.
Carbon's signature also takes `BaseHookable` where Oxide's takes `Plugin`. Passing `this` is
correct on both; a variable typed `Plugin` is not.
### The store — **this section was wrong, and the truth is worse**
> **Corrected 2026-09-15 against both live rigs.** This document previously said *"Oxide persists to
> JSON; Carbon persists to Protobuf or SQLite"*, and offered that difference as the reason not to read
> the file. **Both halves were wrong.** The real shape is more dangerous than the one that was
> imagined, which is the only reason it is worth the space.
Read off the two running servers, byte for byte:
| | Oxide rig | Carbon rig |
|---|---|---|
| Path | `oxide/data/oxide.users.data`, `oxide.groups.data` | `carbon/data/oxide.users.data`, `oxide.groups.data` |
| First bytes | `0a 16 0a 07 64 65 66 61 75 6c 74 …` | `0a 17 0a 07 64 65 66 61 75 6c 74 …` |
| Format | **Protobuf** | **Protobuf** |
| Default groups | `default`, `admin` | `default`, `admin`, **`moderator`** |
**Neither framework writes JSON, and Carbon writes Carbon's data into files named after Oxide.** So
the trap is not "two formats you must tell apart". It is:
1. **The filename is identical and tells you nothing**, so a reader keyed on `oxide.users.data`
silently follows the wrong framework's file if it ever guesses the directory wrong.
2. **The format is an undocumented binary**, not the JSON the name and the `.data` extension suggest.
3. **Carbon can change it out from under you at run time** and Oxide cannot. `PermissionSerialization`
in `carbon/config.json` defaults to `0` (the Protobuf above); `c.migrate_perms_sql` moves the whole
store to SQLite at `server/identity/carbon.perms.db`, itself relocatable via `-carbon.sqlpermsdb`.
`Oxide Overrides/PermissionSql.cs` and `PermissionStoreless.cs` are those backends.
**R2's conclusion is unchanged and the argument for it is now much stronger.** A file reader would
have *worked* on both rigs today — same format, same names — and would break for the one operator
who ran a migrate command, with no error and no version marker to notice. **Drift detection reads the
API, or it does not work.**
**One more thing R2 has to accommodate: Carbon creates a third default group.** `carbon/config.json`
names `PlayerDefaultGroup`, `AdminDefaultGroup` and `ModeratorDefaultGroup`, all auto-granted by auth
level (`AutoGrantPlayerGroup` / `AutoGrantAdminGroup` / `AutoGrantModeratorGroup`, all `true`). A
site that pushes its *full* group set on connect must not treat `moderator` as drift to be reported,
nor delete it — the framework will simply recreate it, and the site will report drift for ever.
**Carbon does give R2 something Oxide's docs do not advertise: fourteen permission hooks**, a
`Permissions` category of its own — `OnUserPermissionGranted`, `OnUserPermissionRevoked`,
`OnUserGroupAdded`, `OnUserGroupRemoved`, `OnGroupCreated`, `OnGroupDeleted`, `OnGroupParentSet`,
`OnGroupRankSet`, `OnGroupTitleSet`, `OnGroupPermissionGranted`, `OnGroupPermissionRevoked`,
`OnPermissionRegistered`, `OnPermissionsUnregistered`, `OnUserNameUpdated`. Our uMod mirror carries
most of these as universal hooks too, so drift may be **observable as it happens** on both rather
than only diffable on connect. Phase 7 should test that rather than assume it; a hook that fires on
our *own* push is a feedback loop to suppress, not a bonus.
---
## 5. Console commands — `c.` not `oxide.`
Carbon's 129 published commands are `c.`-prefixed. The ones with Oxide counterparts:
| Oxide | Carbon |
|---|---|
| `oxide.grant` / `oxide.revoke` | `c.grant` / `c.revoke` |
| `oxide.group` | `c.group` |
| `oxide.usergroup` | `c.usergroup` |
| `oxide.load` / `oxide.unload` / `oxide.reload` | `c.load` / `c.unload` / `c.reload` |
| `oxide.plugins` | `c.plugins` |
Carbon can be configured to alias the old prefix, so an operator's muscle memory survives — but an
alias is opt-in and **we must never depend on one**. **Confirmed on the live rig:** `c.version`,
`c.plugins`, `c.grant` and `c.group` all answered; **`oxide.plugins` produced no output at all**. Note
the shape of that failure — Pterodactyl's `command` endpoint returns `204` either way, and Carbon
prints nothing for an unknown command, so *a wrong prefix looks exactly like a command that worked.*
`c.plugins` is also worth knowing about for a reason unrelated to permissions: **it reports per-plugin
`hook fires`, `hook time`, `hook memory`, `hook lag` and `hook exceptions`**, which is most of the
"log which of its expected hooks have fired at least once" mechanism [`PLAN.md`](PLAN.md) §6 requires
— for free, and only on Carbon. Useful when debugging *on* Carbon; **not a substitute for the
plugin's own counter**, which has to work on both. Our plugin appears there as
`Runic Gateway RunicGateway v0.1.0 … 2367ms [1077ms]`, under `Scripts`, with `failed plugins (0)`.
**Where this reaches us is narrow but real.** R2 and R18 both act through the plugin API, not the
console, so neither cares. The two that do care are **documentation** — every operator-facing
instruction naming `oxide.grant` needs its Carbon line — and **any place we drive a reload by
console string**, which R18's write path does. Resolve the reload through the framework rather than
by composing a command, or branch it on `#if CARBON`.
---
## 6. Hooks — Carbon is a superset, with thirteen names it does not list
Carbon publishes **894 hook entries, 774 unique names, in 42 categories**, against the **476** on
uMod's Rust hooks page that [`HOOKS.md`](HOOKS.md) mirrors. The larger number is not more game
coverage; Carbon documents patched methods our mirror's audience never sees.
Carbon flags every entry for compatibility. **30 are Carbon-only. Zero are marked Oxide-only.**
### The 30 Carbon-only hooks
| Hook | Category | What it is |
|---|---|---|
| `CanAcceptBackpackItem` | Global | Whether to accept a backpack item |
| `CanPatrolHeliSeePlayer` | Global | Patrol-helicopter line of sight to a player |
| `CanPickupAllFromRack` | Global | Taking every weapon from a rack |
| `CanPickupFromRack` | Global | Taking one weapon from a rack |
| `CanPlaceOnRack` | Global | Placing on a rack |
| `OnPickupFromRack` | Global | Controls taking items from a rack |
| `CanPlayerInheritNetworkGroup` | Global | Network-group inheritance |
| `OnChairComfort` | Global | Chair comfort |
| `OnChickenScared` | Global | A chicken is scared |
| `OnGrowableUpdate` | Global | A growable updates |
| `OnConsoleCommand` | Global | A console command is executed |
| `OnNativeCommandHasPermission` | Global | Permission check on a native console command |
| `OnEntitySpawn` | Global | An entity spawns — **not** Oxide's `OnEntitySpawned`, which exists on both |
| `OnJackieChan` | Global | Undescribed upstream |
| `OnCarbonBanPlayer`, `OnCarbonUnbanPlayer`, `OnCarbonKickPlayer`, `OnCarbonMutePlayer` | Player | Carbon admin-module moderation actions |
| `OnCarbonBlinded`, `OnCarbonUnblinded`, `OnCarbonSpectateStart`, `OnCarbonSpectateEnd` | Player | Carbon admin-module spectate and blind actions |
| `OnCarbonPrivateMessage`, `OnCarbonEmpowerPlayerStats`, `OnCarbonLockPlayerContainer` | Player | Carbon admin-module player actions |
| `OnCompilationFail`, `OnConstructorFail` | Engine | Plugin compile / constructor failure |
| `OnPluginCompileFailure`, `OnPluginOutdated` | Plugin | Plugin lifecycle |
| `OnMarketplaceTerminalPurchase` | Vending | Marketplace terminal purchase |
**None of them is load-bearing for us and none should become so.** The `OnCarbon*` family is the
Carbon admin module's own audit trail — tempting for a staff-actions feed, and exactly the kind of
convenience that quietly makes Carbon the required framework. If we ever want that feed, it has to
have an Oxide answer first.
### The 13 uMod names Carbon's catalogue does not carry
| Hook | Category | uMod's description |
|---|---|---|
| `CanNpcAttack` | Entity | An NPC attempts to attack another entity |
| `CanPushBoat` | Player | Cancelling a boat push |
| `CanUnlockTechTreeNode` | TechTree | Unlocking a blueprint in a tech tree |
| `CanUnlockTechTreeNodePath` | TechTree | …after the path check |
| `OnFrame` | Server | Each frame |
| `OnHelicopterKilled` | Entity | A CH47 is going to be killed |
| `OnNpcDestinationSet` | Entity | Cancelling an NPC destination change |
| `OnNpcPlayerResume` | Entity | Cancelling `TryForceToNavmesh` |
| `OnNpcStopMoving` | Entity | Denying an NPC move stop |
| `OnPlayerCorpse` | Player | A non-null corpse has spawned |
| `OnQuarryEnabled` | Resource | A mining quarry is turned on |
| `OnTeamInvite` | Team | Cancelling a team invitation |
| `OnTeamPromote` | Team | Cancelling a promotion |
**Absent from a catalogue is not the same as absent from the framework**, and two of these look like
renames rather than holes: Carbon lists `OnTeamMemberInvite` and `OnTeamMemberPromote` in its `Team`
category, which is `OnTeamInvite` and `OnTeamPromote` under different names. Carbon's `Team`
category also carries visible duplicates and both tenses of the same event (`OnTeamCreate` *and*
`OnTeamCreated`, `OnTeamUpdate` *and* `OnTeamUpdated`, `OnTeamMemberInvite` twice), which says the
catalogue is generated rather than curated.
So this table is **a list of things to check on a live Carbon server**, not a list of losses. The
practical protection is one we already committed to in [`PLAN.md`](PLAN.md) §6: *hooks bind by name
and arity through reflection with no compile-time check*, so the plugin logs which of its expected
hooks have fired at least once. That mechanism was written for Facepunch renaming a hook on wipe
day; it answers this question too, on either framework, without us having to trust either catalogue.
**None of the 13 is currently in a phase.** R5 settled Teams on Rust's **first-party clans**, not
first-party Teams, so `OnTeamInvite`/`OnTeamPromote` are outside the plan as written.
---
## 7. Convars — a Carbon-only set exists, and leases must not reach for it
Carbon publishes 23 convars of its own, several of them precisely the kind of live, gameplay-shaped
value [`PLAN.md`](PLAN.md) §9 wants to lease — `c.recycletickmultiplier`,
`c.safezonerecycletickmultiplier`, `c.researchdurationmultiplier` and so on, most flagged
`ForceModded`.
**A lease over one of those would work on Carbon and be undeclarable on Oxide.** Lease keys are
advertised to the event authoring form, and a key that silently does not exist on half of installs
is the failure `EVENTS.md` §H's *verify every key live* rule exists to prevent. So: **the lease
catalogue is drawn from the game's own convars, which both frameworks expose identically.** If a
Carbon-only key is ever worth the cost, it is advertised conditionally on the connected server's
framework, and that is a deliberate decision rather than an oversight.
---
## 8. Operating differences that reach deployment
- **They cannot coexist.** Oxide ships a patched `Assembly-CSharp.dll`; Carbon requires
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.**
---
## 10. Scorecard — what the live rig confirmed and what it corrected
Run 2026-09-15 against `rust-carbon` (Carbon **2.0.259.0** `[2026.09.03.0]` `21063e8`, Linux,
`production_build`, Rust 103/2633.288.1), with the Oxide rig alongside for comparison.
| Claim | Verdict | Evidence |
|---|---|---|
| An `Oxide.Plugins` / `RustPlugin` source file loads unchanged | **CONFIRMED** | The byte-identical `RunicGateway.cs` that runs on the Oxide rig loaded as `Runic Gateway v0.1.0` in `2367ms`, printed the same startup line, and retried the absent sidecar the same way |
| The framework root is `carbon/`, config dir is `configs` (plural) | **CONFIRMED** | `/carbon/{configs,data,lang,logs,plugins,extensions,modules,managed,native,modifiers,temp,tools}`; **no `/oxide` directory at all** |
| `Interface.Oxide.ConfigDirectory` resolves there | **CONFIRMED, indirectly and decisively** | The plugin's own config was written to **`/carbon/configs/RunicGateway.json`** by the same code that writes `/oxide/config/RunicGateway.json` on the Oxide rig. A literal path in R18 would not have found it |
| Console prefix is `c.`, `oxide.` is not aliased | **CONFIRMED** | `c.version` / `c.plugins` / `c.grant` / `c.group` answered; `oxide.plugins` produced nothing |
| `#if CARBON` is defined | **CONFIRMED** | `carbon/config.json``ConditionalCompilationSymbols: ["CARBON", "RUST", "OXIDE_PUBLICIZED"]` — and two symbols this document had not known about |
| Carbon self-updates | **CONFIRMED** | `SelfUpdating.Enabled: true`, plus the egg refetching `production_build` every boot |
| *"Oxide stores JSON, Carbon stores Protobuf or SQLite"* | **WRONG — see §4** | **Both** store Protobuf, under **identical filenames**, differing only in directory. The refutation strengthens R2 rather than weakening it |
| The 13 uMod hook names missing from Carbon's catalogue | **NOT YET TESTED** | None is in a phase; the plugin's own fired-hook log is the standing answer either way |
**Two things this document did not know to claim**, both found by looking rather than reading:
Carbon ships a **third default group** (`moderator`) that R2's push must tolerate, and `c.plugins`
exposes per-plugin hook telemetry Oxide has no equivalent for.

View File

@@ -1,8 +1,10 @@
# `module-rust` — the plan
**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. **R19R22 (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
ownership along with the map.
| | Oxide rig | Carbon rig |
|---|---|---|
| Panel | node `Main` (192.168.0.12), nest 4 "Rust" | same |
| Egg | ours, derived from **"Rust Autowipe"** (panel egg id 18 is the unmodified base) | same egg |
| `FRAMEWORK` | `oxide` | `carbon` |
| Allocations | game, query, RCON, Rust+, **plus one for the sidecar's `[web].bind`** | same |
Three traps recorded here because each cost time before it was understood:
Started and stopped as needed rather than both left running; the other servers on the node are
shut down, which is what makes two ~20 GB Rust installs fit a 128 GB disk.
- **`start.bat` never updated anything — the 2026-09-15 diagnosis was wrong, corrected in phase 0.**
The script was read as *"updates `C:\rustserver`, runs `D:\rust`"*, and the fix changed the path to
`d:\rust\`. The path was never the problem. **steamcmd requires `+force_install_dir` before
`+login`**, and the script had it after:
**The Carbon rig is a clean install, never a converted one.** Carbon migrates an Oxide install on
first boot — config, data, lang and permission files all come across — so a Carbon rig made by
switching `FRAMEWORK` on the Oxide rig would start out holding the Oxide rig's state and would prove
strictly less.
```
steamcmd.exe +login anonymous +force_install_dir d:\rust\ +app_update 258550 +quit
→ Please use force_install_dir before logon!
→ 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,260 @@ 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,
including one finding that changes R20's shape.
### 14.0 The rig
| | |
|---|---|
| Panel | `http://192.168.0.12` (no TLS — `https://` fails outright), node 1 `Main` |
| Servers | `rust-oxide` id **17** / **`e6758c06`**, and `rust-carbon` id **18** / **`87fb1f67`** (§14.5) |
| Egg | 18 `Rust Autowipe`, `ghcr.io/pterodactyl/games:rust` — both rigs, one egg |
| `FRAMEWORK` | `oxide` / `carbon` |
| Limits | 8192 MB memory, 25600 MB disk — deliberately under half the node, so the Carbon rig fits beside it |
| Allocations | oxide 21000-21004, carbon 21005-21009 — game, query, RCON, Rust+, **and one held for the sidecar's `[web].bind`** |
| World | procedural, size 3000, seed 1234 |
| SFTP | `192.168.0.12:2022` |
The RCON passwords are generated 24-byte tokens rather than the old rig's `letmein`, kept out of this
document and out of the repo. §6 named that shape as the thing nothing published should copy; this is
the first rig where it was not copied.
**A Rust server install is about 6 GB, not the ~20 GB this plan assumed** when it worried about node
capacity — measured at 5,894 MB with the game installed and the world generating. Two rigs are
comfortable on a 128 GB node, and the 25600 MB limit is generous rather than tight.
### 14.1 The two keys, and what each one is actually for
Confirmed by use rather than by reading:
| | Application (`ptla_`) | Client (`ptlc_`) |
|---|---|---|
| Create / configure a server, assign allocations | **yes** | no |
| List, read, power, console, **files** | no (`403`) | **yes** |
| Write or import an egg | **no**`/api/application/eggs` 404s; eggs are an admin-UI or `php artisan` operation | no |
So the full loop needs both, and **a published egg is a JSON file a human imports** — which is also
exactly how an operator will consume ours, so it is a constraint worth designing into rather than
around.
**File operations are refused during install** with `409 ServerStateConflictException`
*"this server has not yet completed its installation process"*. Anything that pushes files has to
wait for `is_installing: false`, not merely for the server to exist.
### 14.2 The correction: there was never a panel bug
An earlier pass through this section recorded that the client key "authenticates and then lists zero
servers", and reasoned from there to a second 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 were wrong, and wrong the same way.** The servers were being deleted while the probing
happened, so two calls minutes apart read two different worlds. Once a server existed, every one of
those calls answered correctly on the first attempt — the client list, the single-server route, and
`include=user`.
The reusable part 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.
### 14.2b The upload loop, proven with the real plugin
Not a hello-world: phase 1's actual `RunicGateway.cs` (27,642 bytes, 709 lines) was pushed straight
from the working tree with the client key, and it came back byte-identical on read.
Three things that worked and were not certain to:
- **`files/write` creates missing parents.** `/oxide/plugins/` did not exist — the framework is laid
down at boot (§14.3), and the server had never been started — and the write created the whole path.
- **A plugin placed before Oxide exists survives Oxide arriving.** The entrypoint's `unzip -o` over
`oxide/` left the file untouched, so the push does not have to wait for a first boot.
- **It compiled and loaded on Linux**, which no previous phase had ever established. Every prior
finding came from Windows and Mono:
```
02:19 [Info] RunicGateway was compiled successfully in 0ms
02:19 [Info] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
02:19 [Info] Loaded plugin Runic Gateway v0.1.0 by RunicGateway
02:19 [Info] [Runic Gateway] cannot reach the sidecar: Connection refused - retrying quietly
```
That last line is phase 1's no-stall contract holding on a second platform: no sidecar exists on
this host yet, the plugin says so once and keeps the game running.
**Read the console without a websocket.** Pterodactyl streams console over a websocket, which is
awkward to drive from a script — but `wrapper.js` also writes `latest.log`, and Oxide writes
`oxide/logs/oxide_<date>.txt`. Both are plain reads through `files/contents`, which is how every log
line quoted in this section was obtained. Worth knowing before anyone writes a websocket client.
### 14.2c The tier-2 loop, end to end
R21's middle tier is the one that has to be pleasant to use, so it was run rather than described.
One pass: patch the working-tree source so the change is visible in the game console, push, reload
through the client API's `command` endpoint, read Oxide's log back, then restore.
```
patched source: True
push -> HTTP 204
oxide.reload -> HTTP 204
02:27 [Info] RunicGateway was compiled successfully in 3392ms
02:27 [Info] Unloaded plugin Runic Gateway v0.1.0 by RunicGateway
02:27 [Info] [Runic Gateway] protocol 1 [PTERODACTYL-PUSH-PROOF], serverId 'main', sidecar 127.0.0.1:7799
02:27 [Info] Loaded plugin Runic Gateway v0.1.0 by RunicGateway
restored source and re-pushed -> 204
```
**Roughly ten seconds from a saved edit to a reloaded plugin**, against a running server with a
generated world, without touching the panel UI. That is the loop `deploy.ps1` gives us for ServUO,
and it is the thing that makes the panel a workable rig rather than only a deployment target.
Four details worth carrying into the push script:
- **Reload is `POST /command`, not a file operation**, and it answers `204` whether or not the plugin
actually came back. The proof has to be read out of `oxide/logs/` afterwards — the same shape R18's
auto-rollback needs, and an early rehearsal of it.
- **The unload/load pair straddles the plugin's own `Init` log line.** `Unloaded` is printed, then the
new instance's startup line, then `Loaded`. A script that waits for `Loaded` before reading has
already passed the line it wanted.
- **Oxide's compiler idles out and restarts.** The boot compile was `0ms`; the reload compile was
`3392ms` because `Shutting down compiler because idle shutdown` had happened in between. A timeout
tuned against a warm compiler will be wrong on the first reload after a quiet period.
- **Restore the working tree and re-push it.** A test that leaves a marker in the source is a test
that ships a marker. Both were put back and verified byte-identical against the server copy.
### 14.3 The image installs the framework on **every boot**, and neither version is pinnable
`ghcr.io/pterodactyl/games:rust`'s entrypoint is where `FRAMEWORK` is consumed — **not** the egg's
install script, which knows nothing about it. On every single start, before the game runs, it:
- runs `steamcmd +app_update 258550` unless `AUTO_UPDATE=0`;
- for `carbon`, downloads
`CarbonCommunity/Carbon.Core/releases/download/**production_build**/Carbon.Linux.Release.tar.gz`;
- for `oxide`, downloads `OxideMod/Oxide.Rust/releases/**latest**/Oxide.Rust-linux.zip`.
**Both are moving targets, fetched fresh at every restart.** CARBON.md §8 predicted this for Carbon
from its rolling release tags; the egg makes it true of *Oxide as well*, because `latest` is the same
kind of promise. The consequence is sharper than "the rig may drift":
> **A restart is a framework upgrade.** Two runs of the same test on the same server, minutes apart,
> are not guaranteed to be running the same framework build — and nothing in the panel says so.
That reaches three places. **R4's `doctor`**: the weaker "current enough" claim is not Carbon-specific
after all; under the egg neither framework has a pinned version to check. **§6's wipe-cadence risk**:
the re-verify step is per *restart*, not per wipe. And **R20 itself**: if the egg is our deliverable,
whether it should pin the framework at all is a decision, not an oversight — the upstream egg's
answer is "always newest", which is right for an operator on wipe day and wrong for a test rig
trying to reproduce a finding.
### 14.4 **The trap that changes R20: the startup string is not a safe place to launch the sidecar**
R20 says the startup command becomes "a small wrapper that launches `rust-link-sidecar` and then
`RustDedicated`". The mechanism allows it and the ordering makes it wrong.
`wrapper.js` runs the startup string through `child_process.exec`, which is `/bin/sh -c` — so
`./rust-link-sidecar & ./RustDedicated …` is syntactically fine. **But for Carbon the entrypoint
prepends to the whole string:**
```bash
MODIFIED_STARTUP="LD_PRELOAD=$(pwd)/libdoorstop.so ${MODIFIED_STARTUP}"
```
So a startup beginning with our sidecar becomes:
```bash
LD_PRELOAD=…/libdoorstop.so ./rust-link-sidecar & ./RustDedicated …
```
**The preload lands on the sidecar and not on the game.** Carbon loads through Doorstop rather than
through a patched `Assembly-CSharp.dll`, so the result is a server that starts cleanly, reports no
error, and **is not modded** — no plugins, no hooks, and a bridge that connects to a game it can
never hear from. It is the exact silent-success failure §6 keeps cataloguing, and it would only ever
appear on the Carbon half.
Two further consequences of the same handoff:
- **`quit` SIGTERMs the shell, not the sidecar.** `wrapper.js` kills `gameProcess`, which is the `sh`
running the startup string; a backgrounded sidecar is not its child in the way that reaches. R20
already required "stop means stop the game" — this is the mechanism by which it would fail, and it
leaves an orphan holding port 21004 against the next start.
- **Doorstop also confirms R21's clean-install rule from a second direction.** Switching `FRAMEWORK`
on an existing install does not undo the other framework: Oxide's patched DLL stays on disk while
Carbon preloads over it. The migration argument was the soft reason for a fresh Carbon rig; this is
the hard one.
**So R20 needs a decision it did not know it needed:** the sidecar is launched by something other
than the startup string — our own image or entrypoint layered on the upstream one — or the startup
string is composed so that whatever the entrypoint prepends still lands on `RustDedicated`. The first
is more work and survives upstream changing its entrypoint; the second is free and depends on a line
in somebody else's repository. Raised rather than settled.
### 14.5 The Carbon rig, and R19 proven
`rust-carbon` — id **18**, identifier **`87fb1f67`**, same egg, same world (procedural, 3000, seed
1234), same limits, allocations **21005-21009 with 21009 held for the sidecar**, `FRAMEWORK=carbon`.
**A clean install, never a converted one**, per R21: install 122s, boot 543s, running.
**R19 is proven.** The byte-identical `RunicGateway.cs` that runs on the Oxide rig — no `#if CARBON`
anywhere in it, nothing conditional at all — loaded on Carbon **2.0.259.0** and behaved the same:
```
[INFO] Carbon 2.0.259.0 [2026.09.03.0] 21063e8 on Linux
[INFO] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
[INFO] Loaded plugin Runic Gateway v0.1.0 by RunicGateway [2367ms]
[INFO] [Runic Gateway] cannot reach the sidecar: Connection refused - retrying quietly [RunicGateway Link|26]
```
That last line is the no-stall contract holding on its **third** platform now — Windows/Mono,
Linux/Oxide, Linux/Carbon — from one source file. [`CARBON.md`](CARBON.md) §10 is the full scorecard;
the parts that change decisions are below.
**R18's amendment is confirmed the best way it could have been.** The plugin's own config, written by
the same Oxide-compat API on both rigs, landed at `/oxide/config/RunicGateway.json` on one and
**`/carbon/configs/RunicGateway.json`** on the other. D3 put the plugin's config inside R18's editor;
had that editor used a literal `oxide/config/`, **it would not have found its own plugin's config on
half of all installs.** No test would have caught it; only two rigs would.
**And one claim was refuted — the one with the sharpest consequence.** `CARBON.md` had said Oxide
stores permissions as JSON and Carbon as Protobuf or SQLite, offering the difference as the reason
not to read the file. Both rigs say otherwise:
| | Oxide rig | Carbon rig |
|---|---|---|
| Path | `oxide/data/oxide.users.data` | `carbon/data/`**`oxide.users.data`** |
| First bytes | `0a 16 0a 07 64 65 66 61 75 6c 74` | `0a 17 0a 07 64 65 66 61 75 6c 74` |
| Format | Protobuf | Protobuf |
| Default groups | `default`, `admin` | `default`, `admin`, **`moderator`** |
**Same binary format, same filenames, different directory** — and Carbon writes *its* data into files
named after Oxide. **This makes R2's API-only rule more important, not less.** A file reader would
have worked on both rigs today and broken silently for the one operator who ran `c.migrate_perms_sql`
— no error, no version marker, just a site reporting drift against a store nobody is writing any
more. The rule survives; the reasoning behind it was wrong and is now right.
Two things nobody had thought to claim, found by looking:
- **Carbon auto-creates a third default group, `moderator`**, auto-granted by auth level alongside
`default` and `admin`. R2 pushes its *full* set on connect, so it has to tolerate a group the
framework will recreate the moment it is deleted — otherwise the site reports drift for ever.
- **`c.plugins` reports per-plugin `hook fires`, `hook time`, `hook memory`, `hook lag` and
`hook exceptions`** — most of §6's "log which expected hooks have fired" mechanism, free, and only
on Carbon. Useful when debugging on Carbon; **not a substitute** for the plugin's own counter, which
must work on both.
**A warning about how a wrong console command fails here.** Pterodactyl's `command` endpoint returns
`204` whether or not anything happened, and Carbon prints nothing for an unknown command. So
`oxide.plugins` on Carbon — which is simply not a command — is indistinguishable from success at the
API. Anything driving the console has to read a log to know, which is the same conclusion §14.2c
reached about `oxide.reload` and the same shape R18's rollback needs.
---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins
[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust

View File

@@ -1,10 +1,13 @@
# Rust — the Oxide/uMod ecosystem reference
# Rust — the modding-framework reference
Reference material for the **upcoming `module-rust`**: a mirror of the uMod/Oxide documentation —
the Rust game API *and* the game-independent plugin framework around it — captured here so the
module can be designed and built against it without a round trip to umod.org on every question.
Reference material for **`module-rust`**: a mirror of the uMod/Oxide documentation — the Rust game
API *and* the game-independent plugin framework around it — captured here so the module can be
designed and built against it without a round trip to umod.org on every question.
Everything below was **scraped verbatim from uMod on 2026-09-15**.
The mirrored material was **scraped verbatim from uMod on 2026-09-15**. One file,
[`CARBON.md`](CARBON.md), covers the *other* framework modded Rust servers run: PLAN.md **R19**
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.