diff --git a/modules/rust/CARBON.md b/modules/rust/CARBON.md new file mode 100644 index 0000000..61a20ab --- /dev/null +++ b/modules/rust/CARBON.md @@ -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", "", "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_`, `RUST_BLW_`, `RUST_IS_` | Rust protocol above / below / exactly `` | +| `CARBON_ABV_` | 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. diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 9978838..f651d92 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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 `/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 `/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_.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 diff --git a/modules/rust/README.md b/modules/rust/README.md index 8b6d26b..a78dbc8 100644 --- a/modules/rust/README.md +++ b/modules/rust/README.md @@ -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.