diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 8c347f3d..8698f8be 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -1,7 +1,9 @@ # MeshBench agent skills The skills an agent loads when working in this repository. Each is a directory -with a `SKILL.md` whose front-matter `description` says when it applies. +with a `SKILL.md` whose front-matter `description` says when it applies. The +front-matter `name` matches the directory, because the directory is what an +agent invokes and a `name` that says something else is a label nobody sees. | skill | for | |---|---| @@ -14,11 +16,31 @@ with a `SKILL.md` whose front-matter `description` says when it applies. So they can be installed into any agent, not only one working in this repo, the skills are mirrored into two standalone repositories: -- **[meshbench-scripting-skills](https://github.com/MeshBench/meshbench-scripting-skills)** - — `meshbench-scripting` and `meshcoresim`: driving and scripting the tool. -- **[meshbench-dev-skills](https://github.com/MeshBench/meshbench-dev-skills)** - — `wb2-design-language`: developing the tool. +- **[meshbench-scripting-skills](https://github.com/MeshBench/meshbench-scripting-skills)**: + `meshbench-scripting` and `meshcoresim`, driving and scripting the tool. The + second is installed there under the directory name `meshbench-driving`, + which is the one difference between the two trees. +- **[meshbench-dev-skills](https://github.com/MeshBench/meshbench-dev-skills)**: + `wb2-design-language`, developing the tool. -**The copies here are canonical.** When one of these skills changes, update the -mirror repository in the same breath, or the installed copies go stale — the -mirror READMEs say the same from their side. +**The copies here are canonical.** The mirroring is a manual copy: nothing +generates it, nothing checks it, and a mirror has already been found a line +behind. When one of these skills changes, update the mirror repository in the +same breath, and diff the two before believing they agree. + +The docs site explains what each skill is for and how to install it into +Claude Code, Cursor, VS Code, Gemini CLI and Codex: +. + +## The register they are written in + +A skill is not reference material. `docs/scripting-verbs.md` already lists every +verb; a skill earns its place by saying what an agent would get wrong without +it, with the reason attached, so the rule survives the case it did not +anticipate. + +Two consequences worth keeping. **A line that states a fact must name what +settles it**, a file, a verb or a command, because a stale skill is acted on +with more confidence than no skill at all. And **a count nobody generates does +not go in**: cite `tools/verbdoc/verbdoc.py`, or ask the running session, rather +than typing a number that will be wrong in a month. diff --git a/.claude/skills/meshbench-scripting/SKILL.md b/.claude/skills/meshbench-scripting/SKILL.md index 163a23a6..4d8bbf25 100644 --- a/.claude/skills/meshbench-scripting/SKILL.md +++ b/.claude/skills/meshbench-scripting/SKILL.md @@ -1,30 +1,79 @@ --- name: meshbench-scripting -description: Write and debug scripts that drive the MeshBench workbench from outside — the Python or Go client, the control socket, or raw verbs. Use when writing an example, a CI regression check, a soak driver, or anything that opens a session, brings a mesh up and waits for it. Encodes what twenty-four silent failures taught, and the shape of the ones still out there. +description: Write and debug scripts that drive the MeshBench workbench from outside, with the Python, Go or Node client, the control socket, or raw verbs. Use when writing an example, a CI regression check, a soak driver, or anything that opens a session, brings a mesh up and waits for it. Encodes what twenty-four silent failures taught, and the shape of the ones still out there. --- # Scripting the workbench -Two clients — `pkg/client-python/meshbench` and `pkg/client-go/meshbench` — over a -line-delimited JSON socket. They are peers; neither wraps the other. Examples -live beside each, one per directory, and `go build ./...` compiles the Go ones -so a broken example is a red build rather than something somebody finds by -trying it. +Three clients over one line-delimited JSON socket: `pkg/client-python/meshbench`, +`pkg/client-go/meshbench`, `pkg/client-js`. They are peers; none wraps another. +Python and Go ship the same seven examples, one per directory, and +`go build ./...` compiles the Go ones so a broken example is a red build rather +than something somebody finds by trying it. + +Read `docs/driving-the-workbench.md` for the verb-level story, and +`docs/scripting-verbs.md` for every verb and the call that covers it. This is +about writing something that works. + +**Never write down how many verbs there are.** `tools/verbdoc/verbdoc.py` +generates the counts into `docs/scripting-verbs.md` and `docs/scripting-api.md` +and CI runs it with `--check`; ask `session.verbs` at runtime, or cite the +generator. Several numbers typed by hand have already gone stale, and a number +nothing checks is worse than no number. + +## The one rule, in two shapes + +**A parameter the workbench cannot understand is now refused. Everything else it +declines, it declines by returning a value.** + +Refusals travel as `{"id":…, "error":"", "code":"…"}`. +The code is the closed set in `internal/app/control/codes.go`: `bad_params`, +`not_found`, `conflict`, `unavailable`, `unknown_verb`, `closing`, +`protocol_mismatch`, `unauthorised`. **Branch on the code, never on the prose** - +the message is deliberately the verb's own wording and will be reworded. Python +raises a subclass of `Refused`; Go returns a `*meshbench.Refused` that +`errors.Is` matches against `ErrBadParams` and friends. + +That half is new, and it closed a whole class: `nodes.select_many` handed +`{"names": […]}` used to match neither branch of a type switch, deselect +everything, and answer `{"selected": null}` as though it had worked. It now +refuses, names the shape it takes, and on an unknown node lists the nodes that +exist. A `nil` parameter still legitimately clears the selection. + +The other half has not changed, and it is still where scripts die. Twenty-four +faults were found by running the seven examples end to end and **every one was +silent**. A verb that answers "I did not do that" with a perfectly valid result +turns into a script that sits there doing nothing, and "it hangs" is what gets +reported, about the workbench rather than about the script. -Read `docs/driving-the-workbench.md` for the verb-level story. This is about -writing something that works. +So: **read the reply of anything that looks like a command**, and when a wait +times out, suspect the premise of the wait before you suspect the simulator. -## The one rule +## Terrain is not downloaded until somebody says yes -**The workbench answers "no" by returning a value, not by raising.** +A fresh machine has never been asked, and the preference +(`terrain_downloads` in `~/.config/meshbench/workbench2.json`) has three states, +not two: allowed, refused, never asked. Until it is answered, the tile store is +offline, `terrain.prefetch` refuses, and the first warm is held. -Twenty-four faults were found by running the seven examples end to end. Every -single one was silent. Not one raised. A script that ignores a reply turns a -clear refusal into a script that sits there doing nothing, and "it hangs" is -what gets reported — about the workbench, not about the script. +**A held warm does not hang, which is worse.** It marks the `links` job finished +*and failed* with "waiting for permission to download terrain", then sets the +session warmed so nothing is left in flight. A script that opens a fixture and +calls `wait_idle` therefore returns almost at once, with no jobs running and +**zero links measured**, and every study downstream runs over bare earth, which +is the most optimistic answer available. Nothing raises. -So: **read the reply of anything that looks like a command**, and when a wait -times out, suspect the premise of the wait before you suspect the simulator. +So a script that needs terrain does one of: + +- call `terrain.allow` (the `on` parameter defaults to true) and wait for the + warm it restarts, or +- wait on the `links` job specifically and check its `failed` flag, which is the + only place the sentence surfaces, or +- open nothing: launch with `-fixture ""` when the measurement does not want a + network. + +There is no environment variable and no CLI flag for this. Headless is not +exempt: `cmd_headless.go` loads the same preferences the window does. ## Bring a run up with the client, never with sim.start @@ -38,7 +87,7 @@ times out, suspect the premise of the wait before you suspect the simulator. | otherwise | plays | `{playing: true}` | Only the last is what a script means, and it starts firmware only when *no* -node is running — so a mesh where you pinned a build onto two nodes is +node is running, so a mesh where you pinned a build onto two nodes is "started" with the other fifty-six down. Use `wb.sim.start()` / `Sim.Start(ctx)`, which wait out the warm, call @@ -48,12 +97,18 @@ must drive it by hand, do those three things in that order and check each. ## Waits, and the premises that make them lie - **Wait for the fixture, not just the socket.** The windowed build opens its - fixture on a worker so the window appears first. `launch(fixture=…)` now - waits for nodes to exist; anything else you write should too, or `wait_idle` - returns in 0.00s having waited for work nobody had queued. -- **`wait_idle` ignores finished jobs.** Some jobs are removed when they end - and some are only marked. Waiting for the list to *empty* waits for ever on - half of them. + fixture on a worker so the window appears first. `launch(fixture=…)` waits for + nodes to exist; anything else you write should too, or `wait_idle` returns in + 0.00s having waited for work nobody had queued. +- **A finished job is sometimes removed and sometimes only marked.** + `job.progress` with `finished` keeps the row (`infer.run`'s is one of those); + `job.done` deletes it. Waiting for the list to *empty* waits for ever on half + of them, which is why both clients filter on the flag. `job.list` returns the + rows with a `running` count and drops finished ones unless you pass + `all: true`; note that both shipped clients still read the session snapshot + rather than that verb, so a change to one is not a change to the other. +- **A finished job is not a successful one.** Check `failed`. A read that could + not reach the feed, and a warm held for terrain consent, both end the job. - **Idle is not measured.** A warm that stopped to ask permission to download terrain finishes its own job row, so `wait_idle` returns in a moment having waited for nothing and no link was measured. `sim.state` answers @@ -69,8 +124,48 @@ must drive it by hand, do those three things in that order and check each. everything. - **A diagnostic can cost more than the thing it diagnoses.** `nodes.stats` is a `/proc` read per node. Calling it every poll during firmware startup timed - the socket out. Enrich a wait's message rarely — every ten seconds, not every - fiftieth of a second. + the socket out. Enrich a wait's message rarely, every ten seconds rather than + every fiftieth of a second. + +## Ask the session what it is missing + +`setup.check` is read-only, touches no network, and answers with four groups of +rows, each `{name, state, what, cost, where, do, verb, params}` over the states +`ready | needed | missing | undecided | blocked`. It covers what kind of build +this is, what firmware is installed, whether terrain has been answered, and +which emulator tools are present. A row carries the verb that would fix it, so a +first-run script can read the check and act on it rather than guessing at +`~/.cache`. `needed + undecided > 0` is what makes the workbench open its Setup +page unprompted, three seconds in. + +**`session.list` answers what is running before you attach to anything.** In +the clients it is a module or package function rather than a method, precisely +because the question comes before a connection. Liveness is proved by dialling +the address: a unix socket file outlives the process that bound it, and a pid +gets reused, so a script that checks either can attach to a corpse. That is +also the verb to reach for when one run is about to trample another, because +one mesh at a time is a real constraint on this machine. + +## Counts where the rows were the question + +Five verbs answered with a number and left the rows where only a panel could +reach them. All five are fixed and keep the old count key beside the new list: +`nodes.stats` (`stats`), `firmware.library` (`builds`), `console.read` (`tail`), +`boundary.list` (`areas`), `resource.list` (`resources`). + +**Four are still out there**, and the rows genuinely cannot be reached from a +script, because `session.snapshot` publishes counts for exactly these: + +| verb | answers | where the rows go | +|---|---|---| +| `budget.for_selection` | `{budgets: n}` | `World.Budgets`, in no summary and in neither client | +| `sweep.run` | `{arms, seeds}` | `World.Matrix`, read by one panel | +| `schedule.add` | `{sends: n}` | `World.Sends`; there is no `schedule.list` | +| `assert.add` | `{assertions: n}` | `World.Assertions`; `assert.check` does return `results` | + +The shape is worth recognising: **if a verb's reply is an `int` where you wanted +a list, look for a second key before assuming the data is not there** - and if +there is no second key, say so rather than working around it silently. ## Two consoles, and there is no `send` @@ -85,8 +180,9 @@ directly, pick by kind yourself. The vocabulary is `advert`, `floodadv`, `public `, `chan `, `infos`, `ver`, `contacts`, `sync_msgs`, `set`, `time`. **There is no `send`.** -`console.read` returns the lines under **`tail`**; `lines` is how many there -are. Reading `lines` hands you an integer where you asked for text. +`console.read` returns the lines under **`tail`**, capped at the last 200; +`lines` is how many there are. Reading `lines` hands you an integer where you +asked for text. Use `ask()`, not `send()` then `read()`: a node reads its serial input on its next loop and its loop only runs when the engine steps, so reading straight @@ -98,21 +194,13 @@ Verbs are keyed on the **application name**, as MeshCore names its example directory: `simple_repeater`, `companion_radio`, `simple_room_server` (plus `_usb` / `_ble` for board images). Use the `Role` enum. -The published catalogue spells some of the same things differently — +The published catalogue spells some of the same things differently: `repeater`, `room-server`. Those belong to release assets. Pin a build under one and it is installed, visible, and never run by anything. `Firmware.Download`'s role is the *asset* name and is deliberately a plain string. Everything else takes `Role`. -## Counts where the rows were the question - -Four verbs answered with a number and left the rows in the snapshot where only -a panel could reach them: `nodes.stats`, `firmware.library`, the study area, -and `console.read`. All four are fixed, and the shape is worth recognising — -if a verb's reply is an `int` where you wanted a list, check for a second key -before assuming the data is not there. - ## Enums, not strings `Kind`, `Board`, `Preset`, `Role`, `Class`, `Tab`, `Strategy`, `Transport` are @@ -120,40 +208,55 @@ generated by `tools/clientgen` from the tree, so both clients agree and CI fails when they drift. Never spell one as a free string: a board name nothing matches produces a different node, silently. +`Class` is the miss-cause set, and it grew: `sent`, `received`, `half-duplex`, +`interference`, `collision`, `receiver-busy`, `floor`, `unclassified`. Code that +matched on `floor` as the catch-all now silently sees fewer of them. Group an +`events.dump` NDJSON file on the `class` field, never on the detail sentence. + ## Running examples on this machine - **One mesh at a time.** Two 58-node fixtures at once will make the socket time out and look like a deadlock. I lost two debugging cycles to this. -- Point `MESHBENCH_BINARY` at the build under test; both clients honour it. +- Point `MESHBENCH_BINARY` at the build under test; the clients honour it. - Unix socket paths are capped at 104 bytes. The scratchpad path is longer than that, so let the client choose the address. -- A killed run can leave a QEMU emulator behind. Check `pgrep -f qemu-system`. +- **An emulated board no longer needs a hand-built toolchain.** + `resource.fetch` downloads `radioserver`, `qemu-system-xtensa` and `renode` + into `~/.cache/meshbench/tools/`, which is where a boot already looks, so no + environment variable is needed afterwards. **Pass `kind: "toolchain"`**: the + parameter defaults to `softdevice`, and a fetch that omits it asks for the + wrong thing. QEMU and Renode are published for linux/amd64 only; macOS gets + `radioserver` alone and Windows nothing, and `resource.list` says which with + a reason. +- A killed run can leave an emulator behind. Check `pgrep -f qemu-system`. - Firmware roles on disk: `ls ~/.cache/meshbench/firmware/native/`. ## Debugging a script that has stopped In this order, because each step invalidated a theory today: -1. **What else is running?** `pgrep -af meshbench`. Load, not logic. +1. **What else is running?** `pgrep -af meshbench`, or `session.list`. Load, not + logic. 2. **Attach and ask.** A second client can attach while the first is stuck: - `describe`, `firmware.state`, `jobs`, `nodes.stats`. That is how the - `56 of 58` and the unfinished warm job were both found in seconds. + `describe`, `firmware.state`, `job.list`, `setup.check`, `nodes.stats`. That + is how the `56 of 58` and the unfinished warm job were both found in seconds. 3. **Reproduce the exact sequence, timed.** Proving the general case healthy says nothing about the specific one. A general responsiveness test said the windowed workbench was fine; example 5's actual sequence was not. -4. **Then read the verb.** Not before — twice today the code looked correct and +4. **Then read the verb.** Not before: twice today the code looked correct and the behaviour was not. ## Defects queue behind each other -Today's chain ran four deep: `sim.start` ignored → firmware never up → traffic -never fired → the assertion could not pass → the fixture had no traffic to -fire. Fixing the outer one is what makes the inner one visible, so **"one more -fix and it will work" is a bad prediction.** Budget for the chain. +Today's chain ran four deep: `sim.start` ignored, so firmware was never up, so +traffic never fired, so the assertion could not pass, and the fixture had no +traffic to fire anyway. Fixing the outer one is what makes the inner one +visible, so **"one more fix and it will work" is a bad prediction.** Budget for +the chain. And the one that passed: example 2 exited 0, printed a cheerful summary, and had put a local build on a 311-node national network because it decided "first -run" by asking whether the session was empty — and a launched workbench never +run" by asking whether the session was empty, and a launched workbench never is. **A green exit code is not evidence.** Check the numbers say what the script claims. @@ -165,3 +268,7 @@ claims. goroutine and a worker both reach. - Nothing in CI brings a mesh up and waits for it. Green means it compiles and the unit tests pass; it does not mean an example runs. +- The live emulator tests skip unless `MESHBENCH_LIVE=1`, and they still gate on + `MESHBENCH_QEMU` or `PATH` rather than on the lookup a boot uses. A machine + set up entirely by `resource.fetch` therefore skips them while being perfectly + able to run a board. diff --git a/.claude/skills/meshcoresim/SKILL.md b/.claude/skills/meshcoresim/SKILL.md index 08c8389c..6929626d 100644 --- a/.claude/skills/meshcoresim/SKILL.md +++ b/.claude/skills/meshcoresim/SKILL.md @@ -1,24 +1,33 @@ --- -name: meshbench -description: Drive MeshcoreSim to answer RF and mesh-network questions — link viability, coverage, why a packet failed, site selection, solar survival, firmware A/B. Use when asked about MeshCore network behaviour, repeater placement, coverage, or radio settings. Encodes the honesty rules the simulator's own results depend on. +name: meshcoresim +description: Drive MeshBench to answer RF and mesh-network questions, such as link viability, coverage, why a packet missed, site selection, solar survival, firmware A/B. Use when asked about MeshCore network behaviour, repeater placement, coverage, or radio settings. Encodes the honesty rules the simulator's own results depend on. --- -# MeshcoreSim +# MeshBench An RF-accurate MeshCore simulator: real firmware, sample-accurate LoRa baseband, -real terrain. Plane project **MSIM**; decisions are ADR-0001…ADR-0018. +real terrain. Plane project **MSIM**; decisions are recorded as ADRs, in the +project's Pages and, where a decision is about the tree, under `docs/`. Load `plane-conventions` if you are also updating tickets. ## Driving it -It is a **native desktop app**, not a CLI or a service, so it needs a machine -with a display and it has to already be running. You drive the *running* app -over its control socket at +It is a **native desktop app**, not a CLI or a service, so the socket needs a +workbench already running (`meshbench workbench`, or `meshbench headless` for a +session with no window). You drive it over `$XDG_RUNTIME_DIR/meshbench.sock`, newline-delimited JSON, -`{"id":1,"method":"","params":{}}`. `session.describe` lists every verb; -read that before inventing a way to do something, because there is almost always -a verb for it. +`{"id":1,"method":"","params":{}}`. `session.describe` lists every verb +and `session.list` names the workbenches actually running; read the first before +inventing a way to do something, because there is almost always a verb for it. + +**Some questions do not need a session at all.** `meshbench link`, `profile`, +`coverage` and `terrain` are one-shot subcommands over their own tile store, and +they are the quickest route to a number. They do not read the workbench's +preferences, which is both why they work on a machine that has never opened the +app and why their defaults are their own: `meshbench link` defaults to +869.525 MHz, the **deprecated** preset, so pass `-freq 869.618` to compare with +anything the app produced. Prefer the verb over the file. Verbs drive the same code paths a person clicks, so the panel opens and the operator can see what you did; editing config or @@ -38,20 +47,35 @@ UDP stream on 127.0.0.1:5555 and launches Wireshark; it survives the engine rebuild each sweep run does, but a restarted workbench has no capture at all. "Wireshark shows nothing" after a restart means nobody started it. -## Building a scenario from CoreScope — the whole order +## Terrain is off until somebody allows it, and that is silent + +A machine that has never been asked runs with the tile store **offline**. The +first warm is then held: it marks the `links` job finished *and failed* with +"waiting for permission to download terrain" and marks the session warmed, so a +wait returns at once with **zero links measured**, and every study runs over +bare earth, which is free space, which is the most optimistic answer there is. +Nothing raises, and the map still draws. + +`terrain.allow` answers the question and restarts the held warm; `setup.check` +reports it as `undecided` alongside everything else a fresh install is missing. +**Check one of the two before quoting any margin**, because a result computed +over absent terrain looks exactly like a result computed over flat ground, and +the difference is every hill between the two ends. + +## Building a scenario from CoreScope: the whole order Do these in order. Every step below was skipped at least once, and each failure looks like bad RF rather than a missing step. -1. **Boundaries.** `boundary.set` (place) → `boundary.accept`, once per region. - The chosen set unions, so Scotland + Ireland is two accepts. -2. **Import.** `import.set_source` → `import.fetch` → `import.commit` with +1. **Boundaries.** `boundary.set` (place) then `boundary.accept`, once per + region. The chosen set unions, so Scotland plus Ireland is two accepts. +2. **Import.** `import.set_source`, `import.fetch`, `import.commit` with `strategy: "replace-all"` (plain `"replace"` is not a strategy name and leaves the demo nodes in, on a different preset). 3. **Firmware, per role.** `firmware.set` with `role: "simple_repeater"` for everything, then again per companion with `role: "companion_radio"`. Or set `repeater_version` / `companion_version` on `experiment.base`. -4. **Regions — `infer.run` then `infer.apply`.** This is the step that gets +4. **Regions: `infer.run` then `infer.apply`.** This is the step that gets forgotten, and it is the one that decides whether anything relays at all. 5. `firmware.start`, then check `firmware.state` says `running == total`. 6. Only then define and start the sweep. @@ -61,15 +85,16 @@ looks like bad RF rather than a missing step. `boundary.load {path}` or `{geojson}` takes a Polygon, MultiPolygon, Feature or FeatureCollection and puts it in the study area. `boundary.set` searches Nominatim, which needs the network and needs the area to have an administrative -name — a catchment, a valley or something drawn in QGIS has neither. +name; a catchment, a valley or something drawn in QGIS has neither. **Before the import, either way.** The import filters at fetch time, so a boundary set afterwards prunes what has already been fetched. `boundary.list` -says what the study area currently holds; the snapshot only carries how many. +answers `areas` with each area's name and ring count; the snapshot only carries +how many. ### Finding a node you cannot type -Imported names carry emoji and accents — `🏔️ West Lomond 📡` is one real node — +Imported names carry emoji and accents, `🏔️ West Lomond 📡` being one real node, so `nodes.search {"query": "west lomond"}` is how you get a handle on one. It answers `matches[]` ranked best first with a `score`; the tighter name wins, so the exact one beats the one that merely starts the same way. **Check the score.** @@ -79,7 +104,7 @@ that shared one word with the query, silently. ### Getting your own build in `firmware.import {path, role, board, label}`. The `label` is what the library -will know it by and what a node pins — leave it out and it is a timestamp. +will know it by and what a node pins; leave it out and it is a timestamp. Never assume two imports of the same file are the same build: they are two, on purpose, so you can move a node onto the new one and then `firmware.delete` the old by its `path`. Delete only *after* the node is on the replacement; a pin @@ -88,17 +113,18 @@ nothing can honour does not fail until the node next starts. ### The repeater console `console.type {"node": …, "command": …}` runs a line on a node's CLI and returns -what it said — the fastest way to find out what a node actually believes, rather -than what you think you configured. `get name`, `get repeat`, `get flood.max`, -`get path.hash.mode`, `get loop.detect` all read back; `region put `, -`region allowf `, `region default `, `region save` configure regions. +what it said, which is the fastest way to find out what a node actually +believes rather than what you think you configured. `get name`, `get repeat`, +`get flood.max`, `get path.hash.mode`, `get loop.detect` all read back; +`region put `, `region allowf `, `region default `, `region save` +configure regions. -**The command reference is at ** — check +**The command reference is at **; check it before concluding a setting did not apply. There is no `region list` and no `help`; both answer `Err - ??`, which looks like a broken node and is just a command that does not exist. -Console replies come back empty while a sweep is driving the engine — the reply +Console replies come back empty while a sweep is driving the engine: the reply is collected after a 50 ms step, and the experiment owns the clock. Call `experiment.stop` first. @@ -109,7 +135,7 @@ about. A freshly imported node has none, so a scoped message is transmitted by its sender and dropped by all 300 repeaters: **8 transmissions, 0 relays, and a ledger of 137 events.** That reads exactly like a network with no propagation. -Regions are not in the node API — they are **inferred from days of CoreScope +Regions are not in the node API. They are **inferred from days of CoreScope packet traffic**: `infer.run {"hours": 168}`, wait for its job, then `infer.apply`. Applying is a separate call and returns how many nodes it touched; "0 applied" means you inferred and walked away. On ScotMesh a week of @@ -121,15 +147,15 @@ reading goroutine's own callback and refuses when called from anywhere else; before it refused, calling it from outside replaced a finished inference with an empty one. The job is marked finished rather than removed, so a waiter that waits for the job list to *empty* waits for ever, and one that ends when the -list stops changing has to check `failed` — a read that could not reach the +list stops changing has to check `failed`: a read that could not reach the feed ends the job too. The `hours` window is honoured. It used to be accepted, echoed back and discarded, every import reading the most recent 40,000 packets whatever it -said — under two days on ScotMesh, which drops the quiet regions entirely and +said, which is under two days on ScotMesh, drops the quiet regions entirely and reads as a mesh that has gone silent. -### The `#` asymmetry — write the scope with it, the region without +### The `#` asymmetry: write the scope with it, the region without This one cost a whole session. A region is spelled **two different ways** and both are correct: @@ -140,14 +166,14 @@ both are correct: | scope on the wire | **`#`-prefixed** | scope `#sco` | The key in the packet is `sha256("#sco")[:16]`. Ask a companion to send with -scope `"sco"` and it keys its packets `sha256("sco")` — which matches no +scope `"sco"` and it keys its packets `sha256("sco")`, which matches no repeater in existence. Every repeater receives the packet, derives a different key, and declines to forward. **There is no error anywhere.** The senders transmit, the ledger fills with "first time this node heard the message", and nothing relays: 8 transmissions, 0 relays, 137 events. It reads exactly like a mesh with no propagation, and the -temptation is to go hunting through RF, firmware roles and regions — all of +temptation is to go hunting through RF, firmware roles and regions, all of which will look correct, because they are. `experiment.define {"scope": "#sco"}`. The workbench now canonicalises it, but @@ -155,16 +181,42 @@ say it with the `#` anyway. Then **send on a scope the nodes actually hold**. `experiment.define`'s `scope` is applied to the senders only; the repeaters relay it or not according to what -inference gave them. Check the holder counts `infer.apply` reports before choosing — -`#sco` and `#ioi` are the two big ones, and a scope only a handful hold will -look like a dead mesh for the same reason as above. +inference gave them. Check the holder counts `infer.apply` reports before +choosing: `#sco` and `#ioi` are the two big ones, and a scope only a handful +hold will look like a dead mesh for the same reason as above. + +## Antennas: a scalar gain is a wrong answer, not a rough one + +Every node stands under a real pattern now, and the engine is directional in +azimuth, so what a node is pointing at changes the result. + +- `node.antenna {node}` reads one back. A node with **no** antenna answers + `pattern: ""` and `peak_dbi: 0`, deliberately distinguishable from an omni at + 0 dBi. Check which you have before quoting a gain. +- `nodes.antenna` is the only verb that changes one, for a node, a kind, or + everything. It is a **partial overlay**: named fields replace, the rest stay, + so a bearing sweep is one call per step rather than a full restatement. An + unrecognised `polarisation` is refused rather than stored, because an + unrecognised value reads as orthogonal to everything and would take the link + off the air. +- `node.aim {node, at}` computes the great-circle bearing, sets it, and returns + `gain_dbi`, which is what the turn actually won. On an omni that is nothing, + and saying so is the point. + +Two limits to quote with any directional result: the patterns are analytic +Gaussians with a flat front-to-back floor, right on boresight and roughly right +for the first 20 degrees or so, with no side lobes and no nulls; and +polarisation mismatch is charged once per pair in the engine and the link +budget but **not at all in the coverage raster**, so a raster and a budget over +the same crossed pair disagree by up to 20 dB and the raster is the optimistic +one. ## Read the firmware before explaining a result MeshCore is public and the tags match our firmware refs exactly (`repeater-v1.17.0`, `companion-v1.17.0`). Clone `github.com/meshcore-dev/meshcore`, check out the tag under test, and read the -code before writing down a mechanism — a plausible story about what the firmware +code before writing down a mechanism: a plausible story about what the firmware "probably does" is worth nothing next to twenty lines of it. Worth knowing, from `examples/simple_repeater/MyMesh.cpp` at v1.17.0: @@ -175,21 +227,21 @@ Worth knowing, from `examples/simple_repeater/MyMesh.cpp` at v1.17.0: not explaining. - The loop thresholds are **indexed by path-hash size**: `minimal {_,4,2,1}`, `moderate {_,2,1,1}`, `strict {_,1,1,1}`. At a **3-byte - hash all three settings are 1 and therefore identical by construction** — arms - that vary `loop.detect` at 3-byte are measuring nothing, and if they differ, - the simulator has a reproducibility problem rather than a finding. + hash all three settings are 1 and therefore identical by construction**, so + arms that vary `loop.detect` at 3-byte are measuring nothing, and if they + differ, the simulator has a reproducibility problem rather than a finding. - `isLooped()` counts how many times *this node's own hash* already appears in - the packet's path — not whether a hash repeats generally. + the packet's path, not whether a hash repeats generally. - `getPathHashSize() = (path_len >> 6) + 1`, `getPathByteLen() = count × size`, which is where the per-hop airtime cost of a wider hash comes from. **Design a control into the matrix.** Two arms the firmware guarantees are -identical are free reproducibility checks, and one of ours failed — which is how +identical are free reproducibility checks, and one of ours failed, which is how we learned the seed does not capture all the run-to-run variation. ## Regions come from GeoJSON, and there are saved ones -`~/.config/meshbench/boundaries/*.geojson` — Scotland and Ireland are already +`~/.config/meshbench/boundaries/*.geojson`; Scotland and Ireland are already there. `boundary.set` searches for a place, `boundary.accept` adds it to the chosen set, `boundary.prune` deletes every node outside it. **The chosen set unions**, so a two-region scenario is two accepts and one prune. Never @@ -197,9 +249,9 @@ hand-roll a lat/lon rectangle: it silently keeps null-island nodes and cuts real coastline wrong. The import source is **not persisted between launches** and has to be set each -time: `import.set_source` → `import.fetch` → `import.commit`. ScotMesh's +time: `import.set_source`, `import.fetch`, `import.commit`. ScotMesh's CoreScope is `https://scotmesh-corescope.mm7roq.compute.oarc.uk`, and it covers -Scotland, northern England *and* Ireland — around 640 nodes, of which some tens +Scotland, northern England *and* Ireland, around 640 nodes, of which some tens sit at lat/lon 0 and some have no position at all. Say how many you dropped. ## Experiments: the Bench workspace @@ -210,18 +262,17 @@ constants. Then `experiment.seeds`, `experiment.senders`, `experiment.start`, poll `experiment.state`, and `experiment.export` writes an HTML report. **Pin the firmware even when you are not varying it.** `experiment.base` takes -`repeater_version` / `companion_version`, and builds are cached at -`~/.cache/meshbench/firmware/native/` — currently `repeater-` and -`companion-v1.16.0`, `v1.17.0`, and `-faultyirq` variants of both. Freshly +`repeater_version` / `companion_version`. **List the cache rather than trusting +a written list**: `ls ~/.cache/meshbench/firmware/native/`, or ask +`firmware.library`, which now returns `builds` as well as a count. Freshly imported nodes carry no firmware ref at all, which resolves to MeshCore `main`, for which nothing is published; a sweep that varies something else then dies on -its first run with "firmware on 0 of N nodes". The clients expose the same -builds if you would rather ask than list the directory. +its first run with "firmware on 0 of N nodes". **Role is the MeshCore application, not the node kind.** Repeaters run `simple_repeater`; companions run **`companion_radio`**; room servers run **`simple_room_server`**. "companion" is not a role, and `firmware.set` used to -take it without complaint — the run then failed minutes later with " runs +take it without complaint; the run then failed minutes later with " runs no firmware", which reads as a firmware problem and is a typo. The binary names in the cache are the authority (`meshcore--linux-amd64`). @@ -233,7 +284,7 @@ points at the release rather than at the string. A scenario mixing roles has to pin each one separately. **A companion has no command line.** It speaks the companion protocol over its -serial link, so typing `advert` at one does nothing at all — no error, no +serial link, so typing `advert` at one does nothing at all: no error, no packet. That reads as a mesh dropping the first hop. Use a repeater when a test needs to originate from a console, or the companion transport when it needs to be a companion. @@ -253,18 +304,18 @@ mesh of one application. output at `console.log` in its work directory. Read it before theorising: a stale published build stalling after three seconds and a firmware bug look identical from the ledger, and the log tells them apart in one line. A current -build prints `radio_init: entering std_init` — its absence means the binary +build prints `radio_init: entering std_init`; its absence means the binary predates the host radio shim and needs rebuilding upstream. **Check the radio preset after an import.** Imported nodes take the app default, -`EU/UK (Narrow)` — 869.618 MHz, 62.5 kHz, SF8, CR4/8 — which is what ScotMesh +`EU/UK (Narrow)`: 869.618 MHz, 62.5 kHz, SF8, CR4/8, which is what ScotMesh runs, and what the earlier CAD and hash studies used too. Quote it as provenance rather than assuming; a scenario built by hand may sit on the deprecated 869.525 / 250 kHz / SF10, and results either side of that are not comparable. **Diff against a scenario that worked.** When a run produces nothing and the configuration all looks right, load the last project that *did* work and compare -the two JSON files under `~/.config/meshbench/projects/` — radio, regions, +the two JSON files under `~/.config/meshbench/projects/`: radio, regions, firmware refs, scopes. It is far faster than reasoning forwards, and the answer is usually one field. @@ -278,96 +329,79 @@ Three traps, all paid for: and watch be ignored. Firmware was exactly that. - **Measure the thing the change is supposed to affect.** Reach was a set of nodes per message, so an arm that suppressed nine duplicate copies scored the - same as one that suppressed none — every loop-detection comparison came back + same as one that suppressed none: every loop-detection comparison came back "no difference" for a whole session before anyone noticed the metric could not see it. ## Emulation: running the bytes people flash -Two backends run MeshCore. **Native** compiles it for the host and is what -everything so far is built on. **Emulated** runs the published board image -unmodified, under QEMU, and exists as the cross-check on the native one -(ADR-0010). - -An emulated node is now a node on the mesh. A published `Generic_E22_sx1262` -v1.17.0 image boots, adverts, and a native repeater decodes it off the same -channel (`TestEmulatedAndNativeShareAChannel`). A node runs emulated when its -`Firmware.Board` is set; empty means the host build. - -Two constraints that follow from an emulator being in the loop. It runs on -**wall time**, so the engine cannot race the clock ahead — pace `Run` roughly -1:1 or it will look as though no frames arrived. And two runs of one seed will -not produce identical ledgers, so the determinism the rest of the simulator -guarantees does not hold for a scenario containing one. - -**Only the repeater role can be emulated today, and it is board coverage rather -than role code.** The one verified board publishes the repeater alone. Every -plain-ESP32 SX1262 board that publishes all three roles is a T-Beam, and all of -them stall *before* `radio_init`: the I2C bus never comes up, the AXP PMU init -spins, and the radio is never touched — zero SPI transactions, which reads as a -broken emulator rather than a missing PMU model. T-Beam SX1262 also publishes a -BLE-only companion. Every board publishing all three with a USB companion is an -ESP32-S3, so the unlock is an `esp32s3` machine, not more board entries. +Two backends run MeshCore. **Native** compiles it for the host and is what most +of the above is built on. **Emulated** runs the published board image +unmodified, under QEMU or Renode, and exists as the cross-check on the native +one (ADR-0010). A node runs emulated when its `Firmware.Board` is set; empty +means the host build. + +**The toolchain is a download now, not a build.** `resource.fetch` with +`kind: "toolchain"` fetches `radioserver`, `qemu-system-xtensa` and `renode` +into `~/.cache/meshbench/tools/`, which is step three of the lookup a boot +already performs, so nothing has to be set afterwards. The `kind` parameter +**defaults to `softdevice`**, so a fetch that omits it asks for the wrong thing. +`resource.list` says what is present and what it cost; `setup.check` says the +same beside everything else that is missing. QEMU and Renode are published for +linux/amd64 only, macOS gets `radioserver` alone, and Windows nothing, each with +its reason. An emulated nRF52 board additionally needs the Nordic s140 +SoftDevice, which is its own `softdevice` resource. + +**`EmulatableBoards()` is the authority on what runs**, and it returns both the +boards that do and the reason each of the rest does not. Do not carry a list in +your head: it has already moved twice. As things stand it covers plain ESP32, +ESP32-S3 under the fork's `esp32s3` machine, and nRF52840 under Renode. **There +is no role gate**: `emulated.Runnable` filters on image format, on whether the +board has verified wiring, and on transport. Anything that once read as "only +repeaters" was board coverage, and board coverage moves. **BLE companions are excluded deliberately.** There is no Bluetooth here, so one boots and then waits forever for a phone, which looks like a hang rather than an unsupported build. Published companion assets carry their transport in the name (`..._companion_radio_usb-v1.17.0-...`), and the USB one is the only usable one. +Two constraints follow from an emulator being in the loop. It runs on **wall +time**, so the engine cannot race the clock ahead; pace `Run` roughly 1:1 or it +will look as though no frames arrived. And two runs of one seed will not produce +identical ledgers, so **the determinism the rest of the simulator guarantees +does not hold** for a scenario containing one. Run boards one at a time: several +at once will take a twelve-core machine down. + ### Where the pieces live | | | |---|---| | QEMU with our SX1262, GPIO and fixes | `MeshBench/qemu` branch `meshbench-sx1262` | +| Renode with the SEVONPEND fix | `MeshBench/renode` and `MeshBench/tlib` | | The chip model, and the socket server | `meshcore-native`, `VirtualSX1262` + `bridge/radioserver.cpp` | -| Per-board wiring | `internal/firmware/board/boards.go`, `QEMUWiring` | - -Build QEMU with **`--enable-gcrypt`** or the `esp32` machine dies with -`unknown type 'misc.esp32.rsa'`: the RSA device is gated on gcrypt while the -machine references it unconditionally. - - ./configure --target-list=xtensa-softmmu --disable-werror --enable-slirp --enable-gcrypt - -### Only plain ESP32, and only some boards - -`hw/xtensa/esp32.c` instantiates SPI0 to SPI3, so a radio has a bus to sit on. -**ESP32-S3 models only `spi1`**, the flash controller, so an S3 board needs a -GP-SPI controller written before anything can be attached. Published nRF52 -images are linked above a proprietary Nordic SoftDevice and are out of scope. +| Per-board wiring | `internal/firmware/board/board_.go` | Board wiring is **per board and verified per board**, never inferred from the MCU. `Heltec_v2` carries an **SX1276**, not an SX1262, despite sitting beside -the V3 in every shop — its firmware speaks SX127x register access, and the +the V3 in every shop; its firmware speaks SX127x register access, and the giveaway is the firmware sending `0x42`, which is RegVersion on an SX127x. -`EmulatableBoards()` returns what can run and why the rest cannot. ### Three things that are not obvious and cost hours **RadioLib drives NSS as an ordinary GPIO**, not the SPI controller's chip select. Without NSS the controller clocks bytes one at a time and the chip gets -an unframed byte stream it cannot answer, so the driver reports no chip. The -GPIO model had an empty write handler and had to be implemented. +an unframed byte stream it cannot answer, so the driver reports no chip. -**Arduino's default-constructed `SPIClass` is HSPI**, controller 2 — not VSPI. +**Arduino's default-constructed `SPIClass` is HSPI**, controller 2, not VSPI. `std_init(NULL)` picks the global `SPI` (VSPI); `static SPIClass spi;` does not. +Which controller a board's radio sits on is a property of that board's +*firmware*, not of the chip, so read the variant rather than generalising. **A merged image's flash-size header is at 0x1000**, not 0, because the image starts with padding. Read it from the wrong offset and you pad to the wrong size and get `Detected size(4096k) smaller than the size in the binary image header(8192k)`. QEMU accepts only 2/4/8/16 MB images. -### Two bugs in Espressif's QEMU - -Both in the SPI model, both affecting any non-flash peripheral: - -- **RX bounds check** indexed by the transferred byte's value instead of the - loop position, so replies were dropped whenever the byte just sent was - numerically larger than the read length. Already fixed by their open PR #144, - which is cherry-picked onto our branch with authorship intact. -- **Stale command-phase bitlen**: the USR path enabled a command phase when - `SPI_USER.COMMAND` *or* a leftover `COMMAND_BITLEN` was set, injecting a - spurious `0x00` in front of every transfer. Ours, not reported yet. - ### The chip model is clocked two ways and must agree `VirtualSX1262` takes a whole buffer for the native path and single bytes for @@ -381,7 +415,7 @@ strictly, so `signalRssiPkt` read zero on every native run. ## Working on the desktop app **Never launch it with `go run`.** It relinks the cgo in Gio and wgpu every -time, which costs far more per restart than a prebuilt binary — and that gap +time, which costs far more per restart than a prebuilt binary, and that gap decides whether a layout gets checked or guessed at: go build -o msim ./cmd/meshbench && ./msim workbench @@ -393,17 +427,18 @@ looks exactly like a crashed application. Use the compositor's own tool. **Look at the window before claiming it works.** One pass over the firmware library found it taking a viewport of its own and being sized to the display, two tables with fixed pixel heights that would not scale, and a window created -without `WindowFlagsMenuBar` — which silently costs it `panelChrome`, and with +without `WindowFlagsMenuBar`, which silently costs it `panelChrome`, and with it the pop-out and dock verbs. ## Before you report any number **The model is optimistic, and you must say so.** It omits multipath, fading, body loss, oscillator error, and non-LoRa interference beyond what is loaded. -Every omission makes real links *worse*. The bias is one-directional. +Every omission makes real links *worse*. The bias is one-directional, and +`docs/shortcomings.md` is the current list. So: never present a simulated margin as a measurement. "Predicted +2.5 dB, which -is marginal — real conditions will be worse" is honest. "It works" is not. +is marginal, and real conditions will be worse" is honest. "It works" is not. ## The four rules that make results meaningful @@ -416,86 +451,110 @@ is marginal — real conditions will be worse" is honest. "It works" is not. producing a confident number from an unconfident input. 3. **One run is not evidence.** The channel is stochastic. Vary the seed and report the spread, or say explicitly that you ran once. -4. **Quote provenance.** Firmware ref, seed, terrain zoom, region. A figure - without provenance is an anecdote. +4. **Quote provenance.** Firmware ref, seed, terrain zoom, region, radio preset, + and whether terrain was allowed. A figure without provenance is an anecdote. ## Workflows ### "Will this link work?" ``` -evaluate_link A B +meshbench link -from-lat … -from-lon … -to-lat … -to-lon … -freq 869.618 ``` -Read the *whole* budget, not the verdict. Report: margin in both directions, the -dominant loss term, and — if it fails — which diffracting edge cost the most. -"That ridge at 4.2 km costs 31 dB" is actionable; "no path" is not. -### "Why did that packet not arrive?" +It prints the distance, the path loss, the margin in **both** directions, and +which of workable / one-way / fails it is. Report the weaker direction and the +dominant loss term; if it fails, `meshbench profile` over the same path names +the worst obstruction, and "that ridge at 4.2 km costs 31 dB" is actionable +where "no path" is not. -``` -reception_ledger -``` -Distinguish the five outcomes; they have different fixes: +From a session, `link.pair {a, b}` and `link.profile` compute the same thing and +draw it. **They do not hand the numbers back**: the reply is only the endpoints, +`budget.for_selection` answers a bare count, and the margins reach no snapshot +and no client. So read a budget with the CLI, or from the panel, and say plainly +that the socket cannot yet return one rather than inventing a value. -| Outcome | What it means | -|---|---| -| out of range | nothing arrived — terrain or distance | -| too weak to demodulate | raise power, lower SF, better antenna | -| demodulated, CRC failed | marginal — interference or collision | -| received, dropped (dedup) | working as designed, not a fault | -| received, relayed | fine | +### "Why did that packet not arrive?" -Never say "collision" without checking the waterfall — a weak signal and a -collision look identical in delivery statistics and have opposite fixes. +Every miss now carries the cause the engine *established*, on `class`, and it is +a closed set (`internal/sim/engine/events.go`, mirrored into both clients by +`tools/clientgen`). Group on the class; never match on the detail sentence, +which is prose and is reworded. + +| class | what it means | what fixes it | +|---|---|---| +| `sent` | this node transmitted it | nothing, it is the origin | +| `received` | decoded, first time | nothing | +| `half-duplex` | its own transmitter was keyed | timing, not power | +| `interference` | would have decoded alone; something louder took it | less traffic, or separation | +| `collision` | header decoded, then more symbols destroyed than the CR repairs | less traffic | +| `receiver-busy` | the demodulator was already following another packet | less traffic | +| `floor` | under the threshold **on its own** | power, antenna, lower SF | +| `unclassified` | the engine did not establish a cause | investigate, do not guess | + +**`floor` is no longer the catch-all, and that is the point.** It used to absorb +receiver-lock and collision misses, and an operator reading the floor card +bought antennas for a mesh that was actually too busy. `unclassified` exists so +that an unestablished cause reads as a question rather than as a confident wrong +answer; waveform mode produces it often, because the receive chain reports what +it did and not what beat it. A path with no terrain data is `unclassified` too, +not `floor`: nothing about that signal was measured. + +Read them with `events.recent {limit}` or `events.dump {path}`, which writes +NDJSON with `class` on every line. Never say "collision" without checking, and +never average `unclassified` into anything. ### "Where should the next repeater go?" -``` -find_sites --region --objective coverage,redundancy,energy -``` -Report the per-term scores, not the aggregate. A site that gains coverage but +`internal/study/planning` places sites and scores them, and `meshbench coverage` +writes a raster from one station, but **the site search is reachable from no +verb and no subcommand today**. Say so rather than describing a workflow that +does not exist. What can be done from outside is to propose candidates, compute +a coverage raster for each with `coverage.compute`, and compare, reporting the +per-term consequences rather than one aggregate: a site that gains coverage but flattens in December is not a site, and a site that adds interference to two -existing repeaters may be a net loss. Both are visible in the terms. - -Check `energy_forecast` on the winner before recommending it. +existing repeaters may be a net loss. ### "Will this solar node survive winter?" -``` -energy_forecast --months 12 -``` -The answer is not a percentage. It is *when it flattens and what fixes it* — -usually a bigger panel, not a bigger battery. Say which, because people reliably -buy the wrong one. +`node.energy` (which takes the node's name as its whole parameter, not an +object) and `energy.for_selection` produce the budget, and both refuse when the +energy model is disabled. The answer is not a percentage. It is +*when it flattens and what fixes it*, usually a bigger panel rather than a +bigger battery. Say which, because people reliably buy the wrong one. ### "Did that firmware change break relaying?" -``` -run --firmware-ab --seed --traffic scripted -compare -``` -Lock the seed and script the traffic, or the comparison means nothing. Report the -first diverging event, not just the totals. +Pin `repeater_version` on `experiment.base`, vary it with `experiment.vary`, +lock `experiment.seeds` and script the traffic with `schedule.add`, or the +comparison means nothing. Report the first diverging event, not just the totals, +and remember that `experiment.export` is the only place the matrix comes back as +a document: `sweep.run` answers with arm and seed counts and leaves the matrix +where only a panel can read it. ### "Is my site deaf?" -Load emitters, then `evaluate_link`. If the noise floor is raised, check whether -the interferer is **in band or out of band** before suggesting a filter — a +Load emitters, then compute the link. If the noise floor is raised, check whether +the interferer is **in band or out of band** before suggesting a filter: a filter cannot help in-band interference, and saying so plainly saves real money. ## Do not - Do not report a coverage raster as ground truth. At terrain zoom 11 a pixel is - ~30 m; buildings, hedges and vans are invisible to it. + ~30 m; buildings, hedges and vans are invisible to it, and the raster charges + no polarisation mismatch even where the link budget does. - Do not average away an asymmetry. - Do not run one seed and call it a result. - Do not recommend a site without checking energy and interference. -- Do not silently drop nodes excluded for position uncertainty — say how many. +- Do not silently drop nodes excluded for position uncertainty; say how many. +- Do not quote a margin without knowing whether terrain was allowed. ## When the simulator disagrees with reality -`validate` compares predictions against observer data. If agreement is poor, -that is a **finding about the model**, not a data problem to explain away. Report -the residual and its sign; per ADR-0003 we expect to read optimistic, so a -negative bias is more interesting than a positive one and worth investigating -rather than smoothing. +`validate.fetch` and `validate.compare` put predictions against observer data. +If agreement is poor, that is a **finding about the model**, not a data problem +to explain away. Report the residual and its sign. ADR-0015 is why the +comparison exists at all: the omissions in `docs/rf-chain.md` section 9 all +make reality worse than simulation, so we expect to read optimistic, and a +residual in the *other* direction is the interesting one, to be investigated +rather than smoothed. diff --git a/.claude/skills/wb2-design-language/SKILL.md b/.claude/skills/wb2-design-language/SKILL.md index 89f7600a..bf617200 100644 --- a/.claude/skills/wb2-design-language/SKILL.md +++ b/.claude/skills/wb2-design-language/SKILL.md @@ -1,25 +1,30 @@ --- name: wb2-design-language -description: The workbench2 design language - load before building or changing any Gio panel, control, menu, or map drawing so new work matches what shipped +description: The workbench design language - load before building or changing any Gio panel, control, menu, or map drawing so new work matches what shipped --- -# The workbench2 design language +# The workbench design language How MeshBench's Gio interface is built. These are decisions Alex has made, mostly by rejecting something that did not follow them; breaking one recreates a reported bug. +Almost none of it is enforced by a linter, because most of it is about what a +control *means* rather than about what compiles. Treat every rule below as a +convention you are expected to keep, not as something the build will catch. + ## Colour and type - **Every colour and size comes from `theme.Theme`** (`internal/ui/theme`). - Nothing outside that package writes a literal colour or pixel count. Palette is semantic: `Ink/Dim/Faint` for text, `Panel/Sunk/Ground` for surfaces, `Rule` for borders, one `Accent`, `Good/Warn/Bad` for states. - Need a new colour? Add a token to both palettes, then use it. + Need a new colour? Add a token to both palettes, then use it. Nothing + checks this, and `comp/mapbuildings.go` and `comp/mapcoverage.go` both + break it today, so the existence of a literal is not permission for another. - **Text drawn on the map uses `MapInk`/`MapInkDark`, never theme ink.** The basemap layer says whether its ground is dark (`basemap.Layer.Dark`); - `MapView.baseInk` picks. Theme ink on a light basemap was invisible - no - plates, no halos; the font changes, per Alex. + `MapView.baseInk` (in `comp/mapscale.go`) picks. Theme ink on a light + basemap was invisible - no plates, no halos; the font changes, per Alex. - **Mono (`comp.Mono`, `Column.Mono`) for anything compared by eye down a column**: numbers, versions, identifiers, hex. @@ -28,15 +33,17 @@ a reported bug. - **Card + StatCell + CellGrid** (`comp/cards.go`) for settings and overview pages: a labelled value with the "why it matters" caption underneath. The caption is content, not decoration - it is the reason the number exists. -- **Chips** (`comp.Chip`) for filters and tabs: capsule, count beside the - label, tinted when active. Cards above, chips below, table underneath - - the events panel and the firmware library both follow this page shape. +- **Chips** (`comp.Chip`, `comp/chips.go`) for filters and tabs: capsule, + count beside the label, tinted when active. Cards above, chips below, table + underneath - the events panel and the firmware library both follow this + page shape. - **Dropdowns own no list.** `comp.Dropdown` shows the value and a drawn - chevron; pressing hands the choosing to the shell's chooser - (`Prompt.Choose`) via a `choose func(title, opts, pick)` callback wired in - main.go. One way to pick from a list, everywhere. -- **Switches are `comp.Check` drawn with `LayoutSwitch`** - same widget.Bool - underneath, so the control audit still finds them. + chevron; pressing hands the choosing to a `choose func(title, opts, pick)` + callback. Build it with `chooserIn(panel)`, not by reaching for `sh.Ask` + directly: that routes the question through `windows.promptFor`, so a + popped-out panel asks in **its own** window rather than in the main shell. +- **Switches are `comp.Check` (in `comp/comp.go`) drawn with `LayoutSwitch`** - + same widget.Bool underneath, so the control audit still finds them. - **Pills** (`comp.Pill`) for status words (Ready to run / Warming / Running). Capsule radii are `size.Y/2`, never a big constant: Gio does not clamp RRect radii, and an oversized radius smears fill across the window. @@ -46,16 +53,39 @@ a reported bug. (events, firmware library) must force every cell to its declared column width - `d.Size.X = px` - or a 12px tick slides everything after it off its header. Column labels and widths live in one table-of-columns per panel. -- **Event classes** map to colour in exactly one place: `comp.ClassColour`. - Cards, chips, pills and cause text all read it. + +## One table, or the key drifts + +**A colour and the word for it come from the same table, read by both the +drawing and the legend.** `comp.ClassColour` and `comp.ClassLabel` are that +pair for event classes; `timelineKinds()` in `comp/timeline.go` is the same +move for the timeline, feeding `legend()` and `marks()` from one list. A key +maintained beside the thing it describes is a key that will disagree with it, +and a wrong legend is worse than none because it is believed. + +The event classes grew from five to eight (`sent`, `received`, `half-duplex`, +`interference`, `collision`, `receiver-busy`, `floor`, `unclassified`), which +is what a table absorbs and a hand-written key does not. ## Behaviour - **Panels never mutate state.** Controls fire verbs through `do(verb, params)`; the store owns the world; panels draw snapshots. A control that - needs input the button cannot carry asks through `sh.Ask` (prompt or - chooser) - the verb itself must still refuse when the parameter is missing, - because scripts call it directly. + needs input the button cannot carry asks through the prompt - the verb + itself must still refuse when the parameter is missing, because scripts call + it directly. +- **Widget state belongs to the panel, never to the package.** A map keyed by + action name at package scope was shared by every window in the process; two + pop-outs writing it was a fatal concurrent map write, and a mutex would not + have saved it, because they would still be sharing one `Clickable`. +- **Widget identity is address**: never rebuild a widget per frame; pool + per-row widgets in a map keyed by a stable row key, and **bound the pool**. + The events panel kept one clickable per distinct event for ever while the + store's tail dropped them; it now rebuilds against what is on screen, with + slack so typing in a filter does not rebuild every frame. +- **Nothing touches the disk on the frame goroutine.** The compare and runs + panels share `runloader.go`, which loads off-frame and hands the result to + whichever frame asks next. - **Every long operation announces itself** in the jobs strip (`job.progress` / `job.done`) or is not long. Estimates are said before spending ("fetching 412 of 500 tiles, roughly 25 MB"). Three healthy waits were reported as @@ -67,34 +97,86 @@ a reported bug. modal. - **Honesty lines are content**: "results are a best case: no multipath, bare earth, ideal demodulator" stays visible; "no data" is never drawn as zero; - "did not apply" is a dash, not a cross. + "did not apply" is a dash, not a cross. The same rule is why a miss shows + the cause the engine established and `unclassified` where it established + none, rather than a plausible default. - **Settings that survive a restart** go through `session.Prefs` (`workbench2.json`) - loaded by the command, never by `Register`, so tests stay hermetic. The scenario itself deliberately stays in the fixture. +## It has to read docked, not just popped out + +A panel is laid out in a narrow rail far more often than in a window of its +own, and a layout that is only checked popped out fails in the way that reads +as a rendering fault rather than as a layout one: the Link panel drew nothing +docked because a rigid header ate the width and the flexed chart was left zero +height. + +The pattern to copy is in `workbench/linkprofile.go`: a width breakpoint, the +header stacking below it, the chart *measured* with a floor rather than +guessed, and the whole panel scrolling when the floor wins. Clip a chart to its +own box. **Capture both widths before believing it.** + ## Menus and windows - Menu structure is data on `shell.MenuItem` (Section/Icon/Shortcut); the table in `workbenchMenus()` feeds the rows, the key filter and the tests, so caption and binding cannot drift. Shortcuts match on exact modifiers. -- The Window menu lists the curated daily set (`Panel.InWindowMenu`); - everything else stays one step away behind "Show all panels...". +- **Every panel names its `Menu` and its `Section`, and the entries are + generated** by `Shell.PanelItems`. There is no curated daily set and no + "Show all panels..." any more: the curated thirteen left twenty panels + reachable only through a chooser that then threw them out of the window. + A panel with no menu is a panel nobody finds. +- The Window menu is about windows and layouts (`layout.reset`, + `window.raise_all`, `window.dock_all`), not about which panels exist. - One entry lives in one menu. +## A file that hits 500 lines is split along a seam + +The hard limit is a build failure (`tools/file-length.sh`), and `mapworld.go` +sitting at 499 blocked two unrelated changes. Splitting is a first-class move, +but split on meaning: what draws the **world** stayed in `mapworld.go`, what +draws a **study's answer laid over it** left for `mapcoverage.go`. A split by +line count alone leaves two files nobody can name. + ## Verification (non-negotiable) -- **Nothing is done until it is seen running.** Build on elite, drive through - the control socket (`/tmp/ctl2.py`), capture window-targeted screenshots - (`/tmp/shot.sh`), and look at them. Green tests alone have let bugs - survive; the pill smear, the invisible hillshade and the drifting columns - were all caught only in captures. +- **Nothing is done until it is seen running.** Green tests alone have let + bugs survive; the pill smear, the invisible hillshade and the drifting + columns were all caught only in captures. - **Everything reachable by flag**: a panel, section, menu, or layer that - only opens on a click is one nobody can capture (`-panel`, - `-config-section`, `-drop-menu`, `-layers`, ...). -- **The control audit** (`audit_test.go`) walks panel structs for - Button/Check/Field and presses everything. New panels join `auditTargets`; - panels whose sections hide controls provide a flat `auditDraw`. Controls - that change the view rather than the world (sidebar rows) are plain + only opens on a click is one nobody can capture. `internal/ui/workbench/main.go` + carries around thirty of them (`-panel`, `-view`, `-config-section`, + `-drop-menu`, `-layers`, `-look`, `-node-tab`, `-coverage`, ...) plus + `-quit-after` and `-control-socket`. A new view adds one. +- **There is no blessed screenshot tool in the repository.** Driving is the + control socket through `pkg/client-python` (`tools/soak/` is the worked + example); capturing is the compositor's own grabber, because under Wayland + the app does not render on Xwayland and an X11 grab returns a solid black + screen that looks exactly like a crash. The closest thing to a committed + capture path is the render tests, `workbench/brandshot_test.go` and + `hardwareshot_test.go`, which write PNGs. `board.screenshot` is not this: it + captures an emulated board's own display. +- **The control audit** (`workbench/audit_test.go`, targets in + `audittargets_test.go`) walks panel structs for Button/Check/Field and + presses everything. New panels join `auditTargets`; panels whose sections + hide controls provide a flat `auditDraw`. It now *waits* for a control's + effect rather than assuming two frames are enough, so a control that defers + its work passes - and a destructive control that asks before acting spends + the whole waiting budget, which is why the suite takes near two minutes. +- Controls that change the view rather than the world (sidebar rows) are plain `widget.Clickable` with their own test. -- Widget identity is address: never rebuild a widget per frame; pool per-row - widgets in a map keyed by a stable row key. + +## A panel that opens itself + +The Setup panel is the only one that does, and the rules it had to keep are +worth reusing: + +- **It is a report, not a wizard.** No row acts on its own; a row nothing can + fix carries the steps in words rather than pointing at a document. +- **It opens only when something is blocking or waiting to be told.** A + machine that is set up sees nothing, which is what stops it being a splash + screen. +- **It waits three seconds first.** A panel docked before the layout exists + lands nowhere, and a check run before the fixture opens misreads every + missing firmware as optional.