From a5d918207b47800f3456c52b61fdd9c150c5673f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 18:25:43 -0500 Subject: [PATCH] =?UTF-8?q?docs(rust):=20phase=2017=20as=20built=20and=20w?= =?UTF-8?q?alked=20=E2=80=94=20protocol=2012,=20D144=20(PLAN=20=C2=A733.5-?= =?UTF-8?q?=C2=A733.7,=20M19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PLAN.md §33.5 as built (D144: the plugin keeps its titles on disk, after the walk found a hot reload emptied them), §33.6 the walk on both rigs, §33.7 findings; the phase row marked built. - PROTOCOL.md §18: titles.set, a group's BetterChat style on perm.sync, chat.say delivery and format, integrations, rg.titles, POST /titles. The header's current version moves from a stale 10 to 12. - PLAYER_WALK.md: the steps that need a person in the game. - android/PLAN.md M19 as built. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- android/PLAN.md | 9 ++- modules/rust/PLAN.md | 153 ++++++++++++++++++++++++++++++++++++++- rust-link/PLAYER_WALK.md | 20 +++++ rust-link/PROTOCOL.md | 103 +++++++++++++++++++++++++- 4 files changed, 281 insertions(+), 4 deletions(-) diff --git a/android/PLAN.md b/android/PLAN.md index 567c0ed..5d2892f 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1619,8 +1619,13 @@ push, and Play (M6–M8) follow the designed app. Excluded: editing the schedule, which is operator configuration. -20. **M19 — chat titles on a Rust leaderboard** (post-v1; planned 2026-09-25, - `feat/rust-phase-17-titles` into `edge`). `module-rust` phase 17. **Design of record: +20. **M19 — chat titles on a Rust leaderboard** (post-v1; planned 2026-09-25, **built and walked + 2026-09-25**, `feat/rust-phase-17-titles` into `edge`). `module-rust` phase 17. As built: + `RustLeaderboardRowDto.titles` (default empty) and `RustTitleDto`; `titleArgb()` and + `titleInkIsDark()` in `RustFormat.kt`, the website's `contrastInk` rule; chips in a `FlowRow` + **under** the name, because the name column is too narrow beside it. Walked on the emulator + against the phase-17 module (`modules/rust/PLAN.md` §33.6). The older-module case is a DTO test: + the standing core was in maintenance and was not changed. **Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §33**, D135–D137. The phase is mostly in-game BetterChat work. The app's part is one field. diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 3ea6382..bd83ad0 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1030,7 +1030,7 @@ Each phase ends with its findings written down, as every workstream here does. | 14 | **The live map** (R9). ✅ **Built and walked 2026-09-25 (§30.7), protocol 11, D119–D120 (§30.6); the person-parts are the map walk in `PLAYER_WALK.md`.** 📐 **Plan of record in §30 (D109–D118), 2026-09-24.** A rig probe found that the `.map` file is terrain, not a picture, and that Rust+ already caches the rendered JPEG; the game's own render stalls the main thread 8.5 s (§30.0). Protocol 11. *As first written:* the map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it | | 15 | **Android leg D** (R10). Map and events. ✅ **Built and walked on an emulator 2026-09-25 (§31.5); the counted tally and a real player are in `PLAYER_WALK.md`.** 📐 **Plan of record in §31 (D121–D125), 2026-09-25.** The map is a fifth tab drawn natively in Compose over the module's projected answer, so the app has no gate of its own to get wrong. "Events" is a site-event marker opening the app's own event page, plus M13 walked against a Rust run. That needs one field on core's public calendar (D125) | Android-app + website + docs | The map renders on a phone with the same layer gates | | 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral. ✅ **Built and walked 2026-09-25 — plan in §32 (D126–D132), as built and findings in §32.5–32.7 (D133).** Five commands. Any answer narrower than public goes to the caller alone (D127), which is the reverse of the case the criterion names and the one core does not cover. Plus an operator-set **next wipe** (a rule and a one-off date) shown on the web, in the app and by `/wipe` | Module-Rust + Android-app + docs | A refusal does not go public in the channel, **and neither does a permitted answer narrower than public** | -| 17 | **Optional mod integrations** (R15). 📝 **Planned 2026-09-25 — plan in §33 (D134–D143).** **BetterChat** three ways: leaderboard titles through `API_RegisterThirdPartyTitle` from operator rules, shown on the web and in the app too; all twelve chat fields on a site-authored group with R2's drift posture; and that style as the voice of news and event lines. **PopupNotifications** becomes optional and a delivery choice. Protocol 12. ~~The uMod **Clans** adapter~~ is dropped (D134): its read API exists after all, and a per-server clan source is a candidate future phase | Rust-Plugins + Rust-Link + Module-Rust + Android-app + docs | A server missing every optional mod still runs the module, Teams included | +| 17 | **Optional mod integrations** (R15). ✅ **Built and walked 2026-09-25 — plan in §33 (D134–D143), as built and findings in §33.5–§33.7 (D144).** **BetterChat** three ways: leaderboard titles through `API_RegisterThirdPartyTitle` from operator rules, shown on the web and in the app too; all twelve chat fields on a site-authored group with R2's drift posture; and that style as the voice of news and event lines. **PopupNotifications** becomes optional and a delivery choice. Protocol 12. ~~The uMod **Clans** adapter~~ is dropped (D134): its read API exists after all, and a per-server clan source is a candidate future phase | Rust-Plugins + Rust-Link + Module-Rust + Android-app + docs | A server missing every optional mod still runs the module, Teams included | | 18 | **The installer** (R4) **and the Pterodactyl egg** (R20) — the two halves of "how the shard side reaches an operator", built together so one story is not told twice. `--game servuo\|rust`, the bundle payload as a variant, a **framework** prerequisite check in `doctor` (which one, not whether Oxide — R19), the protocol pairing refusal carried over; the egg derived from "Rust Autowipe" with the sidecar inside the game container, a second allocation for `[web].bind`, **the sidecar configured from egg variables** (R22), the sidecar store held out of `REMOVE_FILES`, and its install script fetching the same pinned pair the installer resolves | installer + Rust-Link + docs | An operator sets a Rust server up with the released binary and nothing hand-copied; **and** a second operator imports the egg, deploys, and reaches the same place — on either framework | | 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change, and Pterodactyl is a hosting claim the site can now make); the Integration-kit question R2 raised; and whether the kit owes a reader anything about **supporting two mod frameworks at once** (R19) — a shape it has no chapter for either | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way | @@ -6355,6 +6355,155 @@ Each is taken as written unless review says otherwise. save for at most 128 characters and for containing `{Message}`, because a format without it swallows every line that group's members type. +### 33.5 As built + +**One decision the walk needed** (org lead, 2026-09-25): + +| # | Decision | +|---|---| +| **D144** | **The plugin keeps its chat titles on disk.** The walk found that a hot reload of the bridge plugin (an update, an `oxide.reload`) emptied every title, and the site never re-sent them: it re-sends on a new boot id or wipe id, and a reload is neither, because the boot id names the server process. The set is written to `titles.json` with the wipe it was pushed on, and read back at `OnServerInitialized` only while that wipe is current. This amends §33.2's "it is not persisted". Rejected: a per-load id on `server.hello` that the site re-pushes on (titles missing until the site next reaches the server), and a periodic re-push (up to fifteen minutes without titles). | + +**Rust-Plugins** ([Rust-Plugins][rp] `feat/phase-17-integrations`, into `edge`): protocol 12 as +specified in [`PROTOCOL.md`][protocol] §18. Where it differs from §33.2: + +- **`plugins.Find`, not `[PluginReference]`,** for BetterChat and PopupNotifications. It is how the + plugin already reaches Kits and ZoneManager, and a lookup at the moment of the call is never a stale + reference to a plugin that has since unloaded. +- **Every line said is logged** (`said (chat): …`). §33.3 step 5 expected the server log to show the + voice's rich text, and `Server.Broadcast` logs nothing. +- **A group this sync had to create is written whole**, whatever `expect` says: nobody can have edited + it yet. A BetterChat group removed by hand is re-created the way a missing Oxide group already is. +- **`rg.titles` reads its argument with `arg.GetString`.** Current Rust hands a console command + `StringView`s, and `arg.Args[0]` does not compile. The Oxide rig caught it on the first upload. + +**Rust-Link** ([Rust-Link][rl] `feat/phase-17-integrations`, into `edge`): `POST /titles`, +`PROTOCOL_VERSION = 12`, and a README row for the forwards after protocol 1, which the route table +had stopped listing. + +**Module-Rust** ([Module-Rust][mr] `feat/phase-17-integrations`, into `edge`): + +- **Schema** as §33.2, and two columns it did not name: **`rust_perm_pushed.value`**, because a + `chat-field` ledger row has to remember what landed to send it back as `expect`, and + **`rust_perm_drift.detail`**. A changed value is the same field pushed again, not a retirement, so + the value is outside the row's identity and inside the digest. +- **`model/titles/titles.js`** (pure) and **`titles.model.js`**, which reads one leaderboard query per + stat at the deepest top N and remembers each server's answer for 30 s. **`titleSync.js`** pushes on + a change, a restart or a wipe, and skips a server whose plugin is older than protocol 12. What was + sent is kept in memory. +- **`model/permissions/chatStyle.js`** validates a style and composes the voice; + **`voice.js`** holds the fleet setting. **`permSync.js`** adds `expect` per server, turns a + withdrawn style into one `chat-group` retirement, and keeps the ledger rows until BetterChat has + removed the group. +- **Routes:** `PUT /admin/rust/servers/:id/titles`, `GET …/servers/:id/integrations` (live, from + `server.status`), `GET/PUT /admin/rust/voice`. The group `PUT` takes `chat`, and the visibility `PUT` + takes `newsDelivery`. The leaderboard rows gain `titles`. `routes.manifest.json` was regenerated + against the pinned core: 48 routes, all documented. +- **Admin:** a *Chat titles* form and an *Optional mods* check per server, and an *Announcement voice* + card, on the servers page (`ChatTitles.jsx`); a *Chat style (BetterChat)* section on each group, and + style drift rows with *Adopt* and *Put ours back* (`ChatStyle.jsx`); a delivery choice beside each + server's news switch on the visibility page. +- **Web:** a chip per title on the leaderboard, coloured by the rule, with black or white ink by WCAG + contrast (`contrastInk`). + +Where it differs from §33.2 and §33.4: + +- **The news delivery sits beside the news switch**, on the visibility page, as D142 words it, not on + the servers page as §33.2's admin list says. +- **A title also loses `{` and `}`** (reading 3 names four characters). A title is substituted into + `{Title}` before `{Message}` is, so a title reading `{Message}` would print the player's words twice. +- **A format holds `{Message}` exactly once** (reading 7 says "containing"). A voice's line has one + message, and the plugin refuses a format with two. +- **The voice drops `{Username}` with the colon after it.** Reading 5 has `{Username}` render as + nothing, and BetterChat's default format would then say every announcement as `[Title] : …`. +- **`default`'s style is never removed.** A style withdrawn from the site's `default` group stops being + pushed and is left standing, because BetterChat warns on every line when `default` is missing. +- **Revoking a style field queues nothing.** It sets the ledger's value to the game's, so the next + sync expects the hand edit and writes over it. **Adopting one** copies the game's value into the + group's style, which then reaches every server in the group's scope. +- **A bad mode reaches the form as a sentence.** The router's own `isIn` answered *Invalid value* + first; the walk caught it. + +**Android-app** ([Android-app][aa] `feat/rust-phase-17-titles`, into `edge`): M19 as planned, the +chips under the name rather than beside it. + +**Tests.** +- **Module-Rust server: 386**, 18 of them new: titles, styles, the voice, the style half of the + mirror, the push loop and delivery. +- **Module-Rust client: 56.** +- **Android:** `testDebugUnitTest lintDebug assembleDebug` is green. +- **Rust-Link: 57.** + +### 33.6 The walk (§33.3), 2026-09-25 + +**What ran:** +- real core `main` (90ba8cc) on a copy of the walk database, with the phase-17 module copied into + `MODULES_DIR`; +- **both rigs** on the protocol-12 sidecar (swapped in by rename, then a restart) and plugin; +- standings for four players seeded into the copy, one with every stat at zero. + +**Every step passed, on Oxide and on Carbon.** + +The rigs have no Covalence player on record, so `rg.titles ` cannot render. The proof of what +BetterChat draws came from a scratch probe that builds an offline `IPlayer` (by reflection: Carbon's +takes `(string, UserData)`) and calls `API_GetFormattedMessage`, the same call `rg.titles` makes. It was +deleted from both rigs afterwards. + +1. **Nothing optional installed.** Both servers reported `betterChat` and `popupNotifications` not + loaded. `titles.set` was answered `betterChat: false` with three titles held. Every permission sync + reported `chat.loaded: false` and still synced. A `rust.announce` popup to every server failed + **once, not retried**, with both servers' sentences; the chat step said the line on both. +2. **Titles.** Rules *#1 kills* and *top 3 playtime*. + - The API's leaderboard carried *Top Killer* and *Regular* on the right rows, and nothing on the + player with every stat at zero (reading 1). + - With BetterChat loaded, the probe's line read `[Player] Top Killer Regular Brannock: …` on Oxide + (`all`), and `[Player] Top Killer Brannock: …` on Carbon (`first`). `upto 1` showed one. + - After `oxide.reload BetterChat` / `c.reload BetterChat` the plugin logged *registered with + BetterChat again … holding 3*, and the line still carried the title. + - A mode the site does not know was refused with a sentence. +3. **A style.** A partial style was refused with one sentence per missing field. A full one landed on + both rigs, all twelve fields read back, and each logged BetterChat's *Changed Priority to -5*: D143's + save. **A BetterChat reload left every field in place.** A hand edit on Oxide + (`chat group set … TitleColor #00ff00`) became a drift row holding `#00ff00`, unwritten. **Adopt** + moved the site's style, both servers' ledger and the Carbon game to `#00ff00`. A second hand edit, + on Carbon, was **put back** by the next sync (one field applied, one save). +4. **A delete.** Deleting the group reported `removed: [walkstaff]` on both servers and emptied the + ledger. After a BetterChat reload, `chat group list` and the permission groups had no `walkstaff` + (D139). +5. **The voice and delivery.** A group without a style was refused as a voice. + - With `walkvoice` chosen, the event's chat line and a news post were each logged on both rigs as + `said (chat): [Runic] …`. + - With PopupNotifications installed, the popup step was `said (popup)` on both. + - A news post with Oxide set to popup and Carbon to chat was a plain popup on Oxide and a voiced line + on Carbon. + - The admin's live *Optional mods* read both plugins and their versions. +6. **Android**, emulator `s22_ultra`: the leaderboard shows the chips under the names, dark ink on + orange and on yellow. The web page, in a browser, shows the same chips. +7. **D144**, after the fix: a hot reload of the bridge on both rigs left `titles=3 … from disk`, and + `titles.json` names the wipe. + +**Not walked:** +- **A person's chat line**, the voice in a real client and a popup on a real screen: they need + somebody in the game. They are in [`PLAYER_WALK.md`][pw]. +- **The admin forms in a browser**, which needs an admin password typed into the sign-in form. Every + route behind them was walked through the API. **This is the org lead's review.** + +### 33.7 Findings + +- **A hot reload is not a restart** (D144). The permission store, the kit credits and the world + registry are all on disk; the titles were the first site-pushed state held only in memory, and the + boot id — which is what tells the site to re-push — deliberately survives a reload. A future + in-memory push needs the same answer, or a load id. +- **`Server.Broadcast` leaves no trace on the host.** The plan's step 5 assumed the console log would + show the voice, and nothing a broadcast or a popup does is logged. The plugin now logs every line it + says, which is also the only host-side record that the website spoke to every player. +- **The rigs have no Covalence players.** `oxide.covalence.data` is empty on the Oxide rig, so any + proof that needs an `IPlayer` for somebody not connected needs a probe. `rg.titles ` works on a + live server for anyone who has joined. +- **Current Rust passes console arguments as `StringView`.** `arg.Args[0]` stopped compiling; use + `arg.GetString(n)`. Recorded for the next console command. +- **Plugins stay installed:** BetterChat 5.2.15 and PopupNotifications 0.2.1 are on both rigs, for the + player walk. + --- [uo46]: https://gitea.whitlocktech.com/RunicGateway/Module-uo/issues/46 @@ -6371,3 +6520,5 @@ Each is taken as written unless review says otherwise. [w207]: https://gitea.whitlocktech.com/RunicGateway/website/issues/207 [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit +[protocol]: ../../rust-link/PROTOCOL.md +[pw]: ../../rust-link/PLAYER_WALK.md diff --git a/rust-link/PLAYER_WALK.md b/rust-link/PLAYER_WALK.md index c7279bc..4cb5e49 100644 --- a/rust-link/PLAYER_WALK.md +++ b/rust-link/PLAYER_WALK.md @@ -378,3 +378,23 @@ signed in to the app and a staff member at the website. **What counts as a pass:** a player's own dot moves on the phone, a site-event zone opens its event in the app, and a tally counted from a real person reaches *My events* with its fractional score. + +## Chat titles, styles and popups (phase 17, protocol 12) + +Added 2026-09-25. The parts of [`PLAN.md`](../modules/rust/PLAN.md) §33.3 that need somebody typing +in the game (§33.6). Everything else was walked with a probe that asks BetterChat to format a line. +Both rigs have BetterChat 5.2.15 and PopupNotifications 0.2.1 installed. Use player A from the walks +above, and a staff member at the website. + +| # | Do this | You should see | +|---|---|---| +| 1 | Staff add a title rule on the server A plays on, e.g. top 3 playtime, and A plays a few minutes | A's chat lines carry the title after BetterChat's own, in its colour, within a minute of A reaching the top 3 | +| 2 | Staff give a site group a chat style, and put A in the group | A's lines take the group's title and colours after the next sync | +| 3 | Staff choose that group as the announcement voice and publish a news post with the server's news on | The post appears in chat with the group's title and colours and **no player's name** | +| 4 | Staff set the server's news to *as a popup* and publish another | A popup on A's screen with the post's title, as plain text | +| 5 | Run `oxide.reload RunicGateway` (or `c.reload`) and A types a line | A's title is still there (D144) | + +**Run steps 1 and 4 on Carbon as well.** + +**What counts as a pass:** a real chat line carries a site title and a site style, the voice speaks +without a sender, and a popup appears on a real screen. diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index f5e5f2b..0c552c2 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -50,7 +50,7 @@ it is listening without one. ## 2. Versioning -The wire version is a single integer — **10** as of the rewards (§16) — declared in +The wire version is a single integer — **12** as of the optional mods (§18) — declared in **four** places that must agree: | Where | Repo | @@ -1833,3 +1833,104 @@ database (D111). | `GET /map/chunk?mapKey=&sha256=&n=` | `map.fetch` | All three required | | `POST /map/render` | `map.render` | Opaque object; `cmd` and `reqId` written over the caller's | | `GET /map/live` | `map.live` | | + +## 18. Protocol 12 — the optional mods + +Added in phase 17 ([`PLAN.md`](../modules/rust/PLAN.md) §33). BetterChat and PopupNotifications +become **optional**: the plugin looks each up when it needs it and answers with a reason when it is +not there (R3). **One new command, two widened commands, one field on the server body, and one console +command.** `overlay.toml`'s `requires_plugins` loses PopupNotifications (D141). + +### 18.1 `titles.set` + +```json +{"cmd":"titles.set","reqId":"r-40","setId":"","titles":[{"steamId":"76561198800000001","text":"[#ff4400]Top Killer[/#]"}]} +``` + +The chat titles each player holds, **as one whole set** that replaces the last. `text` is already in +BetterChat's markup (`[#hex]…[/#]`, space-separated for a player with several); the site composes it +from the current wipe's standings (PLAN.md §33.2). + +- **Answered `titles.ok`** with `count` and `betterChat`: whether BetterChat, as loaded right now, + holds the plugin's getter. +- **Refused `titles.error`** with a `reason` and a `message`: `malformed` (no `titles` array, a + Steam id that is not a number, or an empty or over-long `text` — the whole set is refused, never + half of it), or `too-large` past 500 entries. +- **The plugin swaps the set in by reference**, never editing the one BetterChat is reading, and keeps + it in `titles.json` with the wipe it was pushed on (D144). A hot reload reads the file back when that + wipe is still current; the site re-sends on a new boot id or wipe id as well. +- **The getter** is registered with `API_RegisterThirdPartyTitle` at `OnServerInitialized`, and again + on `OnPluginLoaded` for BetterChat, whose registrations die with its instance. It does one + dictionary lookup and returns null for a player with no title. BetterChat appends it **after** its own + titles and after its `MaxTitles` cut. + +### 18.2 `perm.sync`: a group's BetterChat style + +A group may carry `chat`, the twelve BetterChat group fields, each with the value the site wants and +the value it last pushed **to this server**: + +```json +{"name":"staff","title":"Staff","rank":5,"permissions":[],"members":[], + "chat":{"TitleColor":{"value":"#ff2200","expect":null},"Title":{"value":"[Staff]","expect":"[Staff]"}}} +``` + +Values are text in the form BetterChat's own setter parses: `true`/`false`, decimal integers, a colour +as `#rrggbb` or a word. The plugin, per field: + +- **leaves it** when the game already holds the value (`same`); +- **writes it** through `API_SetGroupField` when the game holds `expect`, or holds BetterChat's + default and `expect` is null, or the group was created by this sync; +- **reports it** as drift, and does not write it, when the game holds anything else — a hand edit (D138). + +`API_SetGroupField` never saves, so **one `chat group set Priority ` per group +touched**, run as the server console, writes BetterChat's file (D143). A retirement of kind +**`chat-group`** removes the group with `chat group remove`, BetterChat's only way (D139). Both +commands' effects are read back through `API_GroupExists` / `API_GetGroupFields`, never assumed. + +The report gains **`chat`**. With BetterChat absent it is `{ "loaded": false }` and no style op is +compiled; the permissions sync as before. Otherwise: + +| Field | | +|---|---| +| `version` | BetterChat's | +| `applied` | fields written | +| `saved` | groups saved by the console write, read back | +| `same` | fields already right | +| `drift` | `[{ group, field, game }]`, with the game's value | +| `removed` | groups `chat group remove` took out, or that were already gone | +| `failed` | `[{ group, field?, reason }]`: `InvalidField`, `InvalidValue`, `no-answer`, `not-created`, `not-saved`, `still-present`, `betterchat-unloaded`, `malformed` | + +### 18.3 `chat.say`: delivery and a voice + +Two optional fields. A protocol-11 caller sends neither and is answered as before. + +- **`delivery`**: `chat` (the default) or `popup`. A popup is said with PopupNotifications' + `CreatePopupNotification(message, null, 0)`: everybody connected, the plugin's default duration, and + nothing queued for anybody offline. **Without PopupNotifications it is refused + `chat.error { reason: "popup-unavailable" }`**, with a sentence. +- **`format`**: BetterChat markup with exactly one `{message}`, at most 512 characters. The plugin puts + the flattened line in after the 256-character bound (so the bound is the words', not the markup's), + strips `[` and `]` from words that carry `[#` or `[+` (BetterChat's own rule), and turns the markup + into rich text with `covalence.FormatText`. The line has no sender (D140). A popup ignores it. + +`chat.ok` gains `delivery`. **Every line said is logged** (`said (chat): …`), because the game logs +neither a broadcast nor a popup. + +### 18.4 `integrations`, and the console + +`server.hello` and `server.status` carry +`integrations: { betterChat: { loaded, version? }, popupNotifications: { loaded, version? } }`; +`version` only for a plugin that is loaded. + +**`rg.titles [steamId]`** prints how many titles are held, BetterChat's version and whether the getter +is registered, and for a Steam id what is held and what BetterChat renders — through +`covalence.Players`, so only for a player the server has seen. + +### 18.5 The sidecar + +`PROTOCOL_VERSION` becomes 12. One route; `perm.sync`'s and `chat.say`'s new fields pass through +untouched. + +| Route | Command | | +|---|---|---| +| `POST /titles` | `titles.set` | Opaque object; `cmd` and `reqId` written over the caller's |