rust-carbon (id 18, 87fb1f67) is built on the same egg with FRAMEWORK=carbon, a clean install rather than a converted one. Install 122s, boot 543s. R19 is proven. The byte-identical RunicGateway.cs that runs on the Oxide rig, with no conditional compilation in it at all, loaded on Carbon 2.0.259.0 and behaved identically - same startup line, same no-stall retry against an absent sidecar. That contract now holds on three platforms from one source file: Windows/Mono, Linux/Oxide, Linux/Carbon. 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. Had the R18 editor used a literal path it would not have found its own plugin's config on half of all installs, and no test would have caught it. One claim is REFUTED, and it was wrong about Oxide as well as Carbon. CARBON.md said Oxide stores permissions as JSON and Carbon as Protobuf or SQLite. Both rigs say otherwise: both store Protobuf, under IDENTICAL filenames (oxide.users.data, oxide.groups.data), differing only in directory - Carbon writes its own data into files named after Oxide. This strengthens R2's API-only rule rather than weakening it: a file reader would have worked on both rigs today and broken silently for the one operator who ran c.migrate_perms_sql. Two things nobody had thought to claim, found by looking. Carbon auto-creates a third default group, moderator, which R2's full-set push must tolerate or report drift for ever. And c.plugins exposes per-plugin hook telemetry that is most of section 6's fired-hook mechanism, free, and only on Carbon. Also: a wrong console command is indistinguishable from success at the API. Pterodactyl's command endpoint returns 204 either way and Carbon prints nothing for an unknown command, so oxide.plugins on Carbon looks exactly like it worked. Anything driving the console must read a log to know. CARBON.md gains a scorecard (section 10) and drops its unproven banner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
22 KiB
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 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 theCarbonCommunity/Carbonsource atmain, with the narrative pages at carbonmod.gg as the prose source. Carbon is upstream and wins any disagreement, exactly as uMod does forOXIDE_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, therust-carbonrig (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.
// 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:
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'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:
- The filename is identical and tells you nothing, so a reader keyed on
oxide.users.datasilently follows the wrong framework's file if it ever guesses the directory wrong. - The format is an undocumented binary, not the JSON the name and the
.dataextension suggest. - Carbon can change it out from under you at run time and Oxide cannot.
PermissionSerializationincarbon/config.jsondefaults to0(the Protobuf above);c.migrate_perms_sqlmoves the whole store to SQLite atserver/identity/carbon.perms.db, itself relocatable via-carbon.sqlpermsdb.Oxide Overrides/PermissionSql.csandPermissionStoreless.csare 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 §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 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 §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 §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 whyPLAN.mdR21 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.*.dllout ofRustDedicated_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), plusedge_build,experimental_buildand 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'sdoctorprerequisite 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.