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