Files
docs/modules/rust/CARBON.md
wtclaude 22159ec78f docs(modules): the Carbon rig, R19 proven, and one refuted claim
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
2026-09-15 21:51:11 -05:00

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 the CarbonCommunity/Carbon source at main, with the narrative pages at carbonmod.gg as the prose source. Carbon is upstream and wins any disagreement, exactly as uMod does for 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.

// 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:

  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 §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 why 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.jsonConditionalCompilationSymbols: ["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.