docs(rust): the Backpacks spike and its design — D337–D339 (PLAN_REDESIGNS §11.3) #340
@@ -10,7 +10,7 @@ rows still to come (§3.4); §4, built 2026-09-29 (§4.1). **§6 was spiked on 2
|
||||
chose neither route: Runic Gateway writes its own NPC plugin, RunicNPC, planned in
|
||||
[`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md) (D214–D314), and the rest of this plan waits for it.** RunicNPC v1.0.0 shipped on 2026-10-09, and the plan
|
||||
resumed with §2 the same day (D331–D334); §2 was built on 2026-10-12, and §11's Economics spike the same day
|
||||
raised D335–D336 (§11.2.1). Economics was built on 2026-10-12 too (§11.2.4). **Every item left is built, tested and PR'd first; every walk —
|
||||
raised D335–D336 (§11.2.1). Economics was built on 2026-10-12 too (§11.2.4). The Backpacks spike followed and raised D337–D339 (§11.3.1). **Every item left is built, tested and PR'd first; every walk —
|
||||
theirs and the ones still owed by §3 and §5 — happens together at the end (D331, §9).**
|
||||
|
||||
This is a companion to [`PLAN_FIXES.md`](PLAN_FIXES.md) and [`PLAN.md`](PLAN.md). Where they disagree, this
|
||||
@@ -999,6 +999,14 @@ The Economics spike raised two more, answered on 2026-10-12:
|
||||
| **D335** | **Coins paid by an event are kept.** `rust.coins.pay` is `reversible: 'none'`: a run that finishes, is cancelled or is aborted after paying leaves the coins where they are, and staff take coins back by hand from the player's admin page. | Kept on a finish but taken back on a cancel or abort (core would have to tell `revert()` how the run ended, MODULE_API 1.14.0); a per-step choice; always taken back at the end, like a kit weekend's credits. | 11.2.2 |
|
||||
| **D336** | **The richest column and *Magnate* rank only balances above the server's starting balance**, read when a player connects and whenever it changes. A fresh wipe's untouched players are not ranked; a balance carried over from the last wipe is, at once. | Ranking a player only once their balance moves this wipe; ranking everyone, ties at the starting balance included. | 11.2.2 |
|
||||
|
||||
The Backpacks spike raised three more, answered on 2026-10-12:
|
||||
|
||||
| # | Decision | Rejected | § |
|
||||
|---|---|---|---|
|
||||
| **D337** | **Extra backpack space is a size permission leased for the run.** `rust.backpack.size` grants one of the server's `backpacks.size.N` to each recipient through `rust_perm_run_grants`, as a kit reward is granted, and the run's end revokes it. It reaches offline players, needs no experimental setting, and needs no plugin code. The highest size a player holds wins. | Backpacks' `API_AddBackpackCapacity` (+N slots), which throws unless the owner turns on *Dynamic Size (EXPERIMENTAL)*, is clamped to a size profile, and needs the player online; offering both; dropping the reward. | 11.3.2 |
|
||||
| **D338** | **Taking the space back is Backpacks' own behaviour:** nothing moves at the revoke, and on the owner's next open the stacks past their size go to their inventory, or drop at their feet when it is full. Tested on both rigs at the org lead's request; the step's default message warns about it. | Holding the space until the extra slots are empty; never taking it back. | 11.3.1, 11.3.2 |
|
||||
| **D339** | **The Carbon permission leak found by the spike stays out of §11.3**: filed as Rust-Plugins#32, for later. | Fixing it in §11.3; only noting it. | 11.3.1 |
|
||||
|
||||
## 11. First-class optional plugins (D199–D202)
|
||||
|
||||
The org lead named four plugins the module supports as first-class integrations. Kits already is one. The other
|
||||
@@ -1022,7 +1030,7 @@ Personal GitHub mirrors are not a source: k1lly0u's mirror of Kits is two major
|
||||
| Plugin | Author · version · price | API we would use | Hooks it raises | Risk |
|
||||
|---|---|---|---|---|
|
||||
| **Economics** | Wulf · 3.9.2 · free | `Balance(id)` → double; `Deposit`, `Withdraw`, `SetBalance(id, amount)` → bool; `Transfer` | `OnEconomicsDeposit`, `OnEconomicsWithdrawl` (sic), `OnEconomicsTransfer`, `OnEconomicsBalanceUpdated`, `OnEconomicsDataWiped` | Last released about two years ago; fine as optional, never load-bearing |
|
||||
| **Backpacks** | WhiteThunder · 3.17.7 · free | `API_ReadBackpackContentsAsJson(ownerId)`; the capacity family (`API_GetBackpackCapacity`, `API_AddBackpackCapacity`, `API_SetBackpackCapacity`) | `OnBackpackOpened`, `OnBackpackClosed`, `OnBackpackDropped`, `CanOpenBackpack` | Newer builds add `EncryptedValue<ulong>` overloads; one community report of backpack data lost across a **Carbon** restart — a rig test before shipping |
|
||||
| **Backpacks** | WhiteThunder · 3.17.7 (3.17.8 at the spike) · free | `API_ReadBackpackContentsAsJson(ownerId)`; the capacity family (`API_GetBackpackCapacity`, `API_AddBackpackCapacity`, `API_SetBackpackCapacity`) | `OnBackpackOpened`, `OnBackpackClosed`, `OnBackpackDropped`, `CanOpenBackpack` | Newer builds add `EncryptedValue<ulong>` overloads; one community report of backpack data lost across a **Carbon** restart — a rig test before shipping (**not reproduced, §11.3.1**) |
|
||||
| **RaidableBases** | nivex · free 3.2.0 on uMod, paid on Codefling | `EventTerritory(Vector3)`, `SpawnRandomBase(type)`; listing and despawn not yet confirmed | `OnRaidableBaseStarted` (two overloads), `OnRaidableBaseEnded`, `OnRaidableBasePrivilegeDestroyed`, `OnRaidableBaseDespawn` — signatures not yet confirmed from source | Two products with different features and release lines; data path differs by framework; needs a PvE-damage plugin (TruePVE) for players to raid |
|
||||
|
||||
### 11.2 Economics (D200)
|
||||
@@ -1196,15 +1204,189 @@ be overruled:
|
||||
|
||||
### 11.3 Backpacks (D201)
|
||||
|
||||
This is the plan as the org lead approved it on 2026-09-28. The spike (§11.3.1) changed how the extra space
|
||||
is given, and **§11.3.2 is the design to build**:
|
||||
|
||||
- **Read, never write (R2).** A new verb, `backpacks.read {steamId}`, returns
|
||||
`API_ReadBackpackContentsAsJson` reduced to shortname, amount, skin and slot. It never uses the write
|
||||
counterpart, so the site cannot change a backpack.
|
||||
- **Staff** open any player's backpack from the player's admin page. **A player** sees their own on the
|
||||
account page and in the app, only for accounts they have linked (the player tier's usual scoping).
|
||||
- **Extra space as a reward.** A step `rust.backpack.capacity` adds N slots for the event's duration through
|
||||
- **Extra space as a reward.** ~~A step `rust.backpack.capacity` adds N slots for the event's duration through
|
||||
`API_AddBackpackCapacity`, and takes them back at teardown, ledgered like a lease. If the player is offline
|
||||
at either end, it waits for their next connection: the capacity calls take a `BasePlayer`.
|
||||
at either end, it waits for their next connection: the capacity calls take a `BasePlayer`.~~
|
||||
**Superseded by D337: the step leases one of Backpacks' size permissions for the run instead.** The capacity
|
||||
calls work only on a server whose owner has turned on an experimental setting (§11.3.1).
|
||||
- **The spike first:** the Carbon restart report. Fill a backpack on `rust-carbon`, restart, and check it.
|
||||
**Done; the loss did not reproduce (§11.3.1).**
|
||||
|
||||
#### 11.3.1 The spike (2026-10-12)
|
||||
|
||||
The source is Backpacks **3.17.8** (one patch past the 3.17.7 that §11.1 names), WhiteThunder, pulled from
|
||||
`umod.org/plugins/Backpacks.cs` (9,782 lines) on 2026-10-12. A throwaway plugin made stand-in players with
|
||||
Steam-shaped ids (the RunicNPC 9d pattern), called the API and logged every hook, on both rigs, on Rust
|
||||
25877464 with Oxide 2.0.7826 and Carbon 2.0.262. **Both frameworks answered identically everywhere the plugin
|
||||
was concerned.** The spike and Backpacks were then removed from both rigs, with their data, config and lang
|
||||
files, and the permissions the stand-ins held.
|
||||
|
||||
**The restart report did not reproduce.** A backpack changed after the last save (the save interval raised
|
||||
to an hour first, so no timed save could run) was intact after a panel restart on both frameworks, for an
|
||||
online owner and an offline one alike. A plugin reload kept it too. Backpacks saves at every server save and again at
|
||||
unload. **The one way the spike lost a backpack** was its own: the first run killed its
|
||||
stand-ins at unload, Backpacks took each kill for a death, and *Drop on death*, on by default, dropped the
|
||||
contents on the ground. On Carbon, Backpacks was still loaded when the spike unloaded during shutdown; on
|
||||
Oxide it was not, so only Carbon showed it. That is Backpacks working as configured: **any plugin that kills
|
||||
a player entity drops that player's backpack.** The bridge kills no players.
|
||||
|
||||
**Reading.**
|
||||
|
||||
| Call | Found |
|
||||
|---|---|
|
||||
| `API_ReadBackpackContentsAsJson(ulong)` | A JSON string, or **`null` for a player who has never had a backpack**. It loads an offline player's backpack from disk. 0.2–7 ms once loaded; **55–117 ms the first time after a boot** (Oxide logs its own "took 116ms" warning), on the main thread |
|
||||
| `API_CountBackpackItems(ulong, null)` | The number of stacks |
|
||||
| `API_GetBackpackCapacityById(ulong, string)` | The capacity an id would have. **Caches it** (see the trap below) |
|
||||
|
||||
The JSON is a list of Backpacks' own `ItemData`, not of Rust items:
|
||||
|
||||
```json
|
||||
[{"ID":-151838493,"Position":0,"Amount":1000},
|
||||
{"ID":1545779598,"Position":2,"Amount":1,"Contents":[{"ID":567235583,"Position":0,"Amount":1}]},
|
||||
{"ID":1751045826,"Skin":10129,"Position":3,"Amount":1},
|
||||
{"ID":1414245162,"Name":"spike-a","Position":4,"Amount":1}]
|
||||
```
|
||||
|
||||
- **`ID` is the item id, not the shortname**; the bridge maps it with `ItemManager.FindItemDefinition`.
|
||||
- **`Position` runs across pages**: page two of a 96-slot backpack starts at 48 ("Max size per page").
|
||||
- **`Contents`** holds a weapon's attachments or a container's items, nested. `Name` is a renamed item's
|
||||
name. Condition, ammo, fuel and the rest appear when they differ from the default.
|
||||
|
||||
**Capacity.** A player's size comes from permissions: `backpacks.use` gives the default (6), and the
|
||||
highest `backpacks.size.N` they hold sets it, for each N in the config's *Permission sizes* (6, 12, 18, 24, 30,
|
||||
36, 42, 48, 96 and 144 by default). Backpacks registers each one, so the bridge can list them.
|
||||
|
||||
- **`API_AddBackpackCapacity` and `API_SetBackpackCapacity` throw** (`InvalidOperationException`, logged with a
|
||||
stack trace on both frameworks; `Call` returns `null`) unless the config's **"Dynamic Size
|
||||
(EXPERIMENTAL)"** is on, and it is off by default. With it on, each player is clamped to the initial and
|
||||
maximum size of a *size profile* they hold (`backpacks.size.profile.6-48` …); a player with no profile gains
|
||||
nothing. They take a `BasePlayer`, so an offline player cannot be given space. This is why D337 leases a
|
||||
permission instead.
|
||||
- **A size permission takes effect at once, both ways, for a player whose backpack is loaded** (an online
|
||||
player who has opened it, or whose backpack anything has read): granted `backpacks.size.48`, 6 became 48;
|
||||
revoked, 48 became 6. An offline player's grant is simply there when they connect.
|
||||
- **The trap: a capacity cached without its backpack.** Backpacks clears its capacity cache on a permission
|
||||
change only for a backpack it has loaded. If anything reads an online player's capacity while their backpack
|
||||
is not loaded (`API_GetBackpackCapacity*`), the value is pinned: a size granted afterwards did not show, even
|
||||
after the owner opened the backpack, **until they disconnected**. Same on both frameworks. Backpacks itself
|
||||
reads capacity only through a loaded backpack, so only another plugin's call can cause it. **The bridge never
|
||||
reads a capacity except through a backpack it has just loaded with a read.**
|
||||
|
||||
**Taking space back (the org lead's test, 2026-10-12).** A stand-in held `backpacks.size.96`, six stacks on
|
||||
page one and five notes on page two (slots 48–52); the permission was revoked.
|
||||
|
||||
| State | What happened, on both frameworks |
|
||||
|---|---|
|
||||
| After the revoke, before the owner opens it | Nothing moves. Capacity reads 6, and **the read still returns all eleven stacks**: the five past slot 6 wait in the backpack |
|
||||
| The owner opens it, inventory has room | The five notes **moved into the owner's inventory** (main 5/24), and the backpack read six stacks. Backpacks tells the owner in chat: "Your backpack was over capacity. Overflowing items were added to your inventory or dropped." |
|
||||
| The owner opens it, inventory full (30/30) | The five notes **dropped on the ground at the owner's feet** as loose items, and the backpack read six stacks |
|
||||
|
||||
That is Backpacks' default, the behaviour a VIP page that lapses already has, and **it stands (D338).** A
|
||||
staff member looking at someone else's backpack sees the overflow too; only the owner's own open moves it.
|
||||
|
||||
**Hooks.** `OnBackpackOpened(BasePlayer looter, ulong ownerId, ItemContainer)` and `OnBackpackClosed` fired
|
||||
for the owner's open and close; `OnBackpackDropped(ulong ownerId, List<DroppedItemContainer>)` for each
|
||||
death drop. None is needed by the design.
|
||||
|
||||
**Two quirks the bridge never meets,** because it never writes: `API_TryDepositBackpackItem` took only the
|
||||
first item into a backpack nobody had opened yet, and `API_GetBackpackContainer` returns `null` for a backpack
|
||||
that does not exist yet. A page that has never been opened is sized to what it holds until its owner opens it.
|
||||
|
||||
**Found on the way, outside Backpacks:** on Carbon, a permission granted to a Steam id the store has no user
|
||||
record for leaks onto every such id. Filed as
|
||||
[Rust-Plugins#32](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/issues/32) and kept out of
|
||||
§11.3 (D339). The size lease grants through `perm.sync` like a kit reward, so it inherits whatever that issue
|
||||
decides.
|
||||
|
||||
#### 11.3.2 The design to build (D337, D338)
|
||||
|
||||
**Hello.** `integrations.backpacks` is `{ loaded, version, sizes }`. `sizes` lists the N of every
|
||||
registered `backpacks.size.N` permission, ascending (`[6, 12, 18, 24, 30, 36, 42, 48, 96, 144]` by
|
||||
default), read from the permission store, so an operator's own *Permission sizes* are what the site offers.
|
||||
|
||||
**Reading a backpack: `backpacks.read {steamId}`, site → game.**
|
||||
|
||||
```json
|
||||
{"cmd":"backpacks.read","reqId":"r-40","steamId":"76561190000000001"}
|
||||
{"kind":"backpacks.contents","type":"reply","reqId":"r-40","steamId":"76561190000000001","exists":true,
|
||||
"capacity":12,"pageSize":48,
|
||||
"items":[{"slot":0,"shortname":"wood","amount":1000},
|
||||
{"slot":2,"shortname":"rifle.ak","amount":1,
|
||||
"contents":[{"slot":0,"shortname":"weapon.mod.small.scope","amount":1}]},
|
||||
{"slot":3,"shortname":"hoodie","amount":1,"skin":10129},
|
||||
{"slot":4,"shortname":"note","amount":1,"name":"spike-a"},
|
||||
{"slot":14,"shortname":"note","amount":1,"overflow":true}]}
|
||||
```
|
||||
|
||||
- **One id per call, on the main thread.** It calls `API_ReadBackpackContentsAsJson`, and only when that
|
||||
answered a backpack does it read `API_GetBackpackCapacityById`: that backpack is now loaded, so the
|
||||
capacity follows later grants (§11.3.1's trap). A player who has never had one is `exists: false`, with no
|
||||
capacity.
|
||||
- **Each stack is reduced** to slot, shortname, amount, and skin and name when set; nested `contents` keep the
|
||||
same shape, one level deep. An id no item definition knows is sent as `shortname: null` with its `itemId`.
|
||||
- **`overflow: true`** marks a stack at or past the capacity: it is waiting for its owner's next open to be
|
||||
moved (§11.3.1).
|
||||
- **Refusals** are `backpacks.error`: `backpacks-missing`, `bad-id`.
|
||||
|
||||
**Extra space: a size lease (D337).** A new step, **`rust.backpack.size`**, *"Bigger backpack"*, grants one of
|
||||
`integrations.backpacks.sizes` for the run, to the recipients a mode picks.
|
||||
|
||||
- **It is a kit reward with a different permission.** It writes one `rust_perm_run_grants` row per recipient,
|
||||
with `permission` = `backpacks.size.N` and no kit. The permission mirror pushes it in the next `perm.sync`,
|
||||
so a recipient who is offline has it when they connect, and a wipe cannot take it away. Its revert deletes the
|
||||
step's rows, and the next sync revokes them. **So the space lasts until the run ends, a normal finish
|
||||
included** (core reverts every ledgered step at the end of a run, §11.2).
|
||||
- **No plugin code.** The grant, the revoke and their read-backs are `perm.sync`'s.
|
||||
- **Params:** the server; the size (a select of the server's `sizes` above the smallest, refused when it is
|
||||
not one of them); the recipients and count (`rust.kit.entitle`'s modes, `linked` included); and a message
|
||||
and delivery, told once like a kit reward's notice (D333). The default message warns about the end:
|
||||
*"Your backpack holds {size} until the event ends. Anything past your normal size then moves to your
|
||||
inventory, or drops at your feet if it is full."*
|
||||
- **The highest size wins.** A recipient who already holds a size at least as big gains nothing, and nothing is
|
||||
taken from them at the end. The step's description says so.
|
||||
- **The take-back is Backpacks' own (D338):** nothing moves at the revoke, and the overflow goes to the owner's
|
||||
inventory, or the ground, the next time they open the backpack.
|
||||
- `risk` and `reversible` are `rust.kit.entitle`'s (`change`, `ledger`). It spends a new `rust.backpacks`
|
||||
budget, *Backpack sizes*: one per recipient, or the recipient bound for a mode that is not a count, as kit
|
||||
rewards are priced.
|
||||
- **It refuses where Backpacks is absent** (D199): "Backpacks is not installed on this server".
|
||||
|
||||
**The sidecar** adds `POST /backpacks/read`, a thin forward like `/kits/uses`. It keeps nothing.
|
||||
|
||||
**The website (Module-Rust).**
|
||||
|
||||
- **Staff** see a player's backpack on the user's admin page, per linked account and server, through
|
||||
`GET /admin/users/:userId/rust/backpacks?serverId=&steamId=`. **A player** sees their own on their Rust
|
||||
page through `GET /player/rust/backpacks?serverId=&steamId=`, for their own linked accounts only.
|
||||
- **Live, never stored.** Each view is one `backpacks.read`; nothing is written to the database. A server that
|
||||
is down or lacks Backpacks says so on the panel. Opening the panel reads once; there is no polling.
|
||||
- **The grid** draws the slots as pages of `pageSize`, with each stack's icon from the item catalogue, its amount,
|
||||
and an *overflow* mark.
|
||||
|
||||
**The app (Android-app).** The Rust account screen shows the player's own backpack from the player route,
|
||||
capability-gated like the rest of that screen (R10).
|
||||
|
||||
**Not touched:** the installer and the egg. `requires_plugins` stays Kits and ZoneManager (D199). The bridge
|
||||
uses none of Backpacks' hooks.
|
||||
|
||||
#### 11.3.3 The walk (deferred to the end, D331)
|
||||
|
||||
On both rigs, with Backpacks installed and a real player:
|
||||
|
||||
- open a staff view of a full backpack, a two-page one, and one that has never been used;
|
||||
- read your own on the account page and in the app, and see another player's refused;
|
||||
- run a *Bigger backpack* step, online and offline at the grant, and see the size on the next open;
|
||||
- end the run with items on the extra page, open the backpack, and see them move to the inventory; then
|
||||
again with a full inventory, and see them on the ground;
|
||||
- remove Backpacks, and see the step and the panels refuse with a reason.
|
||||
|
||||
### 11.4 RaidableBases (D202)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user