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:
378
modules/rust/CARBON.md
Normal file
378
modules/rust/CARBON.md
Normal 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.
|
||||
@@ -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. **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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user