Compare commits
43 Commits
069e715b1b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| af0e86e367 | |||
| 86dc0ac804 | |||
| 5777e0757c | |||
| fb12b18efc | |||
| 37bc06234d | |||
| a39d860123 | |||
| c385de7155 | |||
| 9c46b06172 | |||
| 0de9d7f90d | |||
| 68989c247b | |||
| 5f18097223 | |||
| 46b565ac9b | |||
| 4049abc41a | |||
| f1b8329540 | |||
| 83e57e6f47 | |||
| b7d9872fed | |||
| cb8e8853f6 | |||
| aa148adfc6 | |||
| edff0e3695 | |||
| eea4f958b2 | |||
| de25ed3276 | |||
| a4c4476c3e | |||
| 3b7b9cca4f | |||
| eb4a8bcafc | |||
| 11b8965aa7 | |||
| 15d64b28fe | |||
| 7f008fd1f3 | |||
| 82145d3b4a | |||
| 132205f0f4 | |||
| bba2ab04e0 | |||
| 4fe8864939 | |||
| e35880e713 | |||
| 54b4059091 | |||
| dfdb0a3f63 | |||
| 2f24236993 | |||
| 788100e048 | |||
| a221d4409b | |||
| f80b9f95c8 | |||
| 30e72adfcf | |||
| ddf777fd8c | |||
| 5274d5a744 | |||
| 3c8b430fae | |||
| 22159ec78f |
@@ -85,8 +85,9 @@ share a shape and nothing else, so neither document is a fallback for the other.
|
|||||||
|
|
||||||
| Doc | What it covers |
|
| Doc | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [PROTOCOL.md](rust-link/PROTOCOL.md) | **Canonical** — the game link and the website API, the four declaration sites of the wire version, and what protocol 1 defines |
|
| [PROTOCOL.md](rust-link/PROTOCOL.md) | **Canonical** — the game link and the website API, the four declaration sites of the wire version, and what each protocol version defines: 1 the transport, 2 the read path |
|
||||||
| [INTEGRATION.md](rust-link/INTEGRATION.md) | Standing the bridge up by hand, and which of the three components is wrong when it does not work |
|
| [INTEGRATION.md](rust-link/INTEGRATION.md) | Standing the bridge up by hand, and which of the three components is wrong when it does not work |
|
||||||
|
| [PLAYER_WALK.md](rust-link/PLAYER_WALK.md) | The half of the read path a console cannot reach: ten minutes on a rig with a player, step by step, with what each hook should produce |
|
||||||
|
|
||||||
### `android/`
|
### `android/`
|
||||||
| Doc | What it covers |
|
| Doc | What it covers |
|
||||||
|
|||||||
282
android/PLAN.md
282
android/PLAN.md
@@ -229,7 +229,8 @@ Verified green: `:app:testDebugUnitTest` (18 new JVM tests — notifications DTO
|
|||||||
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
|
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
|
||||||
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
|
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
|
||||||
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
|
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
|
||||||
planned; an on-device delivery pass against a live ntfy is the one open QA item.
|
planned; an on-device delivery pass against a live ntfy is the one open QA item. *(Closed by M16 on
|
||||||
|
2026-09-23: a raid alert delivered through `ntfy.sh` to an emulator, 19 seconds end to end.)*
|
||||||
|
|
||||||
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
|
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
|
||||||
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
|
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
|
||||||
@@ -313,7 +314,10 @@ Work items:
|
|||||||
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
|
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
|
||||||
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
|
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
|
||||||
account — reuse the linked-accounts signal already fetched for M4's player surface
|
account — reuse the linked-accounts signal already fetched for M4's player surface
|
||||||
(`PlayerShardRepository`), not a fresh source of truth. Toggling to a non-empty set triggers the
|
(`PlayerShardRepository`), not a fresh source of truth. *(M16 amends both halves: the signal is now
|
||||||
|
asked of the site's own module by capability (`LinkedAccountRepository`), because on a Rust site
|
||||||
|
`PlayerShardRepository` answered no for everyone. It holds back only switching push **on**, and no
|
||||||
|
longer greys the whole row.)* Toggling to a non-empty set triggers the
|
||||||
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
|
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
|
||||||
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
|
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
|
||||||
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
|
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
|
||||||
@@ -324,7 +328,8 @@ Work items:
|
|||||||
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
|
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
|
||||||
nothing needs decrypting to render the tap, and the target screen fetches fresh over the
|
nothing needs decrypting to render the tap, and the target screen fetches fresh over the
|
||||||
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
|
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
|
||||||
enhancement, not v1.)
|
enhancement, not v1.) *(M16 did it for `notification:<id>` refs: `PushContentResolver` pulls the
|
||||||
|
inbox row and titles the notification with it. Every other ref keeps the generic title.)*
|
||||||
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
|
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
|
||||||
Account), visible once signed in.
|
Account), visible once signed in.
|
||||||
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
|
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
|
||||||
@@ -1307,6 +1312,273 @@ push, and Play (M6–M8) follow the designed app.
|
|||||||
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
||||||
participation history self-scoped, proved by two accounts rather than asserted.
|
participation history self-scoped, proved by two accounts rather than asserted.
|
||||||
|
|
||||||
|
15. **M14 — the Rust module in the app** (post-v1; built 2026-09-17). The platform's **second game
|
||||||
|
module** reached its first public pages in `module-rust` phase 4
|
||||||
|
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §17), and this is phase 5 — the app's leg.
|
||||||
|
R10 has each Android leg trail the website surface it consumes by exactly one phase, so every
|
||||||
|
route here existed and answered before a line of Kotlin was written.
|
||||||
|
|
||||||
|
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §17 for the surface
|
||||||
|
this mirrors and §18 for this phase as built. The contract is normative there; this entry records
|
||||||
|
what the app does about it.
|
||||||
|
|
||||||
|
**No backend work beyond one word.** The five public routes were live. The one change is
|
||||||
|
Module-Rust#5, which adds `rust` to the module's `capabilities` — see the gate below.
|
||||||
|
|
||||||
|
#### Why this is not the shard screens with a different name
|
||||||
|
|
||||||
|
The two games have genuinely different shapes, and collapsing them would have cost the app the
|
||||||
|
thing that makes each legible. **UO is one shard: a place**, five drawer rows, a live SSE stream.
|
||||||
|
**Rust is a fleet**: a list, and one page beneath it with four tabs. The app grows a second route
|
||||||
|
tree rather than a second meaning for `shard/`, and both can be installed on one backend — in
|
||||||
|
which case both trees exist at once and neither row appears on a site without its module.
|
||||||
|
|
||||||
|
| Screen | Route | Reads |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Rust servers** (the list) | `rust` | `GET /public/rust/servers` |
|
||||||
|
| **One server** (four tabs) | `rust/servers/{serverId}` | `…/:id`, `…/:id/events`, `…/leaderboard`, `…/online`, `…/wipes` |
|
||||||
|
|
||||||
|
#### Four decisions, taken by the org lead on 2026-09-16
|
||||||
|
|
||||||
|
- **D16 — the gate is a new capability, `rust`.** `module-uo`'s five shard rows all hang on one
|
||||||
|
string, `shard`, because that is the only question a capability can answer: *is the module
|
||||||
|
there*. `module-rust` declared five and every one named a **surface** — `servers`, `killfeed`,
|
||||||
|
`leaderboard`, `presence`, `wipes`. Core flattens every started module's capabilities into a
|
||||||
|
single list, so gating on `servers` would let another module declaring that word silently reveal
|
||||||
|
these screens on a site that does not run Rust. Gating on the module **id** was considered and
|
||||||
|
rejected: `id` is a mount prefix (§2.1 requires it to equal the directory core loads from), and
|
||||||
|
`MODULE_API.md` §2.9 forbids a client inferring a route from a capability — making the two the
|
||||||
|
same thing would quietly end that separation. So the module declares its own name as a sixth
|
||||||
|
string, asserted in its suite against `module.json`'s own `id` so the two cannot drift.
|
||||||
|
- **D17 — poll every 20s while the screen is RESUMED**, the phone's version of D14's Page
|
||||||
|
Visibility gate. Immediate refresh on return to the foreground; nothing at all while away.
|
||||||
|
- **D18 — the Rust repositories move to `edge`** for the rest of the workstream, with releases at
|
||||||
|
the cutover rather than per phase. `pr-checks.yml` in all four repositories already triggers on
|
||||||
|
`[main, edge]`, so this costs no CI — the trap that made all nine M12 phase PRs land unchecked
|
||||||
|
was closed in engagement Phase 8.
|
||||||
|
- **D19 — the drawer row carries a live player count**, and NavPaths learns `/rust`.
|
||||||
|
|
||||||
|
#### A refresh is not a load, and the app had only ever done loads
|
||||||
|
|
||||||
|
The app has had exactly one shape for a read since M1: set `Loading`, ask, replace. That is right
|
||||||
|
for opening a screen and wrong for a poll — a twenty-second refresh built on it clears the
|
||||||
|
killfeed, renders a spinner in its place and re-fills it, three times a minute, for ever. **The
|
||||||
|
website hit the same wall one tier along**, which is why `module-rust` bundles its own `usePolled`
|
||||||
|
instead of using core's `useAsync` (§17.3). `ui/Polling.kt` is that hook's other half:
|
||||||
|
|
||||||
|
- `refreshInto` — **a refresh is invisible when it succeeds and keeps the rows when it fails.** A
|
||||||
|
failure with rows on screen keeps them and reports the failure beside them; a failure with
|
||||||
|
nothing on screen is an ordinary error with a retry, because there is nothing to protect.
|
||||||
|
- `PollWhileResumed` — `repeatOnLifecycle(RESUMED)`, which buys three behaviours from one line: no
|
||||||
|
requests at all while backgrounded, an immediate refresh on return, and a pause behind a dialog
|
||||||
|
or the recents switcher. `STARTED` would keep polling for a reader who is not reading.
|
||||||
|
|
||||||
|
Only the **visible** live panel is polled. The leaderboard and the wipe list never are: a
|
||||||
|
leaderboard that re-sorted itself under a finger every twenty seconds would be worse than a stale
|
||||||
|
one. Changing the filter, the sort or the wipe **is** a different question, so that panel blanks
|
||||||
|
and loads — leaving the old rows up would show last wipe's killfeed under this wipe's heading.
|
||||||
|
|
||||||
|
#### The drawer badge is D15 translated, not D15 copied
|
||||||
|
|
||||||
|
D15 put a live count in core's `site.footer.status` slot, which works because every page of the
|
||||||
|
website renders the same footer. The app has no footer and no slot. What it has is a drawer row
|
||||||
|
per surface and, since engagement Phase 8, a precedent for a number beside one — the inbox's
|
||||||
|
unread badge, in the `NavigationDrawerItem` badge slot, with a `contentDescription` so a screen
|
||||||
|
reader says "42 players online" rather than "42". The count rides there, and keeps the website
|
||||||
|
version's three rules: **zero renders nothing** (an empty fleet is not a notification), a failed
|
||||||
|
read keeps the last number, and it never polls. It is asked for only where the module is
|
||||||
|
installed, so a UO site makes no request at all.
|
||||||
|
|
||||||
|
#### Verified
|
||||||
|
|
||||||
|
The app suite (**644 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||||
|
against the phase-4 rig — a core with the module installed, one live server and one seeded fixture
|
||||||
|
that has never reported.
|
||||||
|
|
||||||
|
**Both halves of the phase criterion, directly.** With its server unreachable and reading
|
||||||
|
*Offline*, the page still rendered its map, size, seed, wipe date, killfeed, per-wipe and all-time
|
||||||
|
leaderboards, its last known presence board and its wipe history. The same app pointed at the UO
|
||||||
|
core showed Shard / Rules / Atlas / Leaderboards / Market and **no Rust row**.
|
||||||
|
|
||||||
|
Also proven rather than asserted: `refreshInto` against a genuinely dead backend (the core was
|
||||||
|
stopped with the list on screen; a poll tick later the rows were unchanged under one quiet line);
|
||||||
|
R12's arithmetic on a phone (all-time 59 = 41 + 18, and a player who appears only in the older
|
||||||
|
wipe **drops out** of it rather than reading zero); every `describe` branch from real rows,
|
||||||
|
including the fall that must not read as a kill by nobody; the calendar-day rule, filtering to the
|
||||||
|
August wipe and getting three rows six weeks old, each unmistakably dated; and the badge.
|
||||||
|
|
||||||
|
#### The walk found three defects, and 644 green tests found none of them
|
||||||
|
|
||||||
|
- **The drawer's live count resolved once per process.** It was keyed on the capability answer
|
||||||
|
alone, so it was read at connect and never again — which is not what *live* means on a row
|
||||||
|
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge.
|
||||||
|
- **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries
|
||||||
|
no padding of its own; each caller pads its own content, and these four did not. On a phone the
|
||||||
|
first glyph of each line read as clipped.
|
||||||
|
- **A name touched its own kill count.** Five numeric columns beside an equal-weight name column
|
||||||
|
left *Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes,
|
||||||
|
and the **active sort is marked on the header** rather than by tinting a column of numbers — the
|
||||||
|
header is the control, and tinting the values says *these are special* instead of *this is what
|
||||||
|
the table is ordered by*.
|
||||||
|
|
||||||
|
#### The rig note worth keeping
|
||||||
|
|
||||||
|
The debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost` only**
|
||||||
|
— not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||||||
|
`adb reverse tcp:<port> tcp:<port>` and the loopback address; typed as `10.0.2.2` every request
|
||||||
|
fails with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, which the
|
||||||
|
connect screen reports — correctly, and indistinguishably from a core that is not running.
|
||||||
|
|
||||||
|
- **Excluded**, in the same class as every earlier milestone's exclusions: the Rust **admin**
|
||||||
|
surface. Server configuration, the sidecar token and the connection test are admin
|
||||||
|
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
|
||||||
|
and C (phases 8 and 11), and the map is leg D.
|
||||||
|
|
||||||
|
16. **M15 — the player's own Rust account** (post-v1; built 2026-09-22). `module-rust` phase 8, and
|
||||||
|
R10's leg B: identity (phase 6) and the half of site-owned permissions (phase 7) a player is
|
||||||
|
allowed to see. Every route existed and answered before a line of Kotlin was written except one,
|
||||||
|
`GET /player/rust/permissions`, which this phase added on the website for exactly this screen.
|
||||||
|
|
||||||
|
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §22 for the phase as
|
||||||
|
built and its three decisions. The contract is normative there; this entry records what the app
|
||||||
|
does about it.
|
||||||
|
|
||||||
|
#### It is `CharactersScreen` for a different game, deliberately
|
||||||
|
|
||||||
|
The org lead's instruction was to mirror `module-uo`, and the mirror is exact: **one drawer row
|
||||||
|
under the player group**, with the code card at the top of the screen it opens — the same shape
|
||||||
|
UO has had since M4, where the `[link` card sits above the character rosters. Two alternatives
|
||||||
|
were rejected for reasons the mirror makes obvious: a tab under one Rust server (a link is
|
||||||
|
**fleet-wide** — one Steam account is one person on every server, while stats are per server and
|
||||||
|
per wipe), and a section inside core's own Account screen (the app has no slot mechanism, so the
|
||||||
|
module's data would be hard-wired into a core screen).
|
||||||
|
|
||||||
|
| Screen | Route | Reads |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **My Rust account** | `player/rust` | `GET /player/rust/links`, `POST /player/rust/link`, `DELETE …/links/{steamId}`, `GET /player/rust/permissions` |
|
||||||
|
|
||||||
|
#### The gate is `rust`, and `PLAYER` means staff too
|
||||||
|
|
||||||
|
`module-uo`'s five shard rows all hang on `shard`, and this hangs on `rust` for the same reason
|
||||||
|
D16 gave: a capability answers *is the module there*, and core flattens every started module's
|
||||||
|
capabilities into one list, so a surface word like `identity` is not something a row may hang
|
||||||
|
on. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is right here — `/player/rust/*` is
|
||||||
|
`requireAuth` with no role above it, and staff play the game as well.
|
||||||
|
|
||||||
|
#### Two reads, and neither blocks the other
|
||||||
|
|
||||||
|
The accounts and the entitlements load separately and fail separately. That is not tidiness: an
|
||||||
|
entitlement is authored against the **website** account, so it exists before a Steam id does, and
|
||||||
|
the person who has just been given something and has not linked yet is exactly the one who needs
|
||||||
|
to see both halves at once. The screen says so in as many words when nothing is linked.
|
||||||
|
|
||||||
|
**The app does no scope arithmetic.** `*` never reaches a screen: each entry arrives carrying the
|
||||||
|
servers its scope reaches, each already marked *has it* or *waiting*, because a second
|
||||||
|
implementation of that rule is a second thing to keep true.
|
||||||
|
|
||||||
|
#### The refusals stay four pieces of advice
|
||||||
|
|
||||||
|
A refusal is chosen by **status** and rendered from a string resource, the convention every
|
||||||
|
earlier milestone follows (the app is localized; the website's sentence is not). 400 is a spent
|
||||||
|
code, 409 is a Steam account another website account holds — released with `/unlink` in game,
|
||||||
|
never moved silently — 429 is the server's limiter, and 503 is a server that could not be
|
||||||
|
reached. The last of those may **not** say "get a new code": the code is still good, and a player
|
||||||
|
told otherwise goes back to the same unreachable server for another one.
|
||||||
|
|
||||||
|
The known cost, written down rather than discovered later: the website distinguishes three 503s
|
||||||
|
by sentence (one server unreachable, the whole fleet down, no servers configured at all) and the
|
||||||
|
app has one string for the status, written to be true of all three.
|
||||||
|
|
||||||
|
#### Verified
|
||||||
|
|
||||||
|
The app suite (**657 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||||
|
against a core with the module installed and a **live Oxide rig** behind it — the first Rust leg
|
||||||
|
where the backend was talking to a real game server rather than a stand-in.
|
||||||
|
|
||||||
|
Walked directly: the row absent signed-out and absent on a UO site, present for a signed-in
|
||||||
|
player; both reads; a code the live plugin genuinely refused, with its advice rendering **beside
|
||||||
|
the button** rather than at the top of a long form; a rank marked *has it* and a grant marked
|
||||||
|
*waiting* on the same screen, which is what the pushed ledger actually said; and the release.
|
||||||
|
|
||||||
|
#### The walk found one thing the suite did not
|
||||||
|
|
||||||
|
**The row said who, and not when or where.** The website's row has always read "linked just now
|
||||||
|
on rust-oxide"; the app's carried the name and the Steam id and stopped. Neither fact is part of
|
||||||
|
the identity — a link is fleet-wide — but which server minted the code is where a support
|
||||||
|
conversation starts, and the module's own schema says so in a comment.
|
||||||
|
|
||||||
|
- **Excluded**, in the same class as M14's exclusions: the Rust **admin** permission surface.
|
||||||
|
Authoring grants, groups and drift is `requireRole('admin')` on the website, the app has no
|
||||||
|
admin user-detail screen to put it in, and it writes into a running game — the phone is where
|
||||||
|
you read what you hold, not where you decide what somebody else holds.
|
||||||
|
- **No deep link yet.** `/player/rust` is not in the app's web-path table, deliberately:
|
||||||
|
`module-uo`'s player screens are not either, and that table is built from the *public* nav.
|
||||||
|
Phase 10 is when it will matter, because a notification about an entitlement will want
|
||||||
|
somewhere to land.
|
||||||
|
|
||||||
|
#### Amended 2026-09-22 — M14's Online tab and feed stop naming players by default
|
||||||
|
|
||||||
|
Not a milestone of its own: a correction to what M14 shipped, made on the website first
|
||||||
|
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §23). **Nothing names who is online by
|
||||||
|
default** — the module now withholds the Online list, and every feed item that says a named
|
||||||
|
player was on, from anyone below an operator-chosen audience (staff unless widened), while the
|
||||||
|
player **count** stays public.
|
||||||
|
|
||||||
|
The app's part is to never read that as *nobody is on*. `RustOnlineDto` carries `hidden`,
|
||||||
|
`count` and `audience`, and `RustEventListDto` carries `presenceHidden` and `presenceAudience`;
|
||||||
|
the repository and view model keep the whole answer rather than its rows. The Online tab says
|
||||||
|
"2 players online" and who can see the names; the feed says once, above the rows, that joins,
|
||||||
|
deaths and chat are not shown. An older module without the flags decodes as visible, as before.
|
||||||
|
Walked on an emulator: the withheld panel at the default, and the names arriving on the next poll
|
||||||
|
once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer
|
||||||
|
check. Branch `fix/rust-presence-visibility`.
|
||||||
|
|
||||||
|
17. **M16 — a Rust notification on a phone** (post-v1; built 2026-09-23). `module-rust` phase
|
||||||
|
11, R10's leg C: the app half of phase 10's notifications. **Design of record:
|
||||||
|
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §26**, with its four decisions (D69–D72).
|
||||||
|
|
||||||
|
The inbox (engagement phase 8) and the preferences screen (engagement phase 3) are drawn from the
|
||||||
|
wire, so every Rust trigger already arrives as a row, with no app change. This milestone covers
|
||||||
|
the three places where the app still assumed it was talking to a UO shard:
|
||||||
|
|
||||||
|
- **The link check** behind the *Your game account* section asked `module-uo`'s
|
||||||
|
`/player/shard/accounts`. On a Rust site that locks the raid alert in every channel. It will
|
||||||
|
ask the site's own module, chosen by capability (`rust` → `/player/rust/links`). Switching a
|
||||||
|
channel **off** is never gated, and only switching push **on** needs a link.
|
||||||
|
- **A push is titled from the item it points at.** A `notification:<id>` ref is pulled over the
|
||||||
|
authenticated inbox API, and the relay still carries nothing. The per-stream title stays as the
|
||||||
|
fallback and as the lock-screen version. The M7 items that said nothing is
|
||||||
|
fetched to show a notification are amended in place.
|
||||||
|
- **`/player/rust` and `/rust/servers/<id>?tab=<tab>` open natively.** M15 deferred the first
|
||||||
|
of these to exactly this phase.
|
||||||
|
|
||||||
|
It also closes **M7's open QA item**. On-device delivery against a live ntfy has never been
|
||||||
|
walked. It is walked here, on `ntfy.sh` (D72).
|
||||||
|
|
||||||
|
#### As built (2026-09-23)
|
||||||
|
|
||||||
|
Branch `feat/rust-phase-11-notifications`. The app suite is **672 tests, 0 failures**, up from
|
||||||
|
657, with `lintDebug` and `assembleDebug` green. It was walked on the `s22_ultra` emulator
|
||||||
|
against real core on `main`, with the module installed, the protocol-7 sidecar inside the Oxide
|
||||||
|
rig's container, and the relay on `ntfy.sh`:
|
||||||
|
|
||||||
|
- **Linked (raidowner1):** the raid alert's push switched on, and the device registered
|
||||||
|
`https://ntfy.sh/<topic>`. A door raided with phase 10's rig helper reached the phone
|
||||||
|
**19 seconds** later as *"Your base is being raided — A door was destroyed in P16 on Oxide
|
||||||
|
rig."* The notification was `vis=PRIVATE`, with a public version carrying only the generic
|
||||||
|
title. Tapping it opened the inbox, and the row opened the Oxide rig's screen natively.
|
||||||
|
- **Switched off in the app:** push off, and the next raid enqueued in-app only. The inbox row
|
||||||
|
arrived and no system notification was posted. With nothing else on push, the device was
|
||||||
|
unregistered and the push service stopped.
|
||||||
|
- **Not linked (walkadmin):** push held back with the hint, while email and in-app stayed live.
|
||||||
|
In-app was switched off, stored as `off`, and switched back on.
|
||||||
|
- **D71:** a row linking `?tab=leaderboard` opened on the Leaderboard tab, and `/player/rust`
|
||||||
|
opened *My Rust account*. `?tab=clans` went to a Custom Tab.
|
||||||
|
|
||||||
|
**Not walked:** a UO site (no UO core was running; the path is the call it always was, and a test
|
||||||
|
pins it), and the lock-screen rendering (the emulator has no screen lock; the public version was
|
||||||
|
read back from `dumpsys notification`).
|
||||||
|
|
||||||
### Deferred (not a milestone)
|
### Deferred (not a milestone)
|
||||||
|
|
||||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||||
@@ -1529,7 +1801,9 @@ of push.
|
|||||||
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
|
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
|
||||||
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
|
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
|
||||||
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
|
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
|
||||||
that a row exists, and the contract stays wake-and-pull.
|
that a row exists, and the contract stays wake-and-pull. *(M16 decodes it once more, to the id,
|
||||||
|
and only to pull that row over the authenticated inbox API and title the notification with it. It is
|
||||||
|
still never rendered.)*
|
||||||
|
|
||||||
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
|
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
|
||||||
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
|
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
|
||||||
|
|||||||
@@ -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
|
> and wins any disagreement, exactly as uMod does for [`OXIDE_API.md`](OXIDE_API.md). Nothing here
|
||||||
> is a Runic Gateway contract.
|
> is a Runic Gateway contract.
|
||||||
>
|
>
|
||||||
> **Not yet proven on a live Carbon server.** Every claim here is read off metadata or source. This
|
> **Verified on a live Carbon server on 2026-09-15** — Carbon **2.0.259.0** `[2026.09.03.0]` on
|
||||||
> project's own record on that is poor — phases 0 and 1 each found source-read claims a running
|
> Linux, the `rust-carbon` rig (PLAN.md §14.5). Three of the four load-bearing claims held. **One was
|
||||||
> server contradicted — so treat the whole document as *the hypothesis phase 3 tests*, not as
|
> wrong, and it was wrong about Oxide as well as Carbon** — see §4. Corrected in place; §10 is the
|
||||||
> established fact.
|
> scorecard.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,6 +57,8 @@ compiler simply evaluates the unknown symbol as false.
|
|||||||
| Symbol | Meaning |
|
| Symbol | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `CARBON` | The framework is Carbon |
|
| `CARBON` | The framework is Carbon |
|
||||||
|
| `RUST` | The game is Rust |
|
||||||
|
| `OXIDE_PUBLICIZED` | Compiled against publicised Oxide assemblies |
|
||||||
| `WIN`, `UNIX` | Host operating system |
|
| `WIN`, `UNIX` | Host operating system |
|
||||||
| `STAGING`, `AUX01`, `AUX02` | Rust branch |
|
| `STAGING`, `AUX01`, `AUX02` | Rust branch |
|
||||||
| `RUST_ABV_<v>`, `RUST_BLW_<v>`, `RUST_IS_<v>` | Rust protocol above / below / exactly `<v>` |
|
| `RUST_ABV_<v>`, `RUST_BLW_<v>`, `RUST_IS_<v>` | Rust protocol above / below / exactly `<v>` |
|
||||||
@@ -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
|
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.
|
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
|
### At run time
|
||||||
|
|
||||||
`#if` is decided when the file is compiled, which is what we want for API differences. Where a
|
`#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
|
Every member R2 depends on exists with the same name and the same argument shape
|
||||||
(`Carbon.Common/src/Oxide/Libraries/Permissions.cs`): `RegisterPermission`, `PermissionExists`,
|
(`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.
|
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
|
**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
|
`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
|
[§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
|
Carbon's signature also takes `BaseHookable` where Oxide's takes `Plugin`. Passing `this` is
|
||||||
correct on both; a variable typed `Plugin` is not.
|
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
|
### The store — **this section was wrong, and the truth is worse**
|
||||||
`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
|
> **Corrected 2026-09-15 against both live rigs.** This document previously said *"Oxide persists to
|
||||||
permanently**, which is worth stating once. Drift detection reads the API, or it does not work.
|
> 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
|
**Carbon does give R2 something Oxide's docs do not advertise: fourteen permission hooks**, a
|
||||||
`Permissions` category of its own — `OnUserPermissionGranted`, `OnUserPermissionRevoked`,
|
`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` |
|
| `oxide.plugins` | `c.plugins` |
|
||||||
|
|
||||||
Carbon can be configured to alias the old prefix, so an operator's muscle memory survives — but an
|
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
|
**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
|
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 |
|
| Everything else | Unchanged |
|
||||||
|
|
||||||
The honest summary: **Carbon costs three amendments and one extra rig, not a second codebase.**
|
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.
|
||||||
|
|||||||
3164
modules/rust/PLAN.md
3164
modules/rust/PLAN.md
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ differ.
|
|||||||
| [`DEFINITIONS.md`](DEFINITIONS.md) | **What things are called.** 678 items (short name, id, display name) and 2,590 workshop skin ids across 104 items. |
|
| [`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. |
|
| [`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. |
|
| [`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.** |
|
| [`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, and **proven on a live Carbon 2.0.259.0 server** — R19 at phase 0, and the whole read path at phase 3. |
|
||||||
|
|
||||||
**The one file here that is ours:** [`PLAN.md`](PLAN.md) — the schedule and the decisions of record
|
**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
|
for actually building `module-rust`. Everything else in this directory is copied from uMod; that one
|
||||||
|
|||||||
135
rust-link/INSTALL_RIG.md
Normal file
135
rust-link/INSTALL_RIG.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# The sidecar inside the game container — the Pterodactyl rig recipe
|
||||||
|
|
||||||
|
**Added 2026-09-22, during phase 8.** It is written here rather than in a phase section because it
|
||||||
|
is not a phase: it is how the rigs are wired from now on, and it is the shape
|
||||||
|
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) R20 says the **egg** ships in phase 18.
|
||||||
|
|
||||||
|
Until now the rigs ran the sidecar on a development machine and the game on the Pterodactyl node,
|
||||||
|
which meant the plugin had to dial *out across a LAN* to reach it. That contradicts D2 — the game
|
||||||
|
link is loopback and carries no token, precisely because it is not supposed to leave the host — and
|
||||||
|
it is why the acceptance line of phases 6, 7 and 7b each ended at a firewall rule.
|
||||||
|
|
||||||
|
**The fix is not a firewall rule. It is putting the sidecar where the design always said it lives.**
|
||||||
|
A container's `127.0.0.1` is genuinely private, so a stock plugin config and a stock sidecar find
|
||||||
|
each other with nothing configured at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What goes on the volume
|
||||||
|
|
||||||
|
Two files under `/home/container/rust-link/`, plus whatever the sidecar writes beside them:
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `rust-link-sidecar` | A **statically linked** Linux binary (`x86_64-unknown-linux-musl`), `chmod 755`. Static because the game image is not ours and its glibc is not a contract |
|
||||||
|
| `with-sidecar.sh` | The launcher below, `chmod 755` |
|
||||||
|
| `sidecar.toml` | Written by the sidecar itself on first run, with a generated token. Env overrides it |
|
||||||
|
| `rust-link.db` | The store. **It must never appear in the egg's `REMOVE_FILES`** — R12 keeps all-time rollups across wipes, and a swept store is the failure that looks like success |
|
||||||
|
|
||||||
|
Building the binary needs no Rust toolchain on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$PWD/rust-link/sidecar:/src" -v "$PWD/out:/out" rust:1-slim-bookworm bash -c '
|
||||||
|
apt-get update -qq && apt-get install -y -qq musl-tools >/dev/null
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
cd /src && CARGO_TARGET_DIR=/build cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
cp /build/x86_64-unknown-linux-musl/release/rust-link-sidecar /out/'
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload both files with the panel's **client** API (`POST /api/client/servers/{id}/files/write`,
|
||||||
|
raw body — it creates missing parent directories), then `files/chmod` them. **Chmod one file per
|
||||||
|
call:** a two-entry `files` array applied only the first, silently, on this panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The launcher
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
RL=/home/container/rust-link
|
||||||
|
mkdir -p "$RL"
|
||||||
|
|
||||||
|
export RUSTLINK_CONFIG="$RL/sidecar.toml"
|
||||||
|
: "${RUSTLINK_DB_PATH:=$RL/rust-link.db}"
|
||||||
|
export RUSTLINK_DB_PATH
|
||||||
|
|
||||||
|
for v in RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS; do
|
||||||
|
eval "val=\${$v-}"
|
||||||
|
if [ -n "$val" ]; then export "$v"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
env -u LD_PRELOAD "$RL/rust-link-sidecar" >> "$RL/sidecar.log" 2>&1 &
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things in it are load-bearing, and each is a thing that went wrong first:
|
||||||
|
|
||||||
|
- **`env -u LD_PRELOAD` for the sidecar.** Carbon's entrypoint prepends
|
||||||
|
`LD_PRELOAD=$(pwd)/libdoorstop.so` to the **whole** startup string, so without this the Mono
|
||||||
|
preloader is injected into a static Rust binary that has never heard of it. RustDedicated still
|
||||||
|
inherits it from this script's environment and still boots modded — which is the composition R20
|
||||||
|
left unsettled, and this is the answer.
|
||||||
|
- **`exec "$@"` for the game.** The game *becomes* this process, so the panel console keeps its
|
||||||
|
stdin and stdout and **stop still stops the server** — which then takes the sidecar down with the
|
||||||
|
container. A `wait` here instead would leave the panel talking to a shell.
|
||||||
|
- **An unset variable is never exported.** The sidecar's precedence is env > file > default, and
|
||||||
|
exporting `RUSTLINK_WEB_TOKEN=""` would override a perfectly good token in `sidecar.toml` with
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
## The startup command
|
||||||
|
|
||||||
|
The launcher is a **prefix** on the egg's own startup, with the sidecar's settings in front of it
|
||||||
|
the way egg variables will supply them in phase 18 (R22):
|
||||||
|
|
||||||
|
```
|
||||||
|
RUSTLINK_WEB_BIND=0.0.0.0:<sidecar allocation> RUSTLINK_SERVER_ID=<server id> \
|
||||||
|
RUSTLINK_WEB_TOKEN=<token> ./rust-link/with-sidecar.sh <the egg's unchanged startup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Set it with the **application** API (`PATCH /api/application/servers/{id}/startup`, sending the
|
||||||
|
server's existing `environment`, `egg` and `image` back unchanged with `skip_scripts: true`).
|
||||||
|
|
||||||
|
**Shell operators cannot be used here.** The image's entrypoint runs the startup string through
|
||||||
|
`eval echo` before handing it to `node /wrapper.js`, so an `&` in it would background the *eval*
|
||||||
|
and a quoted sub-shell would lose its quotes. A wrapper program that `exec`s the rest is the shape
|
||||||
|
that survives that, which is why the launcher takes the game command as arguments rather than
|
||||||
|
containing it.
|
||||||
|
|
||||||
|
## Wiring the website to it
|
||||||
|
|
||||||
|
The sidecar's web API is on the **second allocation**, so from the site it is
|
||||||
|
`http://<node ip>:<sidecar allocation>` with the token above — the ordinary Admin → Rust server row,
|
||||||
|
no tunnel and no rule. `POST /admin/rust/servers/{id}/test` should answer with
|
||||||
|
`plugin_connected: true` and the protocol version.
|
||||||
|
|
||||||
|
The plugin needs **no configuration**: `oxide/config/RunicGateway.json`'s defaults
|
||||||
|
(`127.0.0.1:7799`) are already right, which is the clearest statement of why the sidecar belongs in
|
||||||
|
the container.
|
||||||
|
|
||||||
|
## What it proved, first time
|
||||||
|
|
||||||
|
On `rust-oxide` (egg 18, `ghcr.io/pterodactyl/games:rust`), from a cold start:
|
||||||
|
|
||||||
|
```
|
||||||
|
web server listening addr=0.0.0.0:21004
|
||||||
|
plugin connected peer=127.0.0.1:51148
|
||||||
|
{"kind":"server.hello","protocol":5,"serverId":"rust-oxide",...}
|
||||||
|
```
|
||||||
|
|
||||||
|
— the sidecar bound its allocation **29 seconds** before the world had finished generating, and the
|
||||||
|
plugin found it on loopback as soon as Oxide loaded. `/health` from another machine on the LAN
|
||||||
|
answered `plugin_connected: true`.
|
||||||
|
|
||||||
|
## Still open for phase 18
|
||||||
|
|
||||||
|
- **The egg's own variables.** `RUSTLINK_*` are not egg variables yet, so they live in the startup
|
||||||
|
string on the rigs. Pterodactyl rejects environment keys an egg does not declare, which is exactly
|
||||||
|
what R22's variable block is for.
|
||||||
|
- **Carbon.** The launcher is written for it and the reasoning above is specific about why, but at
|
||||||
|
the time of writing it has run on the Oxide rig only. The two rigs cannot be up at once on this
|
||||||
|
node, so this is a walk to run, not a claim to repeat.
|
||||||
|
- **The framework is reinstalled on every boot** (Carbon from `production_build`, Oxide from
|
||||||
|
`releases/latest`), so a restart is a framework upgrade and neither is pinnable. Unchanged by any
|
||||||
|
of this, and still the reason a rig can differ from itself between two runs.
|
||||||
@@ -164,6 +164,17 @@ Two failures that look alike and are not:
|
|||||||
says what was true when it was written; nothing has written it since. Either the poll is failing
|
says what was true when it was written; nothing has written it since. Either the poll is failing
|
||||||
(the website's log) or the sidecar stopped (its own).
|
(the website's log) or the sidecar stopped (its own).
|
||||||
|
|
||||||
|
### 3.0 `untyped_frames` on `/health` is not zero
|
||||||
|
|
||||||
|
**The plugin and the sidecar are on different protocol versions.** The game link has no handshake
|
||||||
|
to catch that at connect time (`PROTOCOL.md` §2), so it shows up here instead: the sidecar files a
|
||||||
|
frame by its `type`, a frame from the wrong version does not carry one it recognises, and it is
|
||||||
|
dropped and counted rather than guessed at.
|
||||||
|
|
||||||
|
The symptom without this counter is the confusing one — a game server plainly up, a sidecar plainly
|
||||||
|
healthy, and a website showing nothing. Check the plugin's `rg.link` (it prints its protocol) against
|
||||||
|
the sidecar's `/health` (which prints its own) and upgrade whichever is behind.
|
||||||
|
|
||||||
### 3.1 The failures that are supposed to happen
|
### 3.1 The failures that are supposed to happen
|
||||||
|
|
||||||
Three things look like breakage and are the design:
|
Three things look like breakage and are the design:
|
||||||
@@ -193,6 +204,92 @@ you get a warning naming both ids.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4.1 What the bridge sends, and how much of it is kept
|
||||||
|
|
||||||
|
From protocol 2 the plugin sends the read path: connects and disconnects, deaths, chat, gathering,
|
||||||
|
bans and reports, and the wipe. Two things about the volume are worth knowing before you size
|
||||||
|
anything.
|
||||||
|
|
||||||
|
**Gathering and NPC kills are counted, not forwarded.** `OnDispenserGather` fires on every swing at
|
||||||
|
a tree; sending one frame per swing would make the bridge the most expensive thing on the server. The
|
||||||
|
plugin keeps a per-player tally and flushes it once a minute as a single `player.tally` frame. So the
|
||||||
|
leaderboard is exact and the wire is quiet.
|
||||||
|
|
||||||
|
**The sidecar's history is bounded; the website's is not.** `[store].retain_days` (default 14) is
|
||||||
|
how long the sidecar keeps raw events. The permanent record — per-wipe totals that survive a wipe —
|
||||||
|
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
|
||||||
|
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
|
||||||
|
|
||||||
|
**From protocol 3 your players can link their Steam account.** In game they type `/link` and the
|
||||||
|
server answers them privately with a six-character code; on the website they type that code in
|
||||||
|
within five minutes and the two are joined. Nothing about the link is stored on the game host — the
|
||||||
|
website owns the record, and `/unlink` in game asks it to let go.
|
||||||
|
|
||||||
|
Two things an operator should know about it:
|
||||||
|
|
||||||
|
- **The code is never in a frame.** It reaches the player and nobody else, which is what makes typing
|
||||||
|
it into a signed-in browser proof that they are the one who asked. What crosses the bridge is
|
||||||
|
`account.link.requested`, a staff-visible note that somebody asked.
|
||||||
|
- **A Steam account can belong to one website account at a time, across your whole fleet.** A code
|
||||||
|
from any of your servers links for all of them. If somebody links the wrong account the site
|
||||||
|
refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in
|
||||||
|
the admin panel.
|
||||||
|
|
||||||
|
**From protocol 4 the website owns your permissions.** Groups and grants are written in
|
||||||
|
Admin → Rust permissions and pushed into this server's own Oxide/Carbon permission store, so every
|
||||||
|
plugin you already run honours them — Kits, ZoneManager, anything that calls `UserHasPermission`.
|
||||||
|
Nothing is required of those plugins and nothing is configured twice.
|
||||||
|
|
||||||
|
Four things an operator should know about it, because each looks like something else from the game
|
||||||
|
side:
|
||||||
|
|
||||||
|
- **A wipe does not lose them.** The site re-pushes the whole set when the server comes back. If your
|
||||||
|
wipe script clears `oxide/data/`, the permissions the site authored are back within a minute of the
|
||||||
|
server being up; ones granted at the console are not, because nothing remembers those.
|
||||||
|
- **Granting at the console still works, and the website notices.** A hand edit is reported as
|
||||||
|
drift on that screen and is **never** undone on its own — an operator is offered two answers to
|
||||||
|
it: adopt it, so the site maintains it from then on, or revoke it. That is deliberate: a console
|
||||||
|
grant during an incident must survive the next sync.
|
||||||
|
- **A permission no loaded plugin has registered cannot be granted.** Oxide's own API silently does
|
||||||
|
nothing for an unknown name, so the site checks first and reports the name as unresolved instead
|
||||||
|
of claiming a privilege nobody has. Load the plugin and the grant lands by itself.
|
||||||
|
- **A player who has never connected to that server can hold a grant but cannot be in a group.**
|
||||||
|
The store has no record of them to put in a group yet; the site says which memberships are waiting
|
||||||
|
and they land on that player's first connection.
|
||||||
|
|
||||||
|
`rg.perms` at the server console prints what the last sync did, which is the fastest way to tell
|
||||||
|
"that permission does not exist here" from "that player has never been seen here".
|
||||||
|
|
||||||
|
**From protocol 5 you can edit your plugins' settings from the website**, in Admin → Rust mod
|
||||||
|
config. It reads the configuration directory your framework actually uses — `oxide/config` or
|
||||||
|
`carbon/configs`, or wherever you moved it — and generates a form from the values it finds, so it
|
||||||
|
works for whatever you have installed. Four things worth knowing before you use it:
|
||||||
|
|
||||||
|
- **A save reloads the plugin and watches the reload.** If the plugin does not come back within four
|
||||||
|
seconds, the old file is **restored automatically** and the site shows you the log line that says
|
||||||
|
why. A typo costs you a few seconds, not a plugin.
|
||||||
|
- **Your data directory is not listed, deliberately.** `oxide/data` (or `carbon/data`) holds live
|
||||||
|
state — kit cooldowns, zone definitions, the permission store itself — not settings. Editing it
|
||||||
|
from a web form edits your players' cooldowns, and a running plugin overwrites the change on its
|
||||||
|
next save anyway.
|
||||||
|
- **Which plugin gets reloaded is your choice, with a guess filled in.** A folder name is
|
||||||
|
convention, not contract, so the site suggests one and lets you change it. The suggestion is right
|
||||||
|
nearly always and wrong silently when it is wrong, which is why it is a field rather than an
|
||||||
|
assumption.
|
||||||
|
- **This bridge's own `Host`, `Port` and `ServerId` are read-only there.** Changing them from the
|
||||||
|
website would cut the link carrying the change, or strand every row the site holds for this
|
||||||
|
server. Edit them on the host; everything else in that file is editable from the site.
|
||||||
|
|
||||||
|
`rg.config` at the server console prints which directory the site is reading and what the last write
|
||||||
|
from it did.
|
||||||
|
|
||||||
|
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
|
||||||
|
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
|
||||||
|
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
||||||
|
`REMOVE_FILES`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. Upgrading
|
## 5. Upgrading
|
||||||
|
|
||||||
The four declaration sites in [`PROTOCOL.md` §2](PROTOCOL.md#2-versioning) must agree. In practice
|
The four declaration sites in [`PROTOCOL.md` §2](PROTOCOL.md#2-versioning) must agree. In practice
|
||||||
|
|||||||
329
rust-link/PLAYER_WALK.md
Normal file
329
rust-link/PLAYER_WALK.md
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# The player walk — proving the half of the read path a console cannot reach
|
||||||
|
|
||||||
|
Protocol 2's catalogue divides cleanly in two, and the line is not about importance: it is about
|
||||||
|
whether a hook can fire without somebody holding a mouse.
|
||||||
|
|
||||||
|
Everything in the first half was proven from a console and a REST client while phase 3 was built —
|
||||||
|
the boards, the wipe id, the envelope, bans, the server lifecycle. Everything below needs a **real
|
||||||
|
player on a real server**, because the hooks carry a `BasePlayer`, a `HitInfo` or a chat line, and
|
||||||
|
none of those three can be manufactured from a console without becoming a different test.
|
||||||
|
|
||||||
|
This document is the walk that closes it. It takes about ten minutes, it is the same on Oxide and on
|
||||||
|
Carbon, and it is written so that the answer is readable afterwards rather than watched live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
1. A rig running, with `RunicGateway.cs` loaded — `oxide.plugins` (or `c.plugins`) lists *Runic
|
||||||
|
Gateway*, and `rg.link` answers `connected=True`.
|
||||||
|
2. A sidecar the rig can reach, with its store **empty** — that is what makes the event list at the
|
||||||
|
end readable as a transcript of the walk and nothing else.
|
||||||
|
3. The sidecar's token to hand, for the reads at the bottom.
|
||||||
|
|
||||||
|
Run this once, before you join:
|
||||||
|
|
||||||
|
```
|
||||||
|
rg.hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
Every player hook should read **silent**. That is the baseline: the point of the walk is to move
|
||||||
|
them, and starting from a run where some already fired proves less.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The walk
|
||||||
|
|
||||||
|
Do these in order. The order matters only in two places, noted where it does.
|
||||||
|
|
||||||
|
| # | Do this | Fires | The frame should carry |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Join the server** | `CanUserLogin`, `OnUserApproved`, `OnPlayerConnected` | Three frames, in that order. The first two carry your **IP address** — check it is a real address and not the string `0`. `player.connected` carries your steam id and name |
|
||||||
|
| 2 | **Wake up / spawn in** (click Respawn if you are dead) | `OnPlayerRespawned` | `player.respawned`, steam id only. It does **not** fire if you simply wake from sleeping — that is the hook's own documented behaviour, so no frame here is a pass, not a failure |
|
||||||
|
| 3 | **Say something in chat**, then **say something in team chat** if you have a team | `OnPlayerChat` | Two `player.chat` frames, with `channel` reading `Global` and `Team`. The message must arrive whole — if it is truncated or the frame is missing, the flattener ate it |
|
||||||
|
| 4 | **Chop a tree for about twenty seconds**, then **mine a node** | `OnDispenserGather` | **Nothing immediately.** This is the aggregate: one `player.tally` frame within 60 seconds, carrying `gathered` with `wood` and `stones`, summed. Seeing a frame per swing would be the bug |
|
||||||
|
| 5 | **Kill an animal or a scientist** | `OnEntityDeath` | Again nothing immediately — `npcKills` on the next `player.tally`. No `player.death`: a chicken is not a killfeed entry |
|
||||||
|
| 6 | **Die to the environment** — fall damage is easiest | `OnPlayerDeath` | `player.death` with `attackerType: "environment"`, a `grid` like `H7`, and **no** `attackerId`. Check the grid against the map: a wrong sign in the row arithmetic mirrors the whole map, and only a human with the map open can see that |
|
||||||
|
| 7 | **Kill yourself** — `kill` in the F1 console | `OnPlayerDeath` | `attackerType: "self"`, no `attackerId` |
|
||||||
|
| 8 | **If a second player is available**: kill each other once | `OnPlayerDeath` | `attackerType: "player"`, with `attackerId`, `attackerName`, a `weapon` shortname and a `distance` in metres. This is the killfeed's whole shape, and it is the one row phase 4's page is built from |
|
||||||
|
| 9 | **Build a foundation, then destroy it yourself** | `OnEntityDeath` | `entity.destroyed` with `ownerId` (yours), `prefab`, `grid` and `attackerId`. Decay must **not** produce one of these — only a player breaking it |
|
||||||
|
| 10 | **Disconnect** | `OnPlayerDisconnected` | `player.disconnected` with a `reason` and a **`sessionSec`** roughly equal to how long you were on. It also flushes your tally first, so any gathering since the last minute arrives immediately before it |
|
||||||
|
|
||||||
|
Two ordering notes: step 4 must come before step 10 by at least a minute if you want to see the
|
||||||
|
cadence flush rather than the disconnect flush, and step 1's three frames are the only place the
|
||||||
|
order between hooks is itself part of the answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading the result
|
||||||
|
|
||||||
|
From the machine running the sidecar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=… # [web].auth_token from sidecar.toml, or `--print-config`
|
||||||
|
BASE=http://127.0.0.1:8090
|
||||||
|
|
||||||
|
# The whole walk, oldest first, as a transcript.
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/feed?since=0&limit=500" \
|
||||||
|
| python -m json.tool
|
||||||
|
|
||||||
|
# Or one kind at a time.
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/events?kind=player.death&limit=20"
|
||||||
|
```
|
||||||
|
|
||||||
|
And from the game console:
|
||||||
|
|
||||||
|
```
|
||||||
|
rg.hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
Every hook in the walk should now read **fired**, with a count. A hook still `silent` after the step
|
||||||
|
that should have fired it is the finding — and on Carbon it is the specific question
|
||||||
|
[`CARBON.md`](../modules/rust/CARBON.md) §6 asks, since Carbon's catalogue omits thirteen uMod names
|
||||||
|
and nobody has yet checked whether they are renames or holes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What counts as a pass
|
||||||
|
|
||||||
|
Not "frames arrived". Three things, and the third is the one worth slowing down for:
|
||||||
|
|
||||||
|
1. **Every hook in the table fired**, on both frameworks, from the same plugin file.
|
||||||
|
2. **Every frame carries the envelope** — `type`, `serverId` and `wipeId` on all of them
|
||||||
|
([`PROTOCOL.md`](PROTOCOL.md) §8.1). A player frame without a `wipeId` cannot be attributed to a
|
||||||
|
wipe and its rollup is lost.
|
||||||
|
3. **The aggregates are aggregates.** `player.tally` is a delta since the last flush, so two minutes
|
||||||
|
of chopping is two frames that sum to the total, not two frames each carrying the total. Getting
|
||||||
|
this backwards makes every leaderboard roughly double, and it looks correct until somebody counts.
|
||||||
|
|
||||||
|
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
||||||
|
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The identity walk (protocol 3, phase 6)
|
||||||
|
|
||||||
|
Added 2026-09-21, and here for the same reason as everything above: **a link code reaches a player
|
||||||
|
and nobody else**, so no console can read one. The site's own half was walked in a browser — the
|
||||||
|
refusals, the admin panel, staff unlink, the rate limit — and what needs a person in game is the
|
||||||
|
three steps below.
|
||||||
|
|
||||||
|
It takes two minutes, and it wants **two website accounts** — one you will link, one you will try to
|
||||||
|
link the same Steam account to.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **In game, type `/link`** | A private reply with a six-character code and a five-minute deadline. Check it is private: a second player on the server must not see it. The code has **no O, 0, I or 1** in it — those glyphs are not in the alphabet, so one in your code is a finding |
|
||||||
|
| 2 | **Type `/link` again straight away** | *"Please wait a moment…"* — the thirty-second cooldown. The first code is now dead either way: a new request drops the old one, so only the newest ever works |
|
||||||
|
| 3 | **On the website, sign in and open `/player/rust`. Type the code** | The account appears, named as the game knows you, with the server it came from. Try the same code again: *"That code is unknown or has expired"* — it works once |
|
||||||
|
| 4 | **Sign in as the SECOND account and type a fresh code for the same Steam account** | Refused, naming the account that holds it: *"That Steam account is already linked to <name>. Run /unlink in game to release it."* The link must **not** move — it is what phase 7 grants permissions against |
|
||||||
|
| 5 | **In game, type `/unlink`** | The site's row disappears within one ingest tick (five seconds by default). Reload `/player/rust` to confirm — this is the frame arriving over the feed, not the page asking |
|
||||||
|
| 6 | **Type a code from a server whose sidecar you have just stopped** | *"One of the servers could not be reached… your code is still good — try again in a minute."* Distinct from step 3's refusal, and the distinction is the point: the code is fine and fetching another one would not help |
|
||||||
|
|
||||||
|
Step 6 needs a fleet of two, one of them down; on a single-server rig it reads *"The game servers are
|
||||||
|
unreachable right now"* instead, which is the same rule with nothing left to be unsure about.
|
||||||
|
|
||||||
|
**What counts as a pass here:** the code never appears anywhere but in front of the player who asked
|
||||||
|
for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame
|
||||||
|
carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website
|
||||||
|
account at a time, and every refusal is a sentence that tells the player what to do next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The permission walk (protocol 4, phase 7)
|
||||||
|
|
||||||
|
Added 2026-09-21. The website half was walked end to end against a stand-in plugin — the authoring
|
||||||
|
screen, the report, drift and its two answers, and a restart that emptied the store and was fully
|
||||||
|
re-pushed. **What is left is the sentence the phase exists for: a grant made on the website gates a
|
||||||
|
third-party plugin in the game.**
|
||||||
|
|
||||||
|
It cannot be walked from a console, and it cannot be walked on the owner's account:
|
||||||
|
|
||||||
|
- **A console session bypasses every gate.** The standard idiom is
|
||||||
|
`return !player || permission.UserHasPermission(...)`, and an RCON command has no `BasePlayer` —
|
||||||
|
so the console is unconditionally allowed ([PLAN.md §12.5](../modules/rust/PLAN.md)).
|
||||||
|
- **An admin account bypasses most plugins' gates too**, and not uniformly: Popup Notifications
|
||||||
|
(`player.IsAdmin ||`) and Zone Manager (`authLevel > 0 ||`) are hard bypasses. Kits is the
|
||||||
|
exception — its `IsAdmin` is the `kits.admin` **permission** and `AdminIgnoreRestrictions`
|
||||||
|
defaults to `false` — so a kit's `RequiredPermission` does apply to a server owner.
|
||||||
|
|
||||||
|
So this walk wants a **second, non-admin Steam account** connected to the rig. Kits alone can be
|
||||||
|
walked on the owner's account; steps 4 and 5 cannot.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Link the second account** (the identity walk above), then on the website open Admin → Rust permissions and grant it a kit's `RequiredPermission` — pick the kit from `GetKitNames`, or read one out of `oxide/config/Kits.json` | The grant appears with the account beside it. Within a minute the server row reads **in sync** — or press *Sync now* and watch it happen |
|
||||||
|
| 2 | **In game on that account, open the kit menu** | The kit is no longer locked. Before the grant it shows as locked; that difference is the whole phase |
|
||||||
|
| 3 | **At the server console, `oxide.show user <steamid>`** | The permission is there, granted by this plugin rather than by hand |
|
||||||
|
| 4 | **At the console, `oxide.grant user <steamid> zonemanager.admin`** (a permission the site manages but did not grant) | Within seconds the website's screen shows it under *Changed in game*. **Revoke** it there, and it is gone from `oxide.show user` on the next sync. **Adopt** a different one instead and it stays, now listed as the site's own |
|
||||||
|
| 5 | **Put the second account in a group on the website, then wipe or restart the server** (a wipe script that clears `oxide/data/` is the interesting case) | After the server is back: the group exists again, the membership is back, and the grant is back — without anybody touching the website. This is R2's central promise and the one thing a stand-in cannot prove |
|
||||||
|
| 6 | **Grant a permission whose plugin you have just unloaded** | The site reports it **unresolved** against that server and keeps the grant. Load the plugin again: it lands on the next sync, with nothing typed |
|
||||||
|
| 7 | **Add a website account that has never connected to this server to a group** | The site reports the membership as *waiting on their first connection*. Have them connect: it lands. A **direct grant** to the same account, by contrast, is in `oxide.show user` immediately |
|
||||||
|
|
||||||
|
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
|
||||||
|
this phase has two specific things to confirm there rather than assume:
|
||||||
|
|
||||||
|
- **`GetPermissionUsers` / `GetUsersInGroup` entry format.** Both answer `id(name)`, and the spacing
|
||||||
|
differs between the calls and between the frameworks. The plugin takes everything before the first
|
||||||
|
bracket. If that parse is wrong, **every holder is reported as foreign** — which is visible
|
||||||
|
immediately: the drift list fills with grants the site itself made.
|
||||||
|
- **`GetGroupPermissions(name, false)`** is called with both arguments. If Carbon's signature has no
|
||||||
|
second parameter, the plugin does not compile there at all — the one place in protocol 4 where
|
||||||
|
R19's byte-identical-plugin claim is at risk.
|
||||||
|
|
||||||
|
**What counts as a pass:** a non-admin player's access in game changes because of something typed on
|
||||||
|
the website and nothing else; a hand edit is reported rather than undone; and a wipe costs the
|
||||||
|
operator nothing.
|
||||||
|
|
||||||
|
> **Run this walk on protocol 9 or later — before that, steps 1–3 could not pass on Oxide.** From
|
||||||
|
> phase 7 until phase 13a the plugin granted with itself as the *owner*, and Oxide silently ignores a
|
||||||
|
> grant whose owner did not register the permission — which is every permission this walk uses. The
|
||||||
|
> site showed the server **in sync** and `oxide.show` showed **nothing**; the phase 13a walk
|
||||||
|
> reproduced exactly that before fixing it ([PLAN.md §28.6](../modules/rust/PLAN.md), step 6). Since
|
||||||
|
> protocol 9 the plugin reads every grant back, and one that did not land is listed on the
|
||||||
|
> permissions screen as *sent and not found in the game's permission store* instead of being counted.
|
||||||
|
> If you see that warning on this walk, it is a finding.
|
||||||
|
|
||||||
|
## The configuration walk (protocol 5, phase 7b)
|
||||||
|
|
||||||
|
Added 2026-09-22. The website half was walked end to end against a real sidecar and a stand-in
|
||||||
|
plugin over a real directory of real config files — the recursive walk, a form save, a rollback, a
|
||||||
|
refusal, a version conflict and the locked keys — and the plugin half **compiles and loads on the
|
||||||
|
live Oxide rig**, where `rg.config` answers
|
||||||
|
`protocol=5 framework=oxide root=/home/container/oxide/config`.
|
||||||
|
|
||||||
|
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
|
||||||
|
in the running game.** It needs the sidecar and the game server on **one host**, because the game
|
||||||
|
link is loopback by design (D2).
|
||||||
|
|
||||||
|
**Since 2026-09-22 the rigs have that**, and it is no longer a firewall rule on anybody's
|
||||||
|
development machine: the sidecar runs **inside the game container** on the Pterodactyl rigs, which
|
||||||
|
is the shape phase 18's egg ships. The recipe is in [`INSTALL_RIG.md`](INSTALL_RIG.md); a stock
|
||||||
|
plugin config (`127.0.0.1:7799`) and a stock sidecar need no configuration at all to find each
|
||||||
|
other, which is the whole point of putting them in one container.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Open Admin → Rust mod config** and pick the server | The tree the framework actually uses — `oxide/config` on Oxide, `carbon/configs` on Carbon — grouped by plugin, with every loaded plugin's version beside it |
|
||||||
|
| 2 | **Open `ZoneManager.json`, change a setting, leave the reload target on its guess, and save** | "Saved, and the plugin reloaded." At the console, `oxide.show`/`c.show` is irrelevant — the proof is the plugin behaving differently, so pick a setting you can see: `Auto Show Zones`, or an entry message |
|
||||||
|
| 3 | **Check a float nobody touched**, e.g. a rate ending `.0`, in the file on the host | It is still `1.0`, not `1`. This is the trap the whole editor exists for, and a server whose configs are full of whole-numbered floats is where it bites |
|
||||||
|
| 4 | **Break a config on purpose** — in Raw JSON, give a numeric field a string, or anything the plugin's own class cannot deserialize — and save with that plugin as the reload target | Within about four seconds: *"The plugin did not come back, so the old file was put back automatically"*, the compiler's own line underneath it, and the file on the host back as it was. `oxide.plugins` shows the plugin **loaded** — because the restore was reloaded too |
|
||||||
|
| 5 | **Save a nested file** (`Kits/kits.json`, or any `config/<Mod>/x.json`) **and confirm the reload target** | The right plugin reloads. Reloading the wrong one is the failure this field exists to prevent, and it reports success — so check `oxide.plugins`' timestamps, not the website's word |
|
||||||
|
| 6 | **Open the bridge's own config** | `Host`, `Port` and `ServerId` are read-only with the reason; `QueueCap` saves; the reload dropdown does not offer this plugin. The save says it was written and **not** reloaded, which is the honest answer — our settings apply on the next deliberate reload |
|
||||||
|
| 7 | **Edit a file on the host over SSH while the website has it open, then save from the website** | A conflict, with the current file offered — never an overwrite |
|
||||||
|
| 8 | **Ask for a file outside the tree** (`../data/oxide.users.data`, an absolute path) with `curl` against the sidecar, with a valid token | Refused by the **plugin**, with a reason. The sidecar forwards paths and judges none of them; the guard is where the directory is |
|
||||||
|
|
||||||
|
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
|
||||||
|
this phase has two specific things to confirm rather than assume:
|
||||||
|
|
||||||
|
- **The reload path.** The plugin asks `Interface.Oxide` for `ReloadPlugin` by reflection and falls
|
||||||
|
back to a console command — `c.reload` on Carbon, `oxide.reload` on Oxide, chosen by looking for a
|
||||||
|
Carbon assembly at runtime. A wrong prefix on Carbon prints **nothing at all**, which looks
|
||||||
|
exactly like a command that worked (`CARBON.md` §5), so the proof is `OnPluginLoaded` arriving,
|
||||||
|
not the command being accepted.
|
||||||
|
- **`OnPluginLoaded` / `OnPluginUnloaded` firing at all.** They are the rollback's only evidence. If
|
||||||
|
either does not fire on a framework, every save there rolls itself back four seconds later and
|
||||||
|
reports a plugin that is in fact perfectly fine. `rg.hooks` at the console is the standing answer:
|
||||||
|
both names are in `ExpectedHooks`, so a framework that never raises one shows a zero.
|
||||||
|
|
||||||
|
**What counts as a pass:** a setting typed on the website changes what the running game does; a
|
||||||
|
deliberately broken config leaves the plugin loaded and the operator holding the reason; and no file
|
||||||
|
the save did not touch differs by a single byte.
|
||||||
|
|
||||||
|
## The account walk on a phone (phase 8, Android leg B)
|
||||||
|
|
||||||
|
Added 2026-09-22. The app's half was walked on an emulator against a core with the module installed
|
||||||
|
and a **live** rig behind it — the drawer row appearing only for a signed-in player on a site that
|
||||||
|
runs the module, both reads, a refused code rendering beside the button, the entitlement list with
|
||||||
|
its per-server marks, and a release. What no emulator can produce is the code itself, so this is the
|
||||||
|
same three minutes as the identity walk above, done on the phone instead of in a browser.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **In game, type `/link`.** On the phone, open the drawer → *My Rust account*, type the code and press *Link account* | The account appears with the name the game knows you by, when it was linked and which server minted the code |
|
||||||
|
| 2 | **Press it again with the same code** | *"That code is unknown or has expired."* Beside the button, not at the top of the screen |
|
||||||
|
| 3 | **Turn the site off and try a fresh code** | *"A server could not be reached… your code is still good — try again in a minute."* It must NOT tell you to get a new code: you would get it from the same unreachable server |
|
||||||
|
| 4 | **Have an operator grant you something on the website, then pull down / reopen the screen** | It appears under *What you can do in game*, marked **waiting** until a sync lands it and **has it** afterwards. The two states are a word as well as a colour |
|
||||||
|
| 5 | **Press *Unlink*** | The row goes, and every entitlement returns to *waiting* on the next read — the site still holds them, and they now reach nobody |
|
||||||
|
| 6 | **Sign out** | The row is gone from the drawer. On a site with no Rust module it is never there at all, whoever is signed in |
|
||||||
|
|
||||||
|
**What counts as a pass:** a player links an account from the phone without touching a browser, and
|
||||||
|
the screen never claims an entitlement is in the game when the site has not confirmed it there.
|
||||||
|
|
||||||
|
## The events walk (phases 12 and 13a)
|
||||||
|
|
||||||
|
Added 2026-09-24. Everything an event does to a server — the leases (protocol 8) and the world verbs
|
||||||
|
(protocol 9) — was walked through real core on both rigs, and every effect was checked in the game
|
||||||
|
with a console probe ([PLAN.md](../modules/rust/PLAN.md) §27.5 and §28.6). What a probe cannot do is
|
||||||
|
**be a player**: stand in a zone, open a crate, get shot at, or see a kit unlock in the menu. That is
|
||||||
|
this walk. It is §27.7's and §28.8's "not proven" lists, done by a person.
|
||||||
|
|
||||||
|
**Before you start:**
|
||||||
|
|
||||||
|
1. **Kits installed on the rig** (`umod.org/plugins/Kits.cs`, into `oxide/plugins/` or
|
||||||
|
`carbon/plugins/`) with at least one kit whose `RequiredPermission` is set — step 1 needs it, and
|
||||||
|
neither rig has had Kits yet.
|
||||||
|
2. **`EventsEnabled` on** — Admin → Rust mod config → `RunicGateway.json`. It takes effect at once.
|
||||||
|
3. **The four event verbs switched on** in Admin → Events → Actions: `core.lease`, `rust.zone.open`,
|
||||||
|
`rust.crate.place`, `rust.npc.place`.
|
||||||
|
4. **A non-admin Steam account for step 6** is preferable but not required. Kits applies its gate to an
|
||||||
|
admin too, so step 1 works on the owner's account, and nothing in steps 2–6 depends on a permission.
|
||||||
|
|
||||||
|
Each step is an event you author in Admin → Events, publish, and start by hand. A monument is picked
|
||||||
|
from the dropdown (it lists this map's monuments by name and grid square); stand near it before you
|
||||||
|
start the run.
|
||||||
|
|
||||||
|
| # | Author this, start it, then | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **A `core.lease` on `rust.group.permission`**: target `<server>/default/<the kit's RequiredPermission>`, value `true`, 10 minutes. Open the kit menu before and after starting it | The kit goes from **locked** to **available** when the step runs, and back to locked when the run ends or its ten minutes pass — whichever comes first. Redeem it once while it is open: the items stay in your inventory after it locks again, which is correct (the lease borrowed the *right*, not the loot) |
|
||||||
|
| 2 | **`rust.zone.open`** at a monument you are standing next to, radius 30, 15 minutes, a name you will recognise. At the console run `rgp.inzone` with you **outside** the zone, then walk in and run it again | `zone_list` at the console lists the zone with your name (`rg-<run>-…`). `rgp.inzone` answers *nobody* for it while you are outside and **your name** once you are in — that is ZoneManager's own `IsPlayerInZone` saying a player is inside a zone the bridge made, which is §28.8's first item. (`rgp.inzone` is the rig-only probe, `RgProbe13`, on both rigs; ZoneManager itself has no command that says who is in a zone.) Nothing reaches the website from this step. 13b's tally asks ZoneManager the same question every five seconds rather than subscribing to `OnEnterZone`/`OnExitZone` (PLAN.md §29.7) |
|
||||||
|
| 3 | **Leave step 2's run going and restart the server.** Rejoin, walk back into the zone and run `rgp.inzone` | `zone_list` shows the zone **back** after the restart, at the same place and with the same name, re-created from the bridge's own record because ZoneManager forgets temporary zones. `rgp.inzone` names you again. On the site the run's zone row still reads `confirmed` |
|
||||||
|
| 4 | **`rust.crate.place`**: `crate.hackable` ×1 and `crate.elite` ×3 at the same monument, spread 8. Loot one elite crate empty; start the hack on the locked crate. Then cancel the run | The crates are there and lootable, the hack timer runs. At teardown the untouched crates vanish, and the looted one is simply **gone** already — its row reads `reverted` all the same (§15.2: reverting something a player took is a success) |
|
||||||
|
| 5 | **`rust.npc.place`**: `npc.scientist` ×3 at the monument. Kill one; let the others see you | They are hostile and fight. The one you killed is just gone; at teardown the survivors vanish mid-fight. Every row reads `reverted`. If an NPC spawned **inside a wall or fell through the ground**, note the monument — some offsets land off the navmesh, and that is a finding about where to put things, not a bug to hide |
|
||||||
|
| 6 | **A zone for 3 minutes.** Stand inside it (`rgp.inzone` names you) and wait out the three minutes | The zone disappears around you **without anybody on the website doing anything**, because the game keeps the deadline (D96). `zone_list` no longer lists it, and the sidecar's `/events?kind=world.expired` shows the frame. Best done on the non-admin account, which is the view a real player has |
|
||||||
|
| 7 | **If players can be arranged, a `core.lease` on `spawn.max_rate`** — these two scalars are left out of the catalogue today because they only matter with people online (§27.5) | With several players on, the population system's spawn rate moves (`spawn.report`) while the lease holds and returns after. If it does, the two `spawn.max_*` keys can be added to the allowlist; if nothing moves even with players, they stay out |
|
||||||
|
|
||||||
|
**Run steps 1–5 on both frameworks.** One thing is specific to Carbon: ZoneManager was only installed
|
||||||
|
on the Carbon rig on 2026-09-24, for the 13a walk — step 2 is the first time a *player* has stood in
|
||||||
|
a zone there.
|
||||||
|
|
||||||
|
**What counts as a pass:** a player sees what the event did and sees it stop — the kit locks again,
|
||||||
|
the zone goes, the crates and NPCs vanish — with no website action beyond starting and cancelling the
|
||||||
|
run; a restart in the middle keeps the zone; and nothing a player took or killed shows up as a
|
||||||
|
failure on the run console.
|
||||||
|
|
||||||
|
## The rewards walk (phase 13b, protocol 10)
|
||||||
|
|
||||||
|
The parts of [`PLAN.md`](../modules/rust/PLAN.md) §29.4 that need somebody in the game. Everything
|
||||||
|
around them was walked without a player (§29.7): the recipient modes, the push, the credits in both
|
||||||
|
directions, the revert, a restart and a wipe, and chat. What is left is a person standing in a zone,
|
||||||
|
landing a kill, and opening the Kits menu.
|
||||||
|
|
||||||
|
**Before you start:**
|
||||||
|
|
||||||
|
1. **Kits 4.4.9 is installed on both rigs**, with two test kits: `rgreward` (`RequiredPermission`
|
||||||
|
`kits.rgreward`, `MaximumUses` 1, 100 wood) and `rgopen` (no permission, `MaximumUses` 1,
|
||||||
|
100 stones). Both were installed on 2026-09-24.
|
||||||
|
2. **`EventsEnabled` on**, and **`rust.kit.entitle` switched on** in Admin → Events → Actions with a
|
||||||
|
`rust.grants` cap. It arrives off: it is a `change`. The participation verbs and
|
||||||
|
`rust.announce` arrive on.
|
||||||
|
3. **Your Steam account linked on the site** (`/link` in game, then the code on the site). A reward
|
||||||
|
reaches only a linked account, and an unlinked winner is named in the step's detail as missed.
|
||||||
|
4. **You have joined the server at least once.** On Carbon this is not optional: Carbon refuses a
|
||||||
|
grant to a Steam id it has never seen (§29.8).
|
||||||
|
|
||||||
|
| # | Author this, start it, then | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **A zone and a tally in it.** Phase 1: `rust.zone.open` at a monument you are near, radius 30, 30 minutes, named `Arena`; `rust.participation.open` on the same server with zone `Arena`, score `both`, kills of `npcs`, 20 minutes; `rust.npc.place` one `npc.scientist.tethered` at the same monument. Phase 2, after 12 minutes: `rust.participation.collect` | Walk into the zone and stay about five minutes. Kill the scientist while you are inside it, then walk out. At the console, `rg.rewards` shows the run's tally with you in it. The run's participants on the site list you, linked, with `seconds` near 300, one kill, and a score near 5 + 5 × 1 = 10 (D99) |
|
||||||
|
| 2 | **The same, with no zone.** Leave the zone empty and pick score `seconds` | You accrue anywhere on the server. Standing still counts; being dead or asleep does not |
|
||||||
|
| 3 | **A reward while you are offline.** Phase 1: `rust.participation.open`, score `seconds`, 10 minutes. Be on the server for a few minutes, then **log off**. Phase 2, after 12 minutes: `rust.kit.entitle` with kit `<server>/rgreward` and recipients `everyone` | Log back in and open the Kits menu: **`rgreward` is available**. Redeem it (100 wood). It stays available: the credit is one more use (D103). Redeem it again, and now it is exhausted. `rg.rewards` shows your credit as `applied=1 redeemed=1`. An in-app notice *"You earned rgreward on …"* is waiting if you turned on the `rewards-v1` rule |
|
||||||
|
| 4 | **A revert after one redemption.** Repeat step 3, redeem once, then cancel the run | The kit locks again: the permission is gone. The wood stays in your inventory, because the redemption stands (R16). `rg.rewards` shows no credit left |
|
||||||
|
| 5 | **A revert before any redemption.** Repeat step 3 and cancel the run before you open the menu | The kit is locked when you look |
|
||||||
|
| 6 | **Chat.** `rust.announce` to your server, then to `*` | The line appears in your chat once, even if the step retried. With news switched on for your server (Admin → Rust visibility → *News in game chat*), publishing a news post puts its title in your chat |
|
||||||
|
|
||||||
|
**Run steps 1, 3 and 6 on Carbon as well.**
|
||||||
|
|
||||||
|
**What counts as a pass:** the tally counts you while you are in the zone and not while you are out
|
||||||
|
of it; the kill lands on the tally; a reward granted while you were offline is in the menu when you
|
||||||
|
come back; its extra use works exactly once; and a revert takes away the right to redeem but never
|
||||||
|
what you already redeemed.
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user