Merge pull request 'docs(rust): phase 14 plan of record — the live map (PLAN.md §30)' (#271) from docs/rust-phase-14 into main
Reviewed-on: #271
This commit is contained in:
@@ -366,6 +366,11 @@ players and infer base positions. The default posture is monuments and world eve
|
||||
and base layers admin-only, and an operator opening one up is a deliberate act with the consequence
|
||||
stated on the switch.
|
||||
|
||||
**Amended by D110 and D109 (§30.1), 2026-09-24.** The picture is not read from the `.map` file,
|
||||
which is terrain data. It is the Rust+ cache the game already holds, or an admin-requested render.
|
||||
Because the cache costs nothing to read, the site fetches a new map by itself, and "no import on boot"
|
||||
applies only to a render.
|
||||
|
||||
**This is the only asset-bridge work in scope.** Item icons and the 2,590 workshop skin ids stay out
|
||||
of v1; kill feeds and kit lists render as text.
|
||||
|
||||
@@ -1001,7 +1006,7 @@ Each phase ends with its findings written down, as every workstream here does.
|
||||
| 12 | **Events: option sources and the leases** (§9, **as corrected by §27**). ✅ **Built and walked 2026-09-24 on both rigs — as built and findings in §27.5–27.7.** 21 keys walked live, two `spawn.max_*` left out; two defects of its own fixed (the Oxide grant owner, `EventsEnabled` not applying); four found outside it and raised. [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised. **Plan of record in §27 (D73–D79)**: the target names the server, game convars only (vanilla Rust has no gather/craft/smelt rate), the weekend-VIP lease is a group-wide permission, an `EventsEnabled` switch off by default, seven-day holds, and no budget dimension until phase 13; protocol 8 | Module-Rust + 2 + docs | A leased value is observed changing in the running game and restored, per key; `rust.group.permission` expires without core asking |
|
||||
| 13a | **Events: the world verbs** (§9, R17, **split by D80**). ✅ **Built and walked 2026-09-24 on both rigs — plan in §28 (D80–D95), as built and findings in §28.5–28.8 (D96–D97).** A restart mid-run found that the reconcile asked a world that had not loaded, and the plugin pruned live crates on the empty answer; fixed with `worldReady` (§28.7). The owner fix was walked before and after. The placing verb became two (D97), because core infers cap boxes from examples. The phase-7 owner fix (D85); `rust.zone.open` and `rust.prefab.place` (crates and NPCs, D88) at a monument or raw coordinates (split into `rust.crate.place` and `rust.npc.place` by D97); the monument and prefab option sources; the plugin's ownership registry, keyed by the idempotency key; and **`reconcile()` with the boot-id and wipe watch calling `ctx.events.reconcile()`** (§11.1). Protocol 9 | all 3 + docs | A wipe reconciles the ledger instead of stranding it, and each world verb's teardown is observed in the game |
|
||||
| 13b | **Events: the rewards** (§9, R3, R16). ✅ **Built and walked 2026-09-24 on both rigs, without a player — plan in §29 (D98–D105), as built and findings in §29.6–29.9 (D106–D108).** Real core refused the module over one camelCase option-source id, which 310 green tests had not caught (§29.8). The steps that need a person are the rewards walk in `PLAYER_WALK.md`. The participation tally kept by the game (D81–D83), `rust.kit.entitle` through the site mirror's per-run rows (D84) with the kit option source flagging kits with no permission gate, and `rust.announce` with the announce leg (D90). Plan of record in §29, written before its code. Protocol 10 | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it |
|
||||
| 14 | **The live map** (R9). 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 |
|
||||
| 14 | **The live map** (R9). 📐 **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 | Android-app | The map renders on a phone with the same layer gates |
|
||||
| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel |
|
||||
| 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included |
|
||||
@@ -5064,6 +5069,221 @@ users and fake linked Steam ids stood in for players.
|
||||
a person.
|
||||
- **A chat line seen by a player.** The plugin answered `said: true` with `players: 0`.
|
||||
|
||||
## 30. Phase 14 — the live map (plan of record, 2026-09-24)
|
||||
|
||||
R9's map, in two parts that travel differently:
|
||||
|
||||
- **The picture.** It is fetched once per map, so request/reply.
|
||||
- **Everything that moves on it.** Asked for while somebody has the map open, and filtered per viewer
|
||||
by layer.
|
||||
|
||||
Protocol 11. Three repositories: [Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr], into
|
||||
`edge`. This document goes into `main`. **Core is not expected to change and `MODULE_API` does not
|
||||
move.** The row's criterion (§5, row 14): *the map renders for the current wipe, and a player layer is
|
||||
invisible until an operator deliberately opens it.*
|
||||
|
||||
### 30.0 A rig probe that rewrote R9's first half
|
||||
|
||||
R9 says the map image is *"`proceduralmap.<size>.<seed>.<save>.map` beside the world save"*, fetched
|
||||
the way the kit's chapter 3 §2b fetches a client file. **That file is not a picture.** It is the
|
||||
world's serialized terrain: height, splat, biome, topology and water maps, plus prefabs. Turning it
|
||||
into an image means reimplementing the game's renderer. A probe plugin (`RgProbe14`, rig only,
|
||||
2026-09-24, on the Oxide rig with a 3000 map and seed 1234) found where the picture actually is:
|
||||
|
||||
| Fact | Value |
|
||||
|---|---|
|
||||
| The game's renderer | `MapImageRenderer.Render(out w, out h, out background, scale = 0.5, lossy = true, transparent = false, oceanMargin = 500)`, public and static, in `Assembly-CSharp` |
|
||||
| **What it costs** | **8.5 s on the main thread** at scale 0.5 (a 2500 × 2500 JPEG, 360 341 bytes); **21.4 s** at scale 1 (4000 × 4000, 945 649 bytes). The server does nothing else for that long |
|
||||
| Geometry | Pixel size = `worldSize × scale + 2 × oceanMargin`. The margin is in **pixels** and is not scaled: 3000 × 0.5 + 1000 = 2500 and 3000 × 1 + 1000 = 4000 |
|
||||
| **The cache** | **Rust+ already holds the picture.** `CompanionServer.Handlers.Map.ImageData` is a public getter over a cached `byte[]`, byte-identical to a fresh scale-0.5 render (both 360 341 bytes), with `_width` / `_height` = 2500 and `_background` = `#0B3B4A`. It is filled at boot when `app.port` is set, and both rigs set it. It is an in-memory read that costs nothing |
|
||||
| Not yet known | Whether the cache is empty when `app.port` is off. This is expected, because `CompanionServer.Server.Initialize` is what fills it, and it is the **first thing the build proves** (§30.4 step 1) |
|
||||
| Live things | A sweep of all 42 202 entities took 12 ms. The rig counted 34 vending-machine markers, 3 locked crates, 2 supply drops, 109 scientists and 212 tunnel dwellers. It had no cargo ship, helicopter, Chinook or Bradley at that moment |
|
||||
| Line cap | The game link discards a line over 1 MiB (`rust-link/sidecar/src/main.rs`). Base64 of a 6000 map's image (about 4000 px, around 1 MB) does not fit in one line |
|
||||
|
||||
**A probe trap worth keeping:** dumping `CompanionServer.Server`'s static fields by reflection
|
||||
printed the rig's Rust+ server token and secret into `latest.log`. A probe must never enumerate
|
||||
that type's fields again.
|
||||
|
||||
### 30.1 The decisions this phase needed
|
||||
|
||||
All ten came from the org lead on 2026-09-24, asked before this section was written.
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| **D109** | **The picture comes from the Rust+ cache.** When it is absent, an **admin button** renders one on demand, with the stall stated beside it (8.5 s on a 3000 map). Nothing ever renders unasked. Rejected: cache only, with no picture without Rust+; and an automatic render, which is a stall nobody chose. |
|
||||
| **D110** | **The site fetches the picture automatically the first time it sees a map it does not have, but only when the source is the free cache.** An admin button fetches again. **This amends R9's "no import on boot".** That rule was the asset bridge's, written when a fetch was an expensive extraction. Here the module already knows when the map changes, and reading the cache costs the game nothing. A render is never automatic (D109). |
|
||||
| **D111** | **The live layers are request/reply** (`map.live`), asked only while somebody has the map open. The module caches each server's answer for a few seconds, so any number of viewers cost one ask. Positions never land on disk. Rejected: a board on a cadence like `players.online`. It would run with nobody looking and keep positions at rest in the sidecar's SQLite. |
|
||||
| **D112** | **Four layers ship, each with its own switch.** **Monuments and world events** (monuments, cargo ship, patrol helicopter, Chinook, Bradley, supply drops, locked crates): public by default. **Site events** (the zones and placed crates and NPCs of phase 13a's runs, while live): public. **Players** (online players and sleepers, with names): staff. **Bases** (tool cupboards, and vending machines, which usually stand at bases): staff. |
|
||||
| **D113** | **The players layer shows only what both it and the presence audience allow**: the narrower of the layer's switch and D42's presence setting for that server. Opening the map can never widen who sees names. D42 stays the one gate on the roll call. |
|
||||
| **D114** | **The switches are a fleet default plus an optional per-server override**, D45's shape, on Admin → Rust visibility. A PvE server may open player positions that a PvP server must not. |
|
||||
| **D115** | **A linked viewer sees their own position and their online clan mates**, whatever the players layer says. The clan is the first-party clan (R5) on that server. Chosen against my recommendation, which was to leave this out of v1. Rust+ already gives a team this in game, and it is the one position a player most wants from the site. |
|
||||
| **D116** | **The map is drawn with Leaflet**, bundled into the module's chunk. Chosen against my recommendation, which was an `<img>` and an SVG overlay with pan and zoom written by hand. Leaflet brings pan, zoom, pinch and markers mature, at about 40 KB gzipped. |
|
||||
| **D117** | **Clan-mate positions go only to linked members of the same clan**, independent of the roster audience (D48), the presence audience and the players layer. Widening the roster widens who sees the **member list**, never positions. Staff see everybody through the players layer as before. Rejected: letting the roster audience also gate positions, which would publish a clan's positions the day its roster went public. |
|
||||
| **D118** | **The own-and-mates view has a switch, default on**, in D114's fleet-plus-override shape. On by default because it leaks nothing a member could not learn in game. It is a switch because an operator may still want the site quieter than the game. |
|
||||
|
||||
### 30.2 What phase 14 builds
|
||||
|
||||
**Rust-Plugins**: protocol 11, and `overlay.toml`'s protocol advanced to match.
|
||||
|
||||
- **`map.info`**: stage one, *what exists*. It answers `mapKey`, `source` (`companion`, `rendered`
|
||||
or `none`), `bytes`, `sha256`, `width`, `height`, `oceanMargin` (500), `worldSize`, `background`,
|
||||
`chunks` and the monuments. It does **no work** beyond reading the cache and hashing it once per
|
||||
map. The hash is computed off the main thread and remembered.
|
||||
- **`mapKey`** is `<worldSize>.<seed>.<salt>` for a procedural map, or the SHA-256 of
|
||||
`server.levelurl` for a custom one. `sha256` is the picture's own identity. The key says *which
|
||||
map*, and the hash says *which picture of it*.
|
||||
- **The monuments** are `TerrainMeta.Path.Monuments` that have a display phrase: kind, English
|
||||
name, the `<kind>#<n>` token 13a's monument source already uses, and x/z. The in-game map shows
|
||||
only monuments with a display phrase, and the site shows the same set. They change only with the
|
||||
map, so they belong to stage one and not the live ask.
|
||||
- **`map.fetch { mapKey, sha256, chunk }`**: stage two. One 512 KiB slice of the JPEG, base64, the
|
||||
asset bridge's phase-7 shape. It is refused with `stale` when the key or hash moved, so a fetch
|
||||
that straddles a wipe cannot splice two maps. **One fetch in flight per server**: a second is
|
||||
refused with `busy`.
|
||||
- **`map.render`**: the admin fallback (D109). It is refused unless `source` is `none`, calls
|
||||
`MapImageRenderer.Render` with scale 0.5 and margin 500, which is the cache's geometry exactly, and
|
||||
keeps the result in `oxide/data/RunicGateway/map.jpg` beside its key, so a restart within the map
|
||||
does not ask for another render. **It is refused while the world is not ready** (13a's
|
||||
`worldReady`). It answers `accepted` at once and renders on the next frame, because the render
|
||||
outlasts the sidecar's 10 s reply timeout on any map larger than the rig's. The module then polls
|
||||
`map.info` until `source` is `rendered`.
|
||||
- **`map.live`**: what moves. One answer with every layer in it: filtering is the site's job
|
||||
(§8.5's rule: the class is enforced by the module). It holds `world` (cargo, heli, chinook,
|
||||
bradley, supply drops, locked crates with seconds left on the hack), `events` (13a's registry:
|
||||
zones with radius, placed crates and NPCs, each with its run id), `players` (Steam id, name, x/z,
|
||||
`sleeping`) and `bases` (tool cupboards and vending machines, **positions only**: no owner, no
|
||||
authorised list, no shop name).
|
||||
- **No sweep per ask.** The world, event and base sets are kept by `OnEntitySpawned` /
|
||||
`OnEntityKill`, filtered to the handful of types, and seeded by one sweep at
|
||||
`OnServerInitialized`. Players come from `activePlayerList` and `sleepingPlayerList`. An ask
|
||||
copies positions out of those sets, which is bounded by what the sets hold.
|
||||
- **Bounds**: `MapMaxSleepers` and `MapMaxBases` (default 2000 each). Over the bound, that layer
|
||||
answers the first N with `truncated: true`, and the page says so. This is not D95's refuse-never-
|
||||
clamp rule, which is about a value an author typed. A read has nobody to refuse.
|
||||
- **Every map verb is outside `EventsEnabled`.** It reads, and never changes the world.
|
||||
|
||||
**Rust-Link**: protocol 11. `GET /map`, `GET /map/chunk?mapKey=&sha256=&n=`, `POST /map/render` and
|
||||
`GET /map/live`, stamped and forwarded like the reward routes. Nothing is stored: the picture passes
|
||||
through, and positions never touch the database (D111).
|
||||
|
||||
**Module-Rust**:
|
||||
|
||||
- **`rust_map_images`**: one row per server holding the current picture: `server_id`, `map_key`,
|
||||
`sha256`, `source`, `width`, `height`, `ocean_margin`, `world_size`, `background`, `derivation`,
|
||||
`monuments` (JSON), `bytes` (`MEDIUMBLOB`) and `fetched_at`. A new map replaces the row. The
|
||||
`purge.sql` pair is updated with it.
|
||||
- **The fetch (D110)** runs off the poll. When `server.hello`'s `seed` / `worldSize` / `wipeId` move,
|
||||
or the server has no row, the module asks `map.info`. If the key or hash differs from the row and
|
||||
the source is `companion` or `rendered`, it fetches the chunks in order, checks the SHA-256 of the
|
||||
whole, and replaces the row in one statement. A failure keeps the old row and retries on a backoff.
|
||||
**One fetch per server at a time**, held in the module.
|
||||
- **`DERIVATION_VERSION`** (R9): how the module turns `map.info` into geometry, starting at 1. A row
|
||||
whose `derivation` is older is re-derived from `map.info` without fetching the bytes again.
|
||||
- **The visibility model** gains the map. The four layers take their audience from
|
||||
`public` / `signed_in` / `staff` (D44's rungs, R9's "public / players / admin"). Fleet defaults live
|
||||
in `rust_settings` (`map.layer.<layer>.audience`, `map.mates`), and overrides in a new
|
||||
`rust_map_overrides (server_id, setting, value)`, rather than five more columns on `rust_servers`.
|
||||
The players layer's effective audience is `narrower(layer, presenceFor(server))` (D113). The
|
||||
asymmetric fallbacks carry over unchanged: an unknown stored value reads as `staff`, and an unknown
|
||||
viewer as `public`.
|
||||
- **Public routes**, all `Cache-Control: private, no-store` with `Vary: Cookie, Authorization` except
|
||||
the picture:
|
||||
- `GET /public/rust/servers/:id/map`: the geometry, the monuments if the viewer may see them, the
|
||||
picture's URL, and **which layers this viewer gets and why not the others** (the §23.3 shape:
|
||||
a hidden layer says who can see it, never what it holds).
|
||||
- `GET /public/rust/servers/:id/map/image?v=<sha256>`: the JPEG, `Cache-Control: public,
|
||||
max-age=31536000, immutable`, because the hash is in the URL.
|
||||
- `GET /public/rust/servers/:id/map/live`: the cached `map.live` answer **projected for this
|
||||
viewer**. Layers above the viewer are removed on the server, never sent and hidden in the browser.
|
||||
`mates` carries the viewer's own position and their online clan mates when D118's switch is on
|
||||
and the viewer is a linked member (D115, D117). Below the players layer, it is the only place a
|
||||
position can appear.
|
||||
- **The live cache (D111)**: one `map.live` per server per 5 seconds at most, whoever asks. The
|
||||
cache is in memory and holds every layer, and each request is projected from it.
|
||||
- **Admin**: a **Map** card on Admin → Rust visibility (D106's reasoning: the one page with a row per
|
||||
server). For each server it shows the four layer audiences and the mates switch, with the fleet
|
||||
defaults above them, saved by the page's one `PUT`, which gains `map`. Beside them are the picture's
|
||||
state (source, key, fetched when) and two buttons: **Fetch again**, and **Render now**, shown only
|
||||
when the source is `none`, with the stall stated for that server's world size. Those two are
|
||||
`POST /admin/rust/servers/:id/map/fetch` and `/render`, `requireRole('admin')`, each one
|
||||
activity-log row. The swagger fragment is regenerated.
|
||||
- **The page**: a **Map** tab on the server page (D13's one page with tabs), and the URL keeps the tab
|
||||
as the other tabs do. Leaflet with `CRS.Simple` over the picture, bounds from the geometry, the
|
||||
grid Rust players know (`A0`, 150 m cells, lettered from the north-west) drawn as a layer, and a
|
||||
legend with each layer's checkbox. A layer the viewer cannot see is listed, unchecked and disabled,
|
||||
with who can. Circle and div markers only, so Leaflet needs no image assets. Its CSS is imported as
|
||||
a string and injected once as a `<style>` (core's CSP allows `style-src 'unsafe-inline'`). It is
|
||||
**polled every 10 seconds while visible** (D14) and not at all while hidden.
|
||||
- **Capability `map`** in `module.json`, so the Android app (phase 15) can gate the screen on it
|
||||
(§2.9: an unknown capability is absent).
|
||||
|
||||
**docs**: this section as built, the phase row, R9 amended by D110, and `rust-link/PROTOCOL.md` §17
|
||||
for protocol 11.
|
||||
|
||||
### 30.3 How world coordinates reach a pixel
|
||||
|
||||
Rust's world is centred on the origin, x east and z north. With `s = (width − 2 × margin) / worldSize`:
|
||||
|
||||
px = (x + worldSize / 2) × s + margin
|
||||
py = height − ((z + worldSize / 2) × s + margin)
|
||||
|
||||
This is what the Rust+ app does with `AppMap`'s `oceanMargin`. The walk proves it by drawing
|
||||
13a's monument positions over the picture (§30.4 step 2). If the cache's margin or scale ever
|
||||
changes, `DERIVATION_VERSION` is how it is corrected without fetching again. The grid label is
|
||||
`<column letters><row number>`, 150 m cells counted from the map's north-west corner, and it is
|
||||
checked against the in-game map at the same place.
|
||||
|
||||
### 30.4 Phase 14 is done when
|
||||
|
||||
Walked on the Oxide rig against real core, then the picture and the switches on Carbon.
|
||||
|
||||
1. **The cache, and its absence.** With `app.port` set, `map.info` answers `companion` and the site
|
||||
fetches the picture by itself on first sight (D110). With `app.port -1`, it answers `none`, the
|
||||
page draws layers on a plain background, and **Render now** makes one. The stall is measured, and
|
||||
a restart within the map keeps `rendered` without asking again.
|
||||
2. **The geometry.** Monuments drawn from `map.info` sit on the monuments in the picture, and the grid
|
||||
label of three points matches the in-game map.
|
||||
3. **A wipe.** After a wipe, the next poll sees a new key, the picture is replaced, and the old one is
|
||||
gone. A fetch that straddles the wipe is refused `stale` and does not splice two maps.
|
||||
4. **The switches, as the security boundary.** At the defaults, an anonymous viewer's `map/live`
|
||||
carries world and event layers and **no players or bases field at all**. A signed-in viewer gets
|
||||
the same. Staff get everything. Widening the players layer to `public` while presence stays
|
||||
`staff` shows anonymous viewers **nothing more** (D113). Widening both shows them. A per-server
|
||||
override opens one server alone.
|
||||
5. **Own dot and mates.** A linked viewer who is not staff, with the players layer at `staff`, sees
|
||||
their own marker and an online clan mate, and not a player outside their clan. A roster widened
|
||||
to `public` changes nothing about who sees positions (D117). The switch off removes both.
|
||||
6. **Live things.** A cargo ship or patrol helicopter spawned by console appears within one poll and
|
||||
moves. A 13a zone and crate appear while their run is live and go at teardown.
|
||||
7. **Cost.** With viewers polling, the sidecar sees at most one `map.live` per 5 s per server, and the
|
||||
plugin's timing of an ask is recorded.
|
||||
8. **Carbon.** `map.info`, the fetch and `map.live` on the Carbon rig.
|
||||
|
||||
Steps 5 and parts of 4 need a real player's position. The rig has no player, so they are walked with
|
||||
a sleeper made by the probe, standing in for a connected player. A connected player is left to the
|
||||
map walk in `PLAYER_WALK.md` if the stand-in cannot prove it.
|
||||
|
||||
### 30.5 Readings the org lead may overrule
|
||||
|
||||
The build needs these, and none of them was asked. They are written down so review can overrule
|
||||
them before code.
|
||||
|
||||
- **The picture and the monuments are public at every setting.** They are derived from a seed that
|
||||
anybody can render on a public map site, and they say nothing about who plays. The switches govern
|
||||
what moves. Monuments are in the first layer only so that an operator can hide them with the world
|
||||
events.
|
||||
- **The picture is stored in the database**, not in core's upload directory. `/uploads` is served by
|
||||
core to anybody by URL. A database row goes with the module's purge, and keeping one row per server
|
||||
bounds it at about 1 MB each.
|
||||
- **Own dot includes the viewer's sleeper** when they are offline. Mates are online only, as D115
|
||||
says.
|
||||
- **Tool cupboards and vending machines carry positions only**: no owner, no authorised list, no shop
|
||||
name. A shop name is text a player typed, and an owner is a name.
|
||||
- **The live poll is 10 seconds and the cache 5.** Rust+ refreshes its map markers at about that
|
||||
speed, and a slower poll makes a moving ship jump.
|
||||
- **Locked crates, supply drops and the helicopters are in "world events"**, not their own layers.
|
||||
D112 grouped them, and one switch per group is what an operator reasons about.
|
||||
|
||||
---
|
||||
|
||||
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
|
||||
|
||||
Reference in New Issue
Block a user