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.