From 5890da633f8452ad720444c308b37b3a5084b8de Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 20:59:23 -0500 Subject: [PATCH 1/4] docs(modules): module-rust supports Carbon too, and ships a Pterodactyl egg Three new decisions of record, and a new reference for the second modding framework. R19 - the bridge plugin is framework-agnostic from now, not ported later. Carbon is not a fork of Oxide but a separate loader shipping an Oxide compatibility layer, so one .cs in the Oxide.Plugins namespace serves both, with #if CARBON only where the APIs genuinely differ. Three existing decisions take an amendment and none is reversed: R18's config walk roots at Interface.Oxide.ConfigDirectory rather than a literal oxide/config (Carbon uses carbon/configs AND lets an operator relocate every directory from the command line); R2's permission store is Protobuf or SQLite on Carbon, which permanently closes the file-reading shortcut it never planned to take, while the PermissionExists pre-check survives because Carbon's bool return is the one thing we cannot read portably; R4's doctor asks which framework rather than whether Oxide, and gets a weaker "current enough" claim because Carbon ships rolling release tags. R20 - a Pterodactyl egg is a third supported deployment path beside the installer and the hand install, derived from the community "Rust Autowipe" egg, which already carries a FRAMEWORK variable offering vanilla/carbon/oxide. The sidecar runs inside the game's container, which is what lets D2 stand unchanged: a container's 127.0.0.1 is genuinely private, so the game link stays loopback and stays unauthenticated. Lands in phase 18 beside the installer. R21 - both rigs move to the Pterodactyl panel, because Oxide and Carbon cannot coexist in one install and so a single server cannot prove R19. Also retires the wipe-day maintenance that dominated section 4, and makes the rig Linux where every prior finding came from Windows and Mono. New: modules/rust/CARBON.md, the difference list - file layout, the permission store, the c. commands, 30 Carbon-only hooks, and 13 uMod names Carbon's catalogue omits (at least two of which look like renames). Sourced from Carbon's own published metadata and source at main, and labelled throughout as not yet proven on a live Carbon server. One outstanding request, recorded in section 3: the panel token on disk is an application key and Pterodactyl puts files, power and console on the client API, so iteration needs a ptlc_ key only the account holder can mint. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/CARBON.md | 307 +++++++++++++++++++++++++++++++++++++++++ modules/rust/PLAN.md | 276 +++++++++++++++++++++++++++++------- modules/rust/README.md | 26 ++-- 3 files changed, 551 insertions(+), 58 deletions(-) create mode 100644 modules/rust/CARBON.md diff --git a/modules/rust/CARBON.md b/modules/rust/CARBON.md new file mode 100644 index 0000000..c863d35 --- /dev/null +++ b/modules/rust/CARBON.md @@ -0,0 +1,307 @@ +# 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. +> +> **Not yet proven on a live Carbon server.** Every claim here is read off metadata or source. This +> project's own record on that is poor — phases 0 and 1 each found source-read claims a running +> server contradicted — so treat the whole document as *the hypothesis phase 3 tests*, not as +> established fact. + +--- + +## 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 | +| `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. + +### 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, different persistence + +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. + +**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 is not a file we can read.** Oxide persists to JSON — `oxide/data/oxide.users.data` and +`oxide.groups.data`. Carbon persists to **Protobuf or SQLite**, switchable at run time +(`c.migrate_perms_proto`, `c.migrate_perms_sql`, with the SQLite path itself relocatable via +`-carbon.sqlpermsdb`, default `server/identity/carbon.perms.db`); `Oxide Overrides/PermissionSql.cs` +and `PermissionStoreless.cs` are the pluggable backends. + +R2 never planned to read the store file, so this changes nothing — but it **closes the option +permanently**, which is worth stating once. Drift detection reads the API, or it does not work. + +**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**. + +**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.** diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 9978838..7228836 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-one decisions of record; one outstanding +request, 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–R21 (2026-09-15) added a second modding framework, a Pterodactyl egg, and moved the +rigs off the workstation** — see [`CARBON.md`](CARBON.md) for the framework reference. 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,149 @@ 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 one thing it cannot do.** The panel's application API token is at +`RunicGateway/pterodactyl_claude_api_token` (`ptla_…`). It creates and configures servers, +allocations and users, and reads eggs. **It cannot touch files, power or console** — Pterodactyl puts +those on the *client* API, which rejects an application key outright. 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 | + +**The client key is the one thing outstanding**, and it is a request rather than a decision: an +application key cannot be widened into one, so the org lead generates it from the account page. Until +it exists, iteration is reinstall-only — correct, and slow enough that nobody would choose it twice. +The push script itself lives in **`Rust-Plugins`**, mirroring where `deploy.ps1` lives for ServUO. + ## 3. Open questions -**None.** Both questions this section carried were closed on 2026-09-15. +**None — but one outstanding request.** R21's iteration loop needs a Pterodactyl **client** API key +(`ptlc_…`); the application key already on disk cannot be widened into one, and only the account +holder can mint it. Not a question and not a decision: until it exists, pushing a file to a rig means +a panel reinstall. Everything else in this section was closed on 2026-09-15. *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 +831,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 +900,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 +913,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 +929,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 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 +987,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 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. -- 2.49.1 From a29cdf0faeddb798ec86c79b673c45493378b268 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 21:08:15 -0500 Subject: [PATCH 2/4] docs(modules): record the Pterodactyl client key and its empty-server-list problem The token file now carries both keys. The client key authenticates as the admin owner and then lists zero servers, on every listing type and for both the short identifier and the full UUID, so tier 2 of R21's deployment loop is not usable yet. Reinstall and SFTP are unaffected. Records the diagnosis rather than only the symptom, including one trap worth not repeating: includes are independently broken on this panel, so servers?include=user returns an empty list where the same route without the include returns six. Nothing built on an include is evidence about ownership. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 49 ++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 7228836..88878eb 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -797,11 +797,12 @@ first boot — it copies config, data, lang and permission files across. A Carbo 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 one thing it cannot do.** The panel's application API token is at -`RunicGateway/pterodactyl_claude_api_token` (`ptla_…`). It creates and configures servers, -allocations and users, and reads eggs. **It cannot touch files, power or console** — Pterodactyl puts -those on the *client* API, which rejects an application key outright. So the deployment loop is three -tiers, matched to what each is for: +**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 | |---|---|---| @@ -809,17 +810,39 @@ tiers, matched to what each is for: | **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 | -**The client key is the one thing outstanding**, and it is a request rather than a decision: an -application key cannot be widened into one, so the org lead generates it from the account page. Until -it exists, iteration is reinstall-only — correct, and slow enough that nobody would choose it twice. -The push script itself lives in **`Rust-Plugins`**, mirroring where `deploy.ps1` lives for ServUO. +**The client key now exists beside the application one** — the token file holds both, as +`application:` and `user:` lines — but at the time of writing it authenticates and then sees **no +servers**, so tier 2 is not yet usable. §3 has the diagnosis. Until it is, iteration is +reinstall-only: correct, and slow enough that nobody would choose it twice. The push script itself +lives in **`Rust-Plugins`**, mirroring where `deploy.ps1` lives for ServUO. ## 3. Open questions -**None — but one outstanding request.** R21's iteration loop needs a Pterodactyl **client** API key -(`ptlc_…`); the application key already on disk cannot be widened into one, and only the account -holder can mint it. Not a question and not a decision: until it exists, pushing a file to a rig means -a panel reinstall. Everything else in this section was closed on 2026-09-15. +**None — but one thing is unresolved on the panel.** R21's iteration loop needs a Pterodactyl +**client** API key, and one now exists (`ptlc_…`, alongside the application key, both in +`RunicGateway/pterodactyl_claude_api_token`). It authenticates correctly — +`/api/client/account` returns user 1 `whitlocktech`, `root_admin: true`, the owner of every server on +the node — **and then lists zero servers.** `/api/client` is `total: 0` for the default listing and +for `type=admin` and `type=admin-all` alike, and every `/api/client/servers/{id}/…` route 404s for +both the short identifier and the full UUID. + +That is a panel-side condition, not a token-shape problem, and it is **not blocking the plan** — it +blocks tier 2 of R21's deployment loop, leaving reinstall and SFTP working. Diagnosis so far, so that +whoever picks it up does not repeat it: + +- The key is **not** mis-scoped: `/api/client/account` and `/api/client/permissions` both answer 200. +- The account is **not** missing ownership: `/api/application/servers` lists six servers, and server 7 + carries `"user": 1`. +- **Includes are broken on this panel independently**, which is worth knowing before trusting a + diagnosis built on them: `/api/application/servers?include=user` returns an *empty* list where the + same route without the include returns six, and `/api/application/users/1?include=servers` reports + zero servers owned. Neither is evidence of anything about ownership. + +The remaining candidates are a stale panel cache and the key's allowed-IP field. Worth noting that +`gitea.whitlocktech.com` was returning 502 and Cloudflare 530 during the same window, so "something +is wrong with that host right now" is a live hypothesis rather than a coincidence to dismiss. + +Everything else in this section was closed on 2026-09-15. *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 -- 2.49.1 From 069e715b1b7cffe090cfad656225cdd9b017b96b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 21:30:06 -0500 Subject: [PATCH 3/4] docs(modules): the Pterodactyl rig as built, R22, and a correction Section 14 records R21's first rig, made with the application key and driven with the client key. Both work; neither can do the other's job, and there is no egg-write endpoint at all, so a published egg is a JSON file a human imports. The tier-2 loop was run rather than described: phase 1's real RunicGateway.cs pushed from the working tree, oxide.reload through the client API, the edit observed in Oxide's own log, source restored. About ten seconds end to end. Three things worked that were not certain to - files/write creates missing parents, a plugin placed before Oxide exists survives Oxide arriving, and the plugin compiles and loads on Linux, which no previous phase had established. Two findings that change decisions. The image installs the framework on EVERY boot, Carbon from the rolling production_build tag and Oxide from releases/latest, so a restart is a framework upgrade and neither is pinnable through the egg. And R20's "wrapper launches the sidecar then RustDedicated" does not survive Carbon: the entrypoint prepends LD_PRELOAD to the whole startup string, so the preload would land on the sidecar and the server would start cleanly, report nothing, and be silently unmodded. R22 (org lead): the sidecar's configuration moves into the egg's variables. Nearly free - rust-link already reads all five keys from the environment with env-over-file-over-defaults precedence - but the game bind must not be operator-editable, the web bind must derive from an allocation, and the db path must not be able to agree with REMOVE_FILES. Also corrects an earlier claim in this branch. The client key listing zero servers and includes returning empty were both CORRECT; the servers were being deleted between reads. A differential diagnosis across two API calls assumes the state did not move between them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 299 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 268 insertions(+), 31 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 88878eb..5443f6f 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1,10 +1,10 @@ # `module-rust` — the plan -**Status:** phases 0 and 1 done, 2026-09-15. **Twenty-one decisions of record; one outstanding -request, no open questions.** Audited against the whole contract, not just the game-facing chapters +**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–R21 (2026-09-15) added a second modding framework, a Pterodactyl egg, and moved the -rigs off the workstation** — see [`CARBON.md`](CARBON.md) for the framework reference. +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 @@ -810,39 +810,83 @@ one. So the deployment loop is three tiers, matched to what each is for: | **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 | -**The client key now exists beside the application one** — the token file holds both, as -`application:` and `user:` lines — but at the time of writing it authenticates and then sees **no -servers**, so tier 2 is not yet usable. §3 has the diagnosis. Until it is, iteration is -reinstall-only: correct, and slow enough that nobody would choose it twice. The push script itself +**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 — but one thing is unresolved on the panel.** R21's iteration loop needs a Pterodactyl -**client** API key, and one now exists (`ptlc_…`, alongside the application key, both in -`RunicGateway/pterodactyl_claude_api_token`). It authenticates correctly — -`/api/client/account` returns user 1 `whitlocktech`, `root_admin: true`, the owner of every server on -the node — **and then lists zero servers.** `/api/client` is `total: 0` for the default listing and -for `type=admin` and `type=admin-all` alike, and every `/api/client/servers/{id}/…` route 404s for -both the short identifier and the full UUID. +**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). -That is a panel-side condition, not a token-shape problem, and it is **not blocking the plan** — it -blocks tier 2 of R21's deployment loop, leaving reinstall and SFTP working. Diagnosis so far, so that -whoever picks it up does not repeat it: +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. -- The key is **not** mis-scoped: `/api/client/account` and `/api/client/permissions` both answer 200. -- The account is **not** missing ownership: `/api/application/servers` lists six servers, and server 7 - carries `"user": 1`. -- **Includes are broken on this panel independently**, which is worth knowing before trusting a - diagnosis built on them: `/api/application/servers?include=user` returns an *empty* list where the - same route without the include returns six, and `/api/application/users/1?include=servers` reports - zero servers owned. Neither is evidence of anything about ownership. +**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 remaining candidates are a stale panel cache and the key's allowed-IP field. Worth noting that -`gitea.whitlocktech.com` was returning 502 and Cloudflare 530 during the same window, so "something -is wrong with that host right now" is a live hypothesis rather than a coincidence to dismiss. - -Everything else in this section was closed on 2026-09-15. +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 @@ -952,7 +996,7 @@ 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) **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 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 | +| 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 @@ -1708,6 +1752,199 @@ 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` | +| Server | `rust-oxide`, id **17**, identifier **`e6758c06`** | +| Egg | 18 `Rust Autowipe`, `ghcr.io/pterodactyl/games:rust` | +| `FRAMEWORK` | `oxide` | +| Limits | 8192 MB memory, 25600 MB disk — deliberately under half the node, so the Carbon rig fits beside it | +| Allocations | 21000 game (default), 21001 query, 21002 RCON, 21003 Rust+, **21004 held for the sidecar's `[web].bind`** | +| World | procedural, size 3000, seed 1234 | +| SFTP | `192.168.0.12:2022` | + +The RCON password is a generated 24-byte token 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. + +--- + [rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link [rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins [mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust -- 2.49.1 From 22159ec78fdd3ae51319e22f1451faaf8e0b6001 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 21:51:11 -0500 Subject: [PATCH 4/4] 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/CARBON.md | 97 ++++++++++++++++++++++++++++++++++++------ modules/rust/PLAN.md | 71 ++++++++++++++++++++++++++++--- 2 files changed, 150 insertions(+), 18 deletions(-) diff --git a/modules/rust/CARBON.md b/modules/rust/CARBON.md index c863d35..61a20ab 100644 --- a/modules/rust/CARBON.md +++ b/modules/rust/CARBON.md @@ -16,10 +16,10 @@ code has to care. Everything not listed here is identical by construction. > and wins any disagreement, exactly as uMod does for [`OXIDE_API.md`](OXIDE_API.md). Nothing here > is a Runic Gateway contract. > -> **Not yet proven on a live Carbon server.** Every claim here is read off metadata or source. This -> project's own record on that is poor — phases 0 and 1 each found source-read claims a running -> server contradicted — so treat the whole document as *the hypothesis phase 3 tests*, not as -> established fact. +> **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. --- @@ -57,6 +57,8 @@ 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 `` | @@ -65,6 +67,11 @@ compiler simply evaluates the unknown symbol as false. 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 @@ -116,7 +123,7 @@ the two roots are obtained. --- -## 4. Permissions — same API, different persistence +## 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`, @@ -127,6 +134,9 @@ Every member R2 depends on exists with the same name and the same argument shape 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 @@ -138,14 +148,43 @@ 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 is not a file we can read.** Oxide persists to JSON — `oxide/data/oxide.users.data` and -`oxide.groups.data`. Carbon persists to **Protobuf or SQLite**, switchable at run time -(`c.migrate_perms_proto`, `c.migrate_perms_sql`, with the SQLite path itself relocatable via -`-carbon.sqlpermsdb`, default `server/identity/carbon.perms.db`); `Oxide Overrides/PermissionSql.cs` -and `PermissionStoreless.cs` are the pluggable backends. +### The store — **this section was wrong, and the truth is worse** -R2 never planned to read the store file, so this changes nothing — but it **closes the option -permanently**, which is worth stating once. Drift detection reads the API, or it does not work. +> **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`, @@ -171,7 +210,17 @@ Carbon's 129 published commands are `c.`-prefixed. The ones with Oxide counterpa | `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**. +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 @@ -305,3 +354,25 @@ framework, and that is a deliberate decision rather than an oversight. | 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 5443f6f..f651d92 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1763,15 +1763,15 @@ including one finding that changes R20's shape. | | | |---|---| | Panel | `http://192.168.0.12` (no TLS — `https://` fails outright), node 1 `Main` | -| Server | `rust-oxide`, id **17**, identifier **`e6758c06`** | -| Egg | 18 `Rust Autowipe`, `ghcr.io/pterodactyl/games:rust` | -| `FRAMEWORK` | `oxide` | +| 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 | 21000 game (default), 21001 query, 21002 RCON, 21003 Rust+, **21004 held for the sidecar's `[web].bind`** | +| 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 password is a generated 24-byte token rather than the old rig's `letmein`, kept out of this +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. @@ -1943,6 +1943,67 @@ string is composed so that whatever the entrypoint prepends still lands on `Rust 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 -- 2.49.1