diff --git a/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md similarity index 94% rename from CODE_OF_CONDUCT.md rename to .github/CODE_OF_CONDUCT.md index b9531587..d07895ba 100644 --- a/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -4,7 +4,7 @@ This applies everywhere Patternflow people meet: this repository (issues, pull r ## How we treat each other -The rule was written before there was anything to build, as a short poem, and it is the sixth section of the [manifesto](docs/manifesto.md#6-how-we-treat-each-other). In full: +The rule was written before there was anything to build, as a short poem, and it is the sixth section of the [manifesto](../docs/manifesto.md#6-how-we-treat-each-other). In full: > 아무것도 몰라도, 하고자 한다면 만들 수 있습니다. > 도와주는 게 당연합니다. 고마운 게 당연합니다. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e67ad620..cdcde0f8 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -13,7 +13,7 @@ contact_links: url: https://discord.gg/Vr9QtsxeTk about: New here? Join the server, then jump into the channels above. - name: Getting help — every venue in one table - url: https://github.com/engmung/Patternflow/blob/main/SUPPORT.md + url: https://github.com/engmung/Patternflow/blob/main/.github/SUPPORT.md about: Where to ask what, including the venue that works where Discord is blocked. - name: Contributing guide url: https://github.com/engmung/Patternflow/blob/main/CONTRIBUTING.md diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 80% rename from SECURITY.md rename to .github/SECURITY.md index 1193d2bb..772748ef 100644 --- a/SECURITY.md +++ b/.github/SECURITY.md @@ -13,7 +13,7 @@ Please report privately through [GitHub's report form](https://github.com/engmun ## What is a documented trust model, not a vulnerability -The device's HTTP server on the LAN has **no authentication**: anyone on the same network can call any `/api/*` route, including `POST /update`. The same holds on the panel's own hotspot (`patternflow-xxxx`, raised when it finds no network it knows), whose password is the documented default `patternflow` until it is changed on the Wi-Fi page. This is deliberate and documented in [docs/rest-api.md](docs/rest-api.md#transport) — the panel is an instrument on a home or venue network, with the same posture as ArduinoOTA's default. `PF_WEBUPDATE_ALWAYS_ARMED 0` is the one lever that narrows `/update`; the hotspot takes its own password, or is switched off, on the Wi-Fi page. Reports that amount to "the LAN API is open" will be closed with a pointer here; reports of a way to reach that API from *outside* the LAN, or from a web page on another origin, are in scope. +The device's HTTP server on the LAN has **no authentication**: anyone on the same network can call any `/api/*` route, including `POST /update`. The same holds on the panel's own hotspot (`patternflow-xxxx`, raised when it finds no network it knows), whose password is the documented default `patternflow` until it is changed on the Wi-Fi page. This is deliberate and documented in [docs/rest-api.md](../docs/rest-api.md#transport) — the panel is an instrument on a home or venue network, with the same posture as ArduinoOTA's default. `PF_WEBUPDATE_ALWAYS_ARMED 0` is the one lever that narrows `/update`; the hotspot takes its own password, or is switched off, on the Wi-Fi page. Reports that amount to "the LAN API is open" will be closed with a pointer here; reports of a way to reach that API from *outside* the LAN, or from a web page on another origin, are in scope. ## Supported versions diff --git a/SUPPORT.md b/.github/SUPPORT.md similarity index 94% rename from SUPPORT.md rename to .github/SUPPORT.md index a06b26f7..e9dc5ab2 100644 --- a/SUPPORT.md +++ b/.github/SUPPORT.md @@ -6,13 +6,13 @@ One table for "where do I ask". Issues are for things that change this repositor | :--- | :--- | | Learn to build, play, make patterns for one or give it sound and MIDI | [The guide](https://patternflow.work/guide) — each step on a 3D Patternflow in the browser. A step that didn't get you through has a **Stuck here?** link, which opens an issue with that step filled in | | Get unstuck building, soldering or flashing | [Discord → hardware-help](https://discord.com/channels/1497757947827327067/1499907910707187833) — photos and quick back-and-forth | -| Flash the DevKit for the first time, or get the panel onto Wi-Fi | [The Play guide's Flash chapter](https://patternflow.work/guide/play#flash), or [`BUILD_GUIDE.md` §8](BUILD_GUIDE.md#8-firmware) in writing. The short version is below the table | +| Flash the DevKit for the first time, or get the panel onto Wi-Fi | [The Play guide's Flash chapter](https://patternflow.work/guide/play#flash), or [`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware) in writing. The short version is below the table | | Show a pattern I made, or get feedback on one | Publish it from the [Pattern Lab](https://patternflow.work/pattern-lab) to the [Community](https://community.patternflow.work/community), then [Discord → patterns](https://discord.com/channels/1497757947827327067/1499908302962819236) | | Put my finished build on the [build map](https://patternflow.work/inside) | [Share your build](https://github.com/engmung/Patternflow/issues/new?template=share_build.yml) — or post it in Discord | | Talk about where the project should go, or find people working on the same direction | [Discord](https://discord.gg/Vr9QtsxeTk) — the dev-log and the project channels | | Report a bug, a wrong or missing doc, or propose a feature | [New issue](https://github.com/engmung/Patternflow/issues/new/choose) — pick the template | | Report a security problem | [SECURITY.md](SECURITY.md) — privately, not as an issue | -| Contribute code, hardware files or docs | [CONTRIBUTING.md](CONTRIBUTING.md) | +| Contribute code, hardware files or docs | [CONTRIBUTING.md](../CONTRIBUTING.md) | **The first flash, in short.** diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml new file mode 100644 index 00000000..afff5d8d --- /dev/null +++ b/.github/workflows/drift.yml @@ -0,0 +1,70 @@ +name: Drift checks + +# The tree keeps some facts in two places by hand: the BOM and the build +# guide's parts table, a card on /editions and a folder under +# firmware/bundles/. Each check in web/scripts/drift/ compares one such pair +# and prints both sides when they part (docs/rfc-cleanup.md §2.4). check:ci +# runs them too, but Web CI starts only on web/ and hardware/case/. A pull +# request that edits only the BOM, a bundle or the changelog runs no web job, +# and that is the pull request a drift check is for. So this one runs on every +# pull request. +# +# PF_DRIFT_STRICT=1 turns a skip into a failure. At a desk a check skips a side +# it cannot read: a tag the clone lacks, no pdftotext, no gh login. Here every +# side can be read, so a skip means this file is missing something. +# fetch-depth: 0 brings the tags the absolute links point at, and the base +# commit check:human-lock compares with. +on: + pull_request: + push: + branches: [main] + +concurrency: + group: drift-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + issues: read + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + env: + PF_DRIFT_STRICT: "1" + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + id: deps + run: npm ci + + # check:bom-schematic reads the schematic's text layer with pdftotext, + # which the runner image does not carry. + - name: Install pdftotext + run: command -v pdftotext || { sudo apt-get update -q && sudo apt-get install -y -q poppler-utils; } + + # check:roadmap asks gh for the state of each issue a roadmap node names. + - name: Drift checks + run: npm run check:drift + env: + GH_TOKEN: ${{ github.token }} + + # Not a word of the maintainer's own writing changes (§2.9). It runs + # after a red drift step too, so one push shows both. + - name: Locked text + if: ${{ !cancelled() && steps.deps.outcome == 'success' && github.event_name == 'pull_request' && hashFiles('web/scripts/human-lock.ts') != '' }} + run: npm run check:human-lock + env: + PF_LOCK_BASE: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 87717e89..628f6792 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -40,9 +40,10 @@ jobs: - name: Install dependencies run: npm ci - # Lint is a hard gate: lint errors can't reach main (warnings still pass). + # Lint is a hard gate, and a warning fails it like an error. The cleanup's + # step 3 took the site from twelve warnings to none; one more fails here. - name: Lint - run: npm run lint + run: npm run lint -- --max-warnings=0 # `next build` type-checks only what it bundles; scripts/ and the smoke # suites are outside that, and a type error there used to surface as a diff --git a/AGENTS.md b/AGENTS.md index 9a3f68dd..cd03e8be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,12 @@ Patternflow is an open-source hardware instrument: four rotary encoders controll It is standalone (not Eurorack), powered from a 5 V power bank through a screw terminal, and it is not an analog video synthesizer. Sound arrives through the Audio edition (on-board microphone, OSC, MIDI), not the default firmware. ## Repository map -- `docs/` — contracts and long-form docs: `EDITIONS.md` (the firmware seam — read it before touching firmware), `rest-api.md` / `osc-spec.md` / `midi-spec.md` / `audio-ws-spec.md` (wire contracts integrations are built against), `pfst-v2-spec.md` (show files), `panel-compatibility.md`, `RELEASING.md`, `SERVICES.md` (production server ops), the assembly map (`docs/assembly/`), the manifesto, investigations, and media (build-guide images in `docs/build-guide/`). Root-level guides: `BUILD_GUIDE.md` (the **v3 board**: v3.9, and v3.0 the same way), `BUILD_GUIDE_v2.md` (legacy v2.x board), `PATTERN_GUIDE.md` (community → deck → Pattern Lab → device), `FEATURE_GUIDE.md` (writing a firmware feature or edition), `AUDIO_GUIDE.md` (sound into the panel). The live roadmap is patternflow.work/journal and the `/roadmap` page. -- `firmware/` — PlatformIO/Arduino code for the ESP32-S3. Main sketch folder: `firmware/patternflow/` with `patternflow.ino`, `config.h` (pin mappings, brightness, limits, LED calibration), `net_config.h` (Wi-Fi / Improv / OTA / self-update defaults and the lane scale — core only; a feature's own tunables live in `features//_config.h`, and settings TUNE a feature, they never add one; per-device secrets in gitignored `patternflow_secrets.h`), and `pattern_registry.h` (function-pointer pattern table; compiles in Origin alone — the rest of the curated set ships as the Basics pack). Shared engine code lives in `firmware/patternflow/src/` (`core_display.h` HUB75 driver, `core_encoders.h`, canvas/color/math/noise helpers, Wi-Fi/OTA/web-console modules, the hotspot in `core_hotspot.h`) — core only: OSC, audio, MQTT, weather, shows, MIDI and the clock all live in `features/`. `firmware/patternflow/abi/` is the frozen host ⇄ module contract. Curated patterns live in `firmware/patternflow/presets/` (`preset_origin.h`, `preset_wave_saw.h`, dated presets), each using its own pattern namespace; user patterns are `.pfm` modules uploaded to the device over Wi-Fi, built from `firmware/modules/` via `firmware/toolchain/build_module.py` (the old `custom1.h`–`custom3.h` root slots are gone). Features attach through `firmware/patternflow/features/` (named `addons/` until 2026-08-30; legacy spellings are shimmed for out-of-tree bundles — see docs/EDITIONS.md) — a directory and a descriptor of function pointers, with the core naming none of them (see its README). `firmware/bundles/` names firmwares built from the same tree (the shelf editions `audio` and `performance`, plus `clock` and `midi`, compositions the tree keeps buildable but does not publish; the default build has no bundle): two files each, saying which features compile in and what the build calls itself; `firmware/bundles/build.sh` builds the default or a named one, `shelf.sh` stages a publishable image. Device console pages are plain HTML in `firmware/patternflow/console/`, spliced into `*_index.h` by `firmware/toolchain/console_pages.py`. Tooling lives in `firmware/toolchain/` (repo-level scripts) and `firmware/patternflow/toolchain/` (PlatformIO `extra_scripts`, which must stay sketch-relative). Also: `firmware/encoder_test/` (standalone encoder diagnostic) and `firmware/CUSTOM_PATTERNS.md` (pattern authoring guide). +- `docs/` — contracts and long-form docs: `EDITIONS.md` (the firmware seam — read it before touching firmware), `rest-api.md` / `osc-spec.md` / `midi-spec.md` / `mqtt-spec.md` / `audio-ws-spec.md` (wire contracts integrations are built against, each with a folder of vectors beside it), `contract-vectors.md` (the vectors' format), `pfst-v2-spec.md` (show files, and `pfst-v2-vectors/`, the first vectors), `panel-compatibility.md`, `RELEASING.md`, `SERVICES.md` (production server ops), the assembly map (`docs/assembly/`), the legacy v2.x build guide (`docs/legacy/BUILD_GUIDE_v2.md`), `AUDIO_GUIDE.md` (sound into the panel), the manifesto, investigations, and media (build-guide images in `docs/build-guide/`). The vector folders are `rest-api-vectors/`, `osc-vectors/`, `midi-vectors/`, `mqtt-vectors/` and `audio-ws-vectors/`. Each vector is a JSON file that quotes one sentence of its spec in `says` and gives the steps that show a panel doing it. It follows the spec, not the firmware: where the firmware disagrees, the vector keeps the spec and the disagreement goes to an issue. An edit that rewords a quoted sentence fails its vector until the vector is brought along. Root-level guides: `BUILD_GUIDE.md` (the **v3 board**: v3.9, and v3.0 the same way), `PATTERN_GUIDE.md` (community → deck → Pattern Lab → device), `FEATURE_GUIDE.md` (writing a firmware feature or edition). The live roadmap is patternflow.work/journal and the `/roadmap` page. +- `firmware/` — PlatformIO/Arduino code for the ESP32-S3. Main sketch folder: `firmware/patternflow/` with `patternflow.ino`, `config.h` (pin mappings, brightness, limits, LED calibration), `net_config.h` (Wi-Fi / Improv / OTA / self-update defaults and the lane scale — core only; a feature's own tunables live in `features//_config.h`, and settings TUNE a feature, they never add one; per-device secrets in gitignored `patternflow_secrets.h`), and `pattern_registry.h` (function-pointer pattern table; compiles in Origin alone — the rest of the curated set ships as the Basics pack). Shared engine code lives in `firmware/patternflow/src/` (`core_display.h` HUB75 driver, `core_encoders.h`, canvas/color/math/noise helpers, Wi-Fi/OTA/web-console modules, the hotspot in `core_hotspot.h`) — core only: OSC, audio, MQTT, weather, shows, MIDI and the clock all live in `features/`. `core_display.h` drives the panel through `src/hub75/`, ESP32-HUB75-MatrixPanel-DMA vendored at tag 3.0.13. Its `UPSTREAM.md` names the upstream commit and each commit taken from upstream since, and `patternflow.patch` holds every line Patternflow changed (`VENDORED.md` says why). An edit under `src/hub75/` rebuilds the patch with the command in `UPSTREAM.md`, never by hand: upstream ends some lines in spaces, and an editor that trims them breaks the hunks. `firmware/patternflow/abi/` is the frozen host ⇄ module contract. Curated patterns live in `firmware/patternflow/presets/` (`preset_origin.h`, `preset_wave_saw.h`, dated presets), each using its own pattern namespace; user patterns are `.pfm` modules uploaded to the device over Wi-Fi, built from `firmware/modules/` via `firmware/toolchain/build_module.py` (the old `custom1.h`–`custom3.h` root slots are gone). Features attach through `firmware/patternflow/features/` (named `addons/` until 2026-08-30; legacy spellings are shimmed for out-of-tree bundles — see docs/EDITIONS.md) — a directory and a descriptor of function pointers, with the core naming none of them (see its README). `firmware/bundles/` names firmwares built from the same tree (the shelf editions `audio` and `performance`, plus `clock` and `midi`, compositions the tree keeps buildable but does not publish; the default build has no bundle): two files each, saying which features compile in and what the build calls itself; `firmware/bundles/build.sh` builds the default or a named one, `shelf.sh` stages a publishable image. Device console pages are plain HTML in `firmware/patternflow/console/`, spliced into `*_index.h` by `firmware/toolchain/console_pages.py`. Tooling lives in `firmware/toolchain/` (repo-level scripts) and `firmware/patternflow/toolchain/` (PlatformIO `extra_scripts`, which must stay sketch-relative). Also: `firmware/encoder_test/` (standalone encoder diagnostic) and `firmware/CUSTOM_PATTERNS.md` (pattern authoring guide). - `hardware/` — Hardware designs. `case/` (Blender source in `source/`, STLs by printer bed size, `knobs/`, `legacy_v2/` for the v2.x board, `legacy_lasercut/` for the v1/v2 acrylic drawings, and `remixes/` — community enclosures, one folder each, whose README is its license header), `pcb/` (KiCad 10.0 source, Gerbers per revision, schematic PDF), `bom/` (`bom_v3.9.csv` — **the BOM source of truth**; the guide's table is derived from it. `bom_v3.0.csv` is kept for the previous board). - `web/` — Next.js site at patternflow.work: landing page (`/`, `/pattern`, `/build`, `/inside` are tabs of one view), the interactive guide (`/guide` is a hub over `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`, each with a `/ko` twin; one 3D stage in `components/guide/`, the firmware simulator in `lib/guide/`), Pattern Lab (`/pattern-lab`), community (`/community/**`, SQLite + Drizzle + Better Auth, only on the Pi deployment), browser flasher (no page of its own: the **Flash Patternflow** button on `/pattern` and in the Play guide, installing `public/flash/manifest.json` and the images in `public/flash/bin/`; the bare `/flash` redirects to `/guide/play#flash`), the edition shelf (`/editions`; `/variants` redirects there), the feature catalogue (`/features`, reels per feature), device update handoff (`/update`), journal, roadmap. `lib/pattern/` is the pattern runtime and annotations, `lib/lab/` the Pattern Lab, `lib/community/` the community, `lib/ai/` the Gemini client. Architecture doc: `web/ARCHITECTURE.md`. The JS presets in `web/src/lib/presets/` are the source of truth for firmware preset headers. -- `tools/` — desktop-side helpers: `patternflow-audio-extension` (the browser audio-react extension — also the authoring source the device's `/audio-in` console page is assembled from), `patternflow-audio-android` (phone capture app), `rtpmidi-probe`, and `case-models` (builds the /build preview's per-case 3D models, `web/public/cases//model.glb`, from the case files in the repo; run by hand, not by the site build). +- `shared/` — `constants.json`, the values the firmware and the site both carry: the panel geometries `PANEL_GEOMETRY` picks from, the knob count, the encoder's 24 detents a turn, the bus maximum and the button timings. Two files are generated from it, `firmware/patternflow/src/core_shared.h` (each key with `PF_SHARED_` in front) and `web/src/lib/shared/constants.ts`, and neither is edited by hand. It exists because the lab counted 20 detents a turn, against the encoder's 24, until 31 July 2026. +- `tools/` — desktop-side helpers: `patternflow-audio-extension` (the browser audio-react extension — also the authoring source the device's `/audio-in` console page is assembled from), `patternflow-audio-android` (phone capture app), `rtpmidi-probe`, `case-models` (builds the /build preview's per-case 3D models, `web/public/cases//model.glb`, from the case files in the repo; run by hand, not by the site build), and `virtual-panel`. `virtual-panel` is the one that is not a client: a Patternflow with no board, the guide's `DeviceSim` (`web/src/lib/guide/deviceSim.ts`) behind the ports the firmware listens on, with the five contracts in `transports/`. It runs each pattern's JS twin, never a `.pfm` module. The vector suites replay against it, so a change to `DeviceSim` is a change to the panel they test. It is a test fixture, and Wi-Fi and firmware updates are not part of it. - `integrations/` — host-software integrations, each with its own README. `integrations/ableton/` is the Max for Live bridge (knobs → Live parameters over OSC). The Home Assistant integration left this repository on 2026-09-03; its author maintains it separately. Integrations are built against the contracts in `docs/` (`rest-api.md`, `osc-spec.md`, `midi-spec.md`), not against the firmware source. `docs/rest-api.md` also has the table for choosing between HTTP, OSC and MQTT, and the rules that make the device's single-connection web server easy to knock over. ## Hard rules (do not violate) @@ -20,13 +21,21 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t 2. `hardware/bom/bom_v3.9.csv` is the BOM source of truth. The parts table in `BUILD_GUIDE.md` and the schematic in `hardware/pcb/schematic.pdf` must match it. If you change one, check the other two. (`bom_v3.0.csv` stays for people holding a v3.0 board; it is not the source of truth.) 3. License split is strict: firmware and web code = MIT; hardware designs (PCB, case STLs, Blender source) = CC-BY-SA 4.0. Two separate license files at root: `LICENSE-MIT` and `LICENSE-CC-BY-SA`. Do not merge them; the root `LICENSE` is a pointer to both so GitHub's detector sees both, not a third license. 4. Brand naming: body text = "Patternflow", physical engravings (PCB silkscreen, future case engravings) = "PATTERNFLOW", filenames and URLs = lowercase "patternflow". Never mix these in a single context. -5. Known issues of the current board are documented in `BUILD_GUIDE.md` section 10; the v2.0 fixes and leftovers are in `BUILD_GUIDE_v2.md` section 10. Reference those sections instead of restating the issues. +5. Known issues of the current board are documented in `BUILD_GUIDE.md` section 10; the v2.0 fixes and leftovers are in `docs/legacy/BUILD_GUIDE_v2.md` section 10. Reference those sections instead of restating the issues. 6. **The board has exactly one power input: `J4`, the screw terminal. Never describe USB-C as a power option.** v3.9 removed the `USB1` footprint and its `R1`/`R2` CC pull-downs outright, after a USB-C-powered v3.0 board ran for 20–30 minutes and then smoked at a connector pin ([#221](https://github.com/engmung/Patternflow/issues/221)) — the failure is delayed, so "it seems to work" is not evidence. On v3.9 the footprint does not exist; on a v3.0 board already in someone's hands, `USB1`/`R1`/`R2` stay unpopulated. Release notes and changelog entries that record the hold as it stood are history — update current-state docs, leave the record alone. 7. **The firmware core is not the place to put a feature.** `firmware/patternflow/patternflow.ino` and `firmware/patternflow/src/` are the device itself — panel, encoders, pattern loader, Wi-Fi, sleep, console — and every published firmware compiles them **unchanged**. They change for a bug or a real improvement to the device, and nothing else; a feature bends to fit the core, never the reverse. Features attach through the hooks in `firmware/patternflow/features/pf_feature.h`, and the core names none of them — no `#include`, no `#if PF__ENABLED`, not even a feature's name in a string on the screen or in a console page. Before editing the core, ask whether the edit would still be correct on a build that does not contain the feature you are working on; if not, it belongs in `features/`. The full reasoning is [`docs/EDITIONS.md`](docs/EDITIONS.md), and the header comment of `patternflow.ino` lists the regressions this rule exists because of. +8. During a cleanup window (see [`docs/rfc-cleanup.md`](docs/rfc-cleanup.md)) no PR changes behaviour; moves and renames carry a redirect or an updated link map. ## Common commands - Web dev server: `npm run dev` (inside the `web/` directory) -- Web production build: `npm run build` (inside the `web/` directory); the smoke suites are `npm run check:*` and CI runs all of them. +- Web production build: `npm run build` (inside the `web/` directory); the smoke suites are `npm run check:*`, and CI runs the ones `npm run check:ci` chains together. +- Web lint and typecheck: `npm run lint` and `npm run typecheck` (inside `web/`). CI runs lint with `--max-warnings=0`, so one warning fails a pull request the way an error does. The rules in `eslint.config.mjs` stay as they are. An exception is an `eslint-disable-next-line` comment for one rule, with its reason after `--`. `typecheck` also reads `tools/virtual-panel/`, and lint does not. +- Shared constants: change `shared/constants.json`, then run `npm run gen:shared` (inside `web/`) and commit the two files it writes. `npm run check:shared` fails while either is stale. It also reads the two copies the generator cannot write, the 1000 in the frozen `abi/pf_params.h` and the 128 and 64 in `firmware/toolchain/build_module.py`, and fails when they no longer match the JSON. +- Drift checks: `npm run check:drift` (inside `web/`) runs each file in `web/scripts/drift/`, and each compares two things the tree keeps by hand, such as `hardware/bom/bom_v3.9.csv` and the build guide's parts table. `npm run check:` runs one: `links-absolute`, `bom`, `bom-schematic`, `features`, `editions`, `presets`, `roadmap`, `changelog` or `shared`. `check:ci` ends with it, and `.github/workflows/drift.yml` runs it on every pull request with `PF_DRIFT_STRICT=1`. At a desk a check skips a side it cannot read (a tag the clone lacks, no `pdftotext`, no `gh` login); in that workflow the skip fails. One check has no script of its own: `markers` fails on a `TODO` or `FIXME` in first-party code, and runs alone as `npx tsx scripts/drift.ts markers`. +- Contract vectors: `npm run check:rest-vectors` (inside `web/`) gives each file in `docs/rest-api-vectors/` a fresh virtual panel on free ports and prints what the vector expects beside what the panel did. `check:osc-vectors`, `check:midi-vectors`, `check:mqtt-vectors` and `check:audio-vectors` do the same for their folders. The panel's clock stands still between steps, so a 500 ms release costs no real time. `check:virtual-panel` runs each preset with a JS twin on a panel of its own for two seconds of panel time. A thrown error fails it. The six are files in `web/scripts/drift/`, so `check:drift` and `drift.yml` run them with the drift checks. +- Virtual panel: `npm run virtual-panel -- --port http=8080` (inside `web/`) starts one to talk to by hand. HTTP is on 80 as on the device, and Linux keeps ports below 1024 from a process that is not root, so the flag moves it; `--port '*=0'` takes a free port for each and prints them. `--features mqtt` composes a feature in by its directory name in `firmware/patternflow/features/`. `GET /frame.png` is the frame as the LEDs would show it, and `/vp/` is a hand and the clock. The firmware has neither. `tools/virtual-panel/README.md` has the rest. +- Locked text: `npm run check:human-lock` (inside `web/`) fails when a word changes in the maintainer's own writing, the regions `docs/rfc-cleanup.md` §2.9 lists plus each path in `docs/.human-written`. Markup and link paths in them may change. Text moved word for word passes, and the move needs the maintainer's approval on the PR. A typo is reported in the PR, not fixed. `drift.yml` runs it on pull requests against the base commit; at a desk the base is the merge-base with `origin/dev`, or with `origin/main` in a clone that has no `dev`. +- Voice lint: `npm run check:voice` (inside `web/`) reads the lines a branch adds since that same base and prints `file:line` for each banned word from `docs/voice-banned.txt`, list of three, opener that says what the text is about, or closing summary. Run it by hand before opening a PR that adds prose; CI does not run it. Files given as arguments are read whole, and `-` reads a PR description from stdin. - Firmware compilation: `./firmware/bundles/build.sh` (PlatformIO, bundled toolchain — not the Arduino IDE; the IDE path cannot build the editions' library set). That builds the default: the device with no features. Named editions are `./firmware/bundles/build.sh audio`, `performance`, `clock` and `midi` (the last builds in its own PlatformIO env, named in `bundles/midi/env`, because its USB port must be the OTG controller — a build flag, not a setting). `./firmware/bundles/build.sh all` builds every composition and scans each binary for per-feature marker strings, proving each image carries exactly its features — run it before pushing anything that touches firmware. - **Changing anything in the firmware core means building every composition** — `build.sh all` is that rule as one command. A hook change that compiles against the default is not tested; the default has no features to break. The boundary rule itself is enforced by `firmware/toolchain/check_boundaries.py` in CI: core referencing a feature namespace, including from `features/`, branching on a feature flag, or naming a feature in a core console page fails the build. `check_presets.py` (also CI) keeps `web/src/lib/presets/` and `firmware/patternflow/presets/` in step. - Console pages: edit `firmware/patternflow/console/*.html`, preview with `python firmware/toolchain/console_serve.py`, then `python firmware/toolchain/console_pages.py build` (CI checks the headers stayed in sync). @@ -42,3 +51,30 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t - New users: `README.md` → `BUILD_GUIDE.md` - Contributors: `CONTRIBUTING.md` → `docs/REPOSITORY.md` → `docs/EDITIONS.md` (firmware) · `web/ARCHITECTURE.md` (web) - Version history: `CHANGELOG.md` + +## Writing voice (hard rule) + +Everything an agent writes that a person will read — docs, README sections, release notes, console page copy, PR descriptions, comments — must read as if the maintainer wrote it. Not "polished". Written. + +Voice reference: `docs/voice.md` describes the project voice. Read it before writing anything longer than a sentence. Match its register (Korean: 해요체 or 합니다체 as the file already uses; English: plain, first person where the project already uses it), its sentence length, and its habit of naming the specific thing — the part number, the detent count, the date — instead of the category. + +Do not: +- Open with what the document is about ("This document describes…", "이 문서는 …을 설명합니다"). Start with the thing. +- Close with a summary that repeats the body, or with "In conclusion" / "결론적으로". +- Turn prose into bullet lists, or bold phrases for emphasis. Bullets are for lists of things. +- Use these words or their Korean equivalents: seamless, robust, leverage, comprehensive, streamline, empower, cutting-edge, ensure, utilize, delve, "it's important to note", "here's", 효율적으로, 원활하게, 강력한, 최적화된, 혁신적인, 다양한, 전반적으로, 참고로, 살펴보겠습니다. +- Write in threes ("fast, simple, and reliable"). One reason is a reason; three is a pattern. +- Join clauses with em dashes (—). End the sentence and start another. +- Hedge every claim. Say it, or leave it out. +- Add emoji, decorative headers, "Note:" callouts, or a "Generated by" line. +- Mention that an AI, an agent or a tool wrote or helped write the text. Ever. +- Reformat an existing human-written paragraph into another structure. + +Do: +- Prefer one specific example over a general statement. +- Vary sentence length. A short one can stand alone. +- Keep the project's own terms (core, feature, edition, bundle, preset, module) exactly as the existing docs use them. +- For PR descriptions and commits: imperative first line, one line of why, nothing else. No "This PR introduces…". +- When unsure of tone, write less. + +Any new or rewritten text over 20 lines goes through `web/scripts/voice-lint.ts` (banned phrases, triplets, opener and closer patterns) before the PR is opened, and a person reads it before merge. diff --git a/BUILD_GUIDE.md b/BUILD_GUIDE.md index 8afef288..f6fe29b4 100644 --- a/BUILD_GUIDE.md +++ b/BUILD_GUIDE.md @@ -1,6 +1,6 @@ # Patternflow v3 -- Build Guide -> **Building a v2.x board?** Use the [v2 build guide](BUILD_GUIDE_v2.md) instead — v2 and v3 parts are **not** interchangeable. +> **Building a v2.x board?** Use the [v2 build guide](docs/legacy/BUILD_GUIDE_v2.md) instead — v2 and v3 parts are **not** interchangeable. This guide walks you through building a Patternflow on the v3 board from scratch. v3.9 is the current revision; a v3.0 board follows the same steps (its USB-C footprint stays unpopulated, see §2). No prior soldering experience needed — every joint is big, forgiving through-hole, and the board was deliberately kept that simple so a first-time solderer can finish it. diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e48521..fab26e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,53 @@ All notable changes to Patternflow will be documented in this file, newest first ## [Unreleased] +### Firmware + +- **The MIDI page counts 24 detents a turn, as the encoder has.** The Sensitivity readout worked out its per-turn figure from 20, so 1 : 1 read 20/turn and 6.4 turns end to end, where a turn of the PEC11R-4220F-S0024 sends 24 steps and the sweep takes 5.3. What the panel sends did not change. +- **The vendored HUB75 driver names the upstream commit it came from.** `firmware/patternflow/src/hub75/UPSTREAM.md` gives ESP32-HUB75-MatrixPanel-DMA at tag 3.0.13 (`a622186`), copied on 31 July 2026, and `patternflow.patch` beside it is every change since: `diff -ruN` of upstream's `src/` against the folder, which applied to a clean checkout of the tag gives the folder back. `VENDORED.md` and the comments in `core_display.h` and `core_canvas.h` point at the patch instead of counting the additions. The firmware is unchanged. +- **The vendored HUB75 driver takes upstream's fix for its config constructor.** `HUB75_I2S_CFG` now sets `line_decoder` from its argument (upstream `7bf3b6c`), so the field is `TYPE138` instead of whatever was on the stack where `initDisplay()` builds the config. `src/hub75/UPSTREAM.md` lists the commit and its rebuild cherry-picks it, so `patternflow.patch` still holds only Patternflow's changes and comes out byte for byte the same. +- **The firmware reads the values it shares with the site from `src/core_shared.h`.** `config.h` takes the panel geometries and their sizes from it, `net_config.h` writes the lane scale as 24 detents times two turns, and the encoders, the bus and the sketch take the debounce, the long press and the bus scale. All come from `shared/constants.json`. No value changed, and all five images build to the same size. + ### Hardware +- **`check:bom` and `check:bom-schematic` hold the BOM to the build guide and the schematic.** Hard rule 2 as two checks. A designator, an MPN or a quantity that `hardware/bom/bom_v3.9.csv` and the parts tables in section 1 of `BUILD_GUIDE.md` do not share turns `check:ci` red, and so does a designator that the BOM and `hardware/pcb/schematic.pdf` do not share. The schematic's designators come out of its text layer through `pdftotext` (poppler); where it is not installed the check skips, and under `PF_DRIFT_STRICT=1` the skip fails. Part names on the board are not compared, since the guide words them its own way. Off the board, where there is no MPN, the rows pair in order and each guide row keeps every word of its BOM row's part. - **Besoiobiy's printed case is the second enclosure remix** (`hardware/case/remixes/besoiobiy-printed/`). Two long pieces glued side by side, a frame round the panel and a box for the board and the power bank, closed at the back by four screwed-on covers, with the panel bolted through the case into its own M3 sockets. Its README takes the place of the build guide's sections 4 and 6 and sets the order of 7 to 9; check your panel against it first, because the BOM's panel takes M4 screws on another hole pattern. The build guide's printing section and the assembly map point at it. - **Mykyta Bilous' horizontal desktop case is the third enclosure remix** (`hardware/case/remixes/mbchars-horizontal-desktop-printed/`, [#488](https://github.com/engmung/Patternflow/pull/488) by [@mbchars](https://github.com/mbchars)). The panel lies the long way across on two removable stands, with the four controls on the right. Three front sections print face down, two white frames and an orange control section, and four joint bars on brass inserts hold them together without glue; two rear covers open onto the electronics and carry wall-mount openings. There is no power-bank bay: a separate PDSink 303PDSink01 trigger module in the control section feeds J4, the screw terminal, at 5 V. One Bambu Studio project holds all nine plates, with illustrated assembly instructions and the BOM changes beside it. ### Web +- **`npm run doc-map`** writes every link into the repository, from Markdown, site code, the console pages and the redirect table, to `docs/investigations/2026-10-doc-map.csv` with whether it resolves. 1007 links at the baseline, none broken. +- **`check:features` fails when /features and the feature folders disagree.** An id with no folder under `firmware/patternflow/features/`, or a folder with no id, turns `check:ci` red, and the run prints both lists and what is on one side only. `core` is the one id without a folder, because the comment on `Feature.id` says so. It is the first check `npm run check:drift` runs: every file in `web/scripts/drift/` is one, and the rest of the cleanup's step 2 lands there. +- **`check:links-absolute` fails on a GitHub link into the repository that does not land.** `web/scripts/doc-map.ts --check absolute` reads the doc map's 118 absolute links from 26 files, writes nothing, and fails on one whose path or anchor is missing at HEAD or at the tag it names, or on a repository URL in a file the map does not read. A tag the clone lacks is skipped and named, and `PF_DRIFT_STRICT=1` fails on that skip. +- **`check:editions` fails when the shelf and the bundles disagree.** A card on /editions with no folder under `firmware/bundles/`, or a bundle that is neither a card nor a try-out on /features, turns `check:ci` red. So does a folder in `web/public/flash/bin/` that no card, try-out or manifest names, and an `EDITIONS` tuple in `release.py` or `check_versions.py` that is not the cards. The default card and a card released from another repository need no bundle, and both are read from the tree. +- **`check:presets` fails when a web preset cannot reach a panel.** A `pattern-*.ts` in `web/src/lib/presets/` that `index.ts` does not import, or an import with no file, turns `check:ci` red. So does a preset with a firmware twin that `pattern_registry.h` does not compile in and the Basics pack does not carry, and a pack entry whose pattern number no web preset has. The web-only and firmware-only presets are read from `check_presets.py`, not copied. +- **`check:human-lock` fails a pull request that changes a word of the maintainer's own writing.** The journal, the manifesto, the release notes, the README's three story sections, Part 1 of each RFC and the roadmap's `detail` and `detailKo` strings are compared with the pull request's base as plain text, with the Markdown, MDX and whitespace taken out. Markup, link paths and image paths can change; a typo fix cannot. A locked file moved word for word passes, and the run says the move needs the maintainer's approval. `docs/.human-written` takes more paths, one per line. +- **`check:changelog` fails when `CHANGELOG.md` and `docs/releases/` disagree.** A release the root keeps in full equals its file once its relative links point two folders up. A line under "Older releases" matches its file's heading, and every `docs/releases/vX.Y.Z.md` is in the root one way or the other. A drifted section prints the line and column where the two copies part, and a release cut by `release.py` stays green. +- **Every pull request runs the drift checks and the text lock, whatever it touches.** `.github/workflows/drift.yml` (Drift checks) runs `check:drift` on every pull request and on push to `main`; Web CI starts only on `web/` and `hardware/case/`, so a pull request that edits only the BOM or a bundle never ran it. It fetches the tags and installs `pdftotext` and runs with `PF_DRIFT_STRICT=1`, so a check that cannot read a side fails instead of skipping. On a pull request it also runs `check:human-lock` against the base commit. +- **`check:roadmap` fails when a /roadmap node and its issues disagree.** `gh` reads the state of every issue the page names on engmung/Patternflow. A `done` node with an open issue, a `planned` node with a closed one, or an issue GitHub answers 404 for turns `check:drift` red. An issue left open on purpose goes in the check's own list, with the maintainer's reason. Without `gh`, a login or access to the repository it skips, and the Drift checks workflow runs it strict. +- **`check:voice` points at the habits of generated prose, line by line.** Run it by hand on new or rewritten text before a pull request opens. With no arguments it reads the lines the branch adds; it also takes files, and `-` reads a PR description from stdin. It flags a word from `docs/voice-banned.txt`, three adjectives or clauses after a serial comma, an opening that says what the text is about and a closing summary, each as `file:line`. CI does not run it. +- **Five of the site's twelve lint warnings are gone.** The unused `normalizePerformance` and `useMediaQuery` imports go, and so do `REASON_LABEL` on the edition card and `zoomTo` on the roadmap map, which nothing read. The map's `clampPan` is now a `useCallback` on `width` and `zoom`, so the wheel effect lists it and still runs on the same renders. Nothing the site serves changes. +- **The seven `` lint warnings carry a named exception.** The breadboard build photos, the hero poster and the journal lightbox stay plain ``, each with an `eslint-disable-next-line @next/next/no-img-element` line that says why; `npm run lint` drops from twelve warnings to five. +- **Pattern Lab's July storage keys say who wrote them and when.** The draft and ramp keys the lab wrote in July 2026, the gallery key it still writes, and the v1 show bake each name their commit and date. The comments no longer say the migration reads the draft once: it runs on every open where no project restores. `legacyGallery` becomes `gallery` with the same key string, and `saveDraft`, which nothing called, is gone. Nothing the site serves changes. +- **The site's redirects and old routes say what links them.** The two journal `?lang=en` redirects, the `/en/en` collapse in the proxy, the eight campaign short links, `/business` and `/community` on the Vercel site each carry a comment with the commit and date that made them and what still points there. The `/variants` comment now dates the console links to 3.7.0 through 3.8.0. Nothing the site serves changes. +- **Web CI fails on a lint warning.** The Lint step runs `npm run lint -- --max-warnings=0`, so the site stays at the zero warnings it reached in step 3 of the cleanup. +- **The community's old formats say what wrote them and when.** The deck's `pf-module-cart` and `pf-saved` keys, the feed's `page`, `size` and `view` parameters, the `"bin"` build format the builds route answers with 410, and the zip page's `duplicate-name` each carry a comment with the commits and dates. `moduleRunner.ts` no longer cites the removed `buildRunner.ts`. Nothing the site serves changes. +- **A new TODO or FIXME turns the drift checks red.** `web/scripts/drift/markers.ts` reads every tracked text file under `firmware/`, `web/src/`, `web/scripts/`, `tools/` and `integrations/`, and prints the file and line of either word in capitals. Vendored code is skipped, so the one `TODO` in upstream's HUB75 driver stays where its `UPSTREAM.md` records it. +- **One file holds the values the firmware and the site both carry.** `shared/constants.json` has the three panel geometries, the knob count, 24 detents a turn, the encoder and button timings and the bus scale. `npm run gen:shared` in `web/` writes it out as `firmware/patternflow/src/core_shared.h` and `web/src/lib/shared/constants.ts`, and `check:shared`, part of `check:ci` and the drift workflow, fails when either file is stale or when the frozen `abi/pf_params.h` or `build_module.py` disagrees with it. Nothing reads the two files yet. +- **The site's lint and test tooling is on its latest releases inside the current majors.** ESLint 9.39.5, `@testing-library/dom` 10.4.2, tsx 4.23.15 and `@types/node` 20.19.43, in `web/package-lock.json` only. ESLint 10, TypeScript 7, vitest 5, jsdom 29, jest-dom 7 and `@types/node` 26 wait until the cleanup is done. +- **The site reads the panel size and the knob numbers from `shared/constants.json`.** `harness.ts`, `controls.ts`, `matrix.ts`, `panelScreens.ts` and `deviceSim.ts` in `web/src/lib/` take the 128×64 canvas, the four knobs, 24 detents a turn, the bus maximum of 1000, the long press, the debounce and the edges in a detent from `web/src/lib/shared/constants.ts`, and keep every name they export. The guide's panel screens cite firmware constants by name instead of by line number, which had drifted. Nothing a page shows changes, and the rebuilt sandbox differs only where a literal became its constant's name. +- **Drizzle is on its latest patch releases.** drizzle-orm 0.45.4 and drizzle-kit 0.31.11, in `web/package-lock.json` only. +- **Tailwind is on its latest 4.x release.** `tailwindcss` and `@tailwindcss/postcss` 4.3.3, in `web/package-lock.json` only. +- **React Three is on its latest releases inside the current majors.** `@react-three/fiber` 9.8.2, `@react-three/drei` 10.7.9 and `@react-three/postprocessing` 3.2.0, in `web/package-lock.json` only. +- **zustand is on its latest release inside the current major.** 5.0.15, in `web/package-lock.json` only. +- **mediabunny is on its latest release inside the current major.** 1.61.4, in `web/package-lock.json` only. A Pattern Lab clip keeps the same video; its header now names `Mediabunny v1.61.4`, and an MP4 also carries a `btrt` bitrate box. +- **@google/genai is on its latest release inside the current major.** 2.30.0, in `web/package-lock.json` only. Pattern Lab's Generate sends Gemini the same request body; the SDK version in its `x-goog-api-client` header now reads 2.30.0. +- **posthog-js is on its latest release inside the current major.** 1.438.10, in `web/package-lock.json` only. Pageviews and clicks reach PostHog with the same names and site properties, and session replay still shows the Gemini endpoint as `(redacted)`. The PostHog chunk every page loads grows from 61 KB to 104 KB gzipped. +- **better-auth is on its latest release inside the current major.** 1.7.9, in `web/package-lock.json` only. The community's auth requests get the same answers as before, and a session made before the update stays signed in. One answer changes: a sign-in with a password over 128 characters now says "Password too long" instead of "Invalid username or password". +- **React is on its latest release inside the current major.** `react` and `react-dom` 19.3.1 in `web/package.json`, `@types/react` and `@types/react-dom` 19.3.0 in `web/package-lock.json`. The pages still run the React that Next 16.3.0 carries inside it, so the browser gets the same code. The new React runs the component tests. +- **Next is on its latest release inside the current major.** `next` and `eslint-config-next` 16.4.1 in `web/package.json`, `@next/mdx` 16.4.1 in `web/package-lock.json`. `npm audit` goes from 27 to 25: the nine advisories against Next 16.3.0 and the two against sharp 0.35.3 are gone. Pages keep their text and CSS rules. CSS module class names are shorter in production (`hryNmG_atlas`, was `Atlas-module__hryNmG__atlas`). +- **The guide's simulated board takes input that no hand made.** `DeviceSim` reads `knobs`, `params`, `paramActive`, `lanes` and `laneActive` as `/api/status` reports them, takes clicks and MIDI-note presses from a socket, lets go of one bus channel or one lane, drives its lanes from the audio WebSocket, and tells what the pattern was given and threw. The guide shows the same frames. The virtual panel of cleanup step 4 is built on it. +- **`check:virtual-panel` runs every preset on the virtual panel.** Each header in `firmware/patternflow/presets/` but the firmware-only `calib` has to be on the panel's pattern list. Each is selected over REST, runs two seconds of panel time and has to give a 128×64 `/frame.png` with no thrown error. `check:ci` and the drift workflow run it. - **/build lets you pick the case, and the 3D view shows the one you pick.** The Build panel's switch (the official v3.9 case, Besoiobiy's printed case, SimonePDA's laser-cut one; `?case=` links to one) brings up that case's card, a remix's opening with its README on GitHub, and swaps the device in the preview for that case, with the same v3.9 board, DevKit and LED panel inside (and, on Besoiobiy's case, its own knob caps). SimonePDA's case comes in its two finishes, acrylic and MDF. Each model is made by `tools/case-models` from the case's own files in this repository (the guide's v3.9 case, Besoiobiy's STLs, SimonePDA's cutting layout) and sits beside the case's photos, `web/public/cases//model.glb`. The model the page loads first is 0.32 MB, where the old v3.0 one (`3dforweb.glb`, now removed) was 1.15 MB; a remix's model loads when it is picked, and the guide takes its LED panel from the official one. - **/build has a fourth case, mbchars' horizontal desktop one, in the switch and in the 3D view.** Its card is its README's facts and the author's photos, and the switch is four tabs, two by two on a phone. `tools/case-models/mbchars.py` puts its model together from the remix's STLs, since no assembled file exists, and checks it against the README; in it the panel lies the long way (`ledTurn` in `placement.json`), the official knob is printed white (`knobLook`), and the orange is the one in the author's 3MF, `#F46B16` (`pla_orange`). The model is 0.38 MB and loads when the case is picked; the other three rebuild byte for byte. The case is 41 cm across, so on a desktop's tall view it is drawn smaller, by as little as keeps it clear of the arrows; on a phone all four cases, and on a desktop the three upright ones, are still drawn at the one scale. The build map's pin for the same build goes by the maker's name now, mbchars (it was extraplojo). - **The 3D view on /build has arrows and a name.** On the Build tab, arrows either side of the device step through the cases, round and back to the first, as the build map's arrows step through builds; the switch, the card and `?case=` follow. Over the device is whose case it is, the name on its tab (Official, Besoiobiy, SimonePDA, mbchars), in the build map's label. @@ -20,11 +60,60 @@ All notable changes to Patternflow will be documented in this file, newest first ### Docs +- **The cleanup has a baseline.** `docs/investigations/2026-10-cleanup-baseline.md` records the commit the cleanup starts from and what the five commands in `docs/rfc-cleanup.md` §2.1 said there. All five pass. `AGENTS.md` gains hard rule 8: during a cleanup window no PR changes behaviour, and moves and renames carry a redirect or an updated link map. +- **Cleanup step 1 is planned.** [`docs/investigations/2026-10-cleanup-step1-plan.md`](docs/investigations/2026-10-cleanup-step1-plan.md) cuts the step into three lanes that share no file (root moves, the changelog split, the ko/en parity list), lists what the RFC did not know about the tree (`docs/releases/` already exists; the ko/en pairs it names do not), and asks the maintainer five more questions, each with a default. +- **The v2 build guide moved to `docs/legacy/BUILD_GUIDE_v2.md`.** Every link to it in the repository and the site's v2 board link point at the new path; links from outside to the old root path break, since GitHub serves no redirect for a moved file. +- **The doc map lists the Korean/English pairs.** `npm run doc-map` in `web/` also writes `docs/investigations/2026-10-doc-parity.csv`: every text the repository keeps in both languages (the journal's posts, the guide's pages and copy, the roadmap, the atlas), one row per pair, with each side's length and whether one is missing or short. Korean is measured at 2.4 English characters a syllable, as the journal runs, so a faithful translation reads `ok`; today it flags 26 rows, among them the device's Format storage dialog, which the Korean guide quotes in English. A later `x.ko.md`, a console page's twin or a `Ko` field in `features-data.ts` lands in the same list. +- **The doc map reads the YAML under `.github/`.** `npm run doc-map` in `web/` now follows the links in the issue forms and their `config.yml`, so a PR that moves `SUPPORT.md` or `CONTRIBUTING.md` sees the contact link it would break. Six rows today, all resolving. +- **`CHANGELOG.md` is split by release into `docs/releases/`.** Every release has its own file, `vX.Y.Z.md`, back to v1.0.0, with its section copied as written and its relative links two folders up; the root keeps the three newest in full and a line linking each older one. `release.py cut` writes the new release's file and moves the fourth-newest section out of the root. The long-form notes for 3.0.0 and 3.1.0 are now `v3.0.0-notes.md` and `v3.1.0-notes.md`. +- **The community files moved to `.github/` and the audio guide to `docs/`.** `CODE_OF_CONDUCT.md`, `SECURITY.md` and `SUPPORT.md` are now in `.github/`, where GitHub still finds them for the community profile and the security tab, and `AUDIO_GUIDE.md` is `docs/AUDIO_GUIDE.md`. Every link to them in the repository, the issue chooser and the site points at the new paths. Links from outside to the old root paths break, since GitHub serves no redirect for a moved file. +- **Cleanup step 2 is planned.** [`docs/investigations/2026-10-cleanup-step2-plan.md`](docs/investigations/2026-10-cleanup-step2-plan.md) cuts the seven drift checks, the changelog check, the human-text lock and the voice lint into fourteen PRs that share no file, and records what the tree already holds for each: the schematic's text layer carries every designator, and `check_presets.py` already covers half of `check:presets`. +- **Cleanup step 3 is planned.** [`docs/investigations/2026-10-cleanup-step3-plan.md`](docs/investigations/2026-10-cleanup-step3-plan.md) cuts §2.5 into twenty-seven PRs, no two of which edit a file while both can be open, and records what scouting found: the MIDI page still counts 20 detents a turn where the encoder has 24, the HUB75 fork carries more local changes than `VENDORED.md` lists, and first-party code has no `TODO` or `FIXME` left. +- **The project's writing voice is written down.** `docs/voice.md` describes, on one page, the voice the README and the build guide already use for English, and a standard for Korean taken from published Korean editing guides: the marks of Korean carried over from English, with two lines from the web guide shown beside a version written in Korean first. `docs/voice-banned.txt` starts the list of words that stay out of the docs, for the voice check the cleanup adds later. +- **Cleanup step 4 is planned.** [`docs/investigations/2026-10-cleanup-step4-plan.md`](docs/investigations/2026-10-cleanup-step4-plan.md) cuts §2.6 into twenty-four PRs, no two of which edit a file while both can be open, and records what scouting found: the virtual panel can be the guide's `DeviceSim` behind sockets, `/api/status` shows no knob delta that arrives over OSC, MIDI, MQTT or the audio socket, and `rest-api.md` contradicts itself in eight places a vector has to choose between. +- **The docs index and the repository map catch up with step 1 of the cleanup.** `docs/README.md` and `docs/REPOSITORY.md` say the root keeps three guides and no others, describe `docs/releases/` as one file per release, and list `voice.md`. The doc map is regenerated, and all 1066 of its links resolve. +- **Cleanup step 5 is planned.** [`docs/investigations/2026-10-cleanup-step5-plan.md`](docs/investigations/2026-10-cleanup-step5-plan.md) cuts §2.7 into four PRs: a record of every place the panel size is set, the any-resolution draft, the Linux player draft, and a close that waits until steps 3 and 4 free the files it shares with them. Scouting found that the 36 references §2.7 counts are a floor (the presets alone carry 142), that the site's pattern engine already takes any size while the device and its simulator do not, and that the S-PWM panels the firmware cannot drive are the ones a Colorlight receiving card, one of the player's outputs, is built for. +- **The contributor docs say what CI runs on every pull request.** `CONTRIBUTING.md`, `web/README.md`, `web/ARCHITECTURE.md` and `docs/REPOSITORY.md` now name `drift.yml`, which runs the drift checks and `check:human-lock` on every pull request whatever it touches. `docs/REPOSITORY.md` no longer says the absolute `blob/main` links go unchecked: `check:links-absolute` fails a pull request that breaks one. +- **`AGENTS.md` carries the writing-voice rule and the step 2 check commands.** The writing-voice hard rule from `docs/rfc-cleanup.md` §2.10 is appended as the RFC writes it, and Common commands gains one line each for `check:drift`, `check:human-lock` and `check:voice`, saying where each runs. +- **The doc map is regenerated at the close of step 2 of the cleanup.** All 1072 of its links resolve. The six new rows are links the step 2 pull requests and the step 5 plan added. +- **Where the panel size lives, line by line.** `docs/investigations/2026-10-panel-geometry.md` counts every place the firmware and the site set or assume 128×64 at `e3d3e83`, from the 36 `PANEL_RES_W/H` uses §2.7 names to the presets, the module toolchain, the driver's DMA buffers and the site's simulator, with the command behind each count. It proposes nothing. The any-resolution and Linux player drafts build on it. +- **`docs/midi-spec.md` says 24 detents a turn.** `outDiv=1` is 24 steps a turn and `4` is 6, where it said 20 and 5. +- **The any-resolution RFC has a draft.** `docs/rfc-any-resolution.md` is Part 2 only. It says where each of the 36 uses of `PANEL_RES_W` and `PANEL_RES_H` goes, proposes a geometry read from NVS at boot and set on a core console page, keeps one module build per size with the size in the cache key, states the limits (classic HUB75 only, S-PWM unsupported, W × H up to 8,192 at depth 8, the stock's own memory) and orders the work as 19 PRs. Part 1 is the maintainer's and is empty. +- **The Linux player has a draft RFC.** `docs/rfc-linux-player.md` is Part 2 of the player path in the cleanup RFC's §2.7: the site's engine in a Linux process, with outputs for a Pi's HUB75 HAT through rpi-rgb-led-matrix, a Colorlight receiving card and HDMI, input from USB encoders, MIDI, OSC and the web knobs, the contracts it speaks and in what order, what it cannot do, the work as fourteen PRs, and nine questions for the maintainer. Part 1 is left for him. Nothing the firmware or the site does changes. +- **`AGENTS.md` names `shared/`, the HUB75 driver's `UPSTREAM.md` and `patternflow.patch`, and the commands step 3 added.** The repository map has `shared/constants.json` and the two files generated from it, and says the patch is rebuilt with the command in `UPSTREAM.md`, never edited by hand. Common commands gain `gen:shared` with `check:shared`, the lint gate (`--max-warnings=0`, an exception with its reason after `--`), and the `markers` drift check. +- **The two step 5 drafts are listed in `docs/README.md`.** `rfc-any-resolution.md` and `rfc-linux-player.md` link each other, and the doc map carries their rows. +- **The repository maps catch up with step 3.** `docs/REPOSITORY.md` has a top-level row for `shared/`. `web/ARCHITECTURE.md` says the pattern harness takes its constants from `shared/constants.json`, and calls `patternflow_lab_gallery_v1` the live gallery key instead of a legacy one. `firmware/README.md` sends readers of its vendored HUB75 note to `UPSTREAM.md` and `patternflow.patch` instead of naming two additions. +- **The doc map is regenerated at the close of step 3 of the cleanup.** All 1095 of its links resolve. The three new rows are links step 3 added to `docs/REPOSITORY.md` and `firmware/README.md`. +- **The REST contract has its first vectors.** `docs/rest-api-vectors/` holds nine, for `GET /api/status`, `POST /api/params` and `POST /api/sleep`, each a sentence of `docs/rest-api.md` and the requests that show a panel doing it. `npm run check:rest-vectors` in `web/` replays them against a fresh virtual panel each, and the drift workflow runs it on every pull request. +- **The MQTT contract has its first vectors.** `docs/mqtt-vectors/` holds nine, for the topic table and the two roles in `docs/mqtt-spec.md`: the sleep words and `sleep/state`, `param/N` and its empty payload, `knob/N` as a difference, `pattern` by name or slug, and what a Publisher ignores and sends. `npm run check:mqtt-vectors` in `web/` replays them against a fresh virtual panel each, through a test broker of its own (`web/scripts/drift/_broker.ts`), and the drift workflow runs it on every pull request. +- **The REST vectors reach the rest of the core.** Twenty-five more in `docs/rest-api-vectors/` read `/api/display`, `/api/knobs`, `DELETE /api/crash`, the favicon, the pattern routes and the headers every route shares. On the default build `caps` is `["patterns","params","sleep"]` and each feature's route answers the plain-text 404. +- **The MQTT vectors reach the channels and the snapshot, and `/api/mqtt` has REST vectors.** Six more in `docs/mqtt-vectors/` replay the prefix naming the channel, channels 1–4 forcing Subscriber, the retained snapshot on channels 1–4 and Live, the Publisher's eight-second heartbeat, a snapshot overwriting a `param/N` write, and Broadcast taking no snapshot. Seven in `docs/rest-api-vectors/` read `GET` and `POST /api/mqtt`: its fields, the password it never returns, `forcesSub`, the knob counts in every role, `mqttRole` in status and the `mqtt` cap. +- **The maps of the tree name the virtual panel and the contract vectors.** `docs/README.md` puts each contract's vector folder on its line, as it already did for pfst, and adds `docs/contract-vectors.md`. `tools/virtual-panel/README.md` lists its eight transports with the feature each needs and the port it answers on. `CONTRIBUTING.md` says a spec change brings its vectors along, and `web/README.md` and `web/ARCHITECTURE.md` name the five vector suites and `check:virtual-panel`. +- **The REST and MIDI specs say one thing where they said two.** HTTP writes knobs and reads them from `/api/status`, select answers with the pattern it queued, `d1`..`d4` alone are a whole `POST /api/params`, `caps` names what each edition adds, and only a path no route serves gets a plain-text `404`. In midi-spec.md, CC 24–27 are binary offset and a host's Program Change comes back as the pattern change it made. +- **`AGENTS.md` names the virtual panel, the contract vectors and their commands.** The repository map adds `mqtt-spec.md`, the vector folder beside each of the five wire contracts with `contract-vectors.md` as their format, and `tools/virtual-panel/`. Common commands gain the five `check:*-vectors` suites, `check:virtual-panel` and `npm run virtual-panel`. +- **The doc map is regenerated at the close of step 4 of the cleanup.** All 1138 of its links resolve. The 43 new rows are links step 4 added: the five vector folders' READMEs, `docs/contract-vectors.md`, `tools/virtual-panel/README.md`, and the maps of the tree that point at them. - **The build pages say what is true now.** Laser cut is no longer "being prepared" on /build, in the README, the v2 guide or `llms.txt`: SimonePDA's case is it. The printing time is about 10 hours for the official case on a 256 mm bed (/build said 10–12, the breadboard guide 11), the v2 guide and the assembly map name the v3.9 board, and the breadboard guide flashes through the DevKit's native USB port from the Pattern tab and links the sections that exist. - **The README opens with what Patternflow is and three ways in.** The tagline and the first line are the manifesto's fixed lines (L1, L2, L3), with *Try one in your browser*, *Build one* and *Get one assembled* above the badges, and the guide's four parts follow as a list, each with its written guide beside it. Paik is told once, in *Where it began*, closed by L5 in full. Patterns come before the device, and **First light** follows the build: J4-only power, download mode first, the Wi-Fi step that usually doesn't come and what to do, unplugging before the DevKit is seated, the panel's own network, the Basics pack with *Format storage*. Lines that had gone stale were brought up to date: the Crowd Supply campaign reads as it stands, past its goal and open until 15 October, sound and MQTT shipped in editions, the hand-drawn map that still showed the Workshop is out, and the timeline runs to October. The long reference material stays, folded. - **The READMEs and docs were tidied.** Where a doc gives the first flash (`firmware/README.md`, `PATTERN_GUIDE.md`, `SUPPORT.md`, `docs/assembly/`) it now says download mode first, that the Wi-Fi step usually doesn't appear the first time and what to do, and the hotspot. The indexes (`docs/README.md`, `docs/REPOSITORY.md`, the hardware and remix READMEs, `web/README.md`, `web/ARCHITECTURE.md`) list the guide pages, the three case remixes, `legacy_lasercut/` and the bundles that exist, and stale wording went: the NETWORK screen's old rows, the custom slots, "variant", the dead `/flash` page. `BUILD_GUIDE_v2.md` is as it was. - **The build guide flashes in the Play guide's order.** `BUILD_GUIDE.md` §8.1 is the Play guide's Flash chapter in writing, step for step: download mode (hold BOOT, tap RST, let go) before Flash Patternflow on every first flash, where it had been the fallback for an empty port list; the Wi-Fi step expected not to come the first time, with RST, Flash Patternflow again and *Connect to Wi-Fi* as the moves; the DevKit seated with the power bank unplugged, then powered from `J4`; and the panel's own network, `patternflow-xxxx`, as the next step when that didn't take, before the Basics pack. Section 7 no longer seats the DevKit before it has firmware, so it is *Wiring* now (anchor `#7-wiring`), and the README's First light points at §8 again as the same steps in writing. Besoiobiy's case README cites the renumbered steps. +### Tools + +- **A virtual panel for the contract vectors.** `tools/virtual-panel/` runs the guide's `DeviceSim` behind an HTTP port: `GET /frame.png` is the 128×64 frame, `/vp/` is a hand and the clock, and each transport is a file in `transports/`. `npm run virtual-panel` in `web/` starts it. +- **The virtual panel answers device state over REST.** `tools/virtual-panel/transports/rest-state.ts` serves `GET /api/status` with every field `docs/rest-api.md` documents, `POST /api/params`, `POST /api/sleep`, `GET /api/display`, `/api/knobs`, `DELETE /api/crash` and the favicon, on the guide's `DeviceSim`. +- **The virtual panel answers the pattern routes.** `GET /api/patterns`, `/api/patterns/select` and `/api/patterns/file` on `tools/virtual-panel/`: the Basics pack's `.pfm` and sidecar out of `basics.zip`, and the `PFT1` picture a pattern leaves once it has run two seconds. +- **Contract vectors have a format and a replay.** `docs/contract-vectors.md` says how a vector is written: the spec sentence it comes from, the features built in, and steps that send over HTTP, a hand, the clock or one of the four other transports and expect a reply, `/api/status`, what the pattern was given or what the panel sent and how soon. `web/scripts/drift/_vectors.ts` replays a folder of them, each against a fresh virtual panel, for the five suites of cleanup step 4. +- **The virtual panel speaks MQTT.** `tools/virtual-panel/transports/mqtt.ts` is an MQTT 3.1.1 client on Node's `net`: the Publisher and Subscriber roles, the Broadcast, channel 1 to 4, Live and Custom prefixes, every public topic in `docs/mqtt-spec.md`, and the retained snapshot every eight seconds. Like a device that was never set up, it dials nothing until a role and a broker are set. +- **The virtual panel answers `/api/mqtt`.** On a panel started with `--features mqtt`, `tools/virtual-panel/transports/mqtt-http.ts` reports the MQTT client's state and sets its broker and role in one `POST /api/mqtt`, as `docs/mqtt-spec.md` describes. `/api/status` gains the MQTT fields the firmware adds there. +- **The virtual panel speaks OSC.** `tools/virtual-panel/transports/osc.ts` listens on 9001 and sends to port 9000 of the host that last sent it a message it knows: the announce, the heartbeat, a hand's knobs and buttons, and pattern and mode changes, as `docs/osc-spec.md` lists them. `--features osc` composes it in, and `--port oscReply=0` sends each reply back to the port it came from. +- **OSC has contract vectors.** `docs/osc-vectors/` holds eleven, each one sentence of `docs/osc-spec.md` and the messages that show a panel doing it, from the ping and its announce to a `#bundle` the panel ignores. `npm run check:osc-vectors` in `web/` replays them against the virtual panel built with the `osc` feature, and CI runs it with the other drift checks. +- **The virtual panel takes the audio WebSocket.** `tools/virtual-panel/transports/audio-ws.ts` listens on 81 for the five messages `docs/audio-ws-spec.md` lists and holds each lane 500 ms past the last message that set it. `GET` and `POST /api/audio` read and flip the AUD switch. `--features audio` composes it in, and `--port ws=…` moves the socket. +- **The virtual panel speaks RTP-MIDI.** `tools/virtual-panel/transports/midi.ts` takes a session on 5004 and 5005 under the name Patternflow, two participants at most, and moves knobs, buttons and the pattern by the host → device table in `docs/midi-spec.md`: CC 20 to 27, notes 60 to 63 and Program Change on channel 1. `--features midi` composes it in, and `--port midi=N` moves the pair to N and N+1. +- **The audio WebSocket has contract vectors.** `docs/audio-ws-vectors/` holds twelve, each one sentence of `docs/audio-ws-spec.md` and the messages that show a panel doing it, from `a=` with `-` slots to a lane a hand holds for five seconds. Three more in `docs/rest-api-vectors/` read `GET` and `POST /api/audio`. `npm run check:audio-vectors` in `web/` replays them against the virtual panel built with the `audio` feature, through a WebSocket client that runs on Node 20, and CI runs it with the other drift checks. +- **The virtual panel sends MIDI back.** `tools/virtual-panel/transports/midi-out.ts` reports a hand's turns on CC 24 to 27 (a position by default, 64 ± steps with `outMode=rel`, at most one message a knob every 50 ms), a hand's buttons on notes 60 to 63 and each pattern change as Program Change, by the device → host table in `docs/midi-spec.md`. It answers `GET` and `POST /api/midi` and adds `midi` to `/api/status`. +- **MIDI has contract vectors.** `docs/midi-vectors/` holds nine, each one sentence of the host → device table in `docs/midi-spec.md` and the messages that show a panel doing it, from CC 20 at 127 landing on 1000 to a message on channel 2 the panel ignores. `npm run check:midi-vectors` in `web/` replays them against the virtual panel built with the `midi` feature, over an RTP-MIDI session `web/scripts/drift/_rtpmidi.ts` opens for each, and CI runs it with the other drift checks. +- **MIDI out and `/api/midi` have contract vectors.** Eleven files in `docs/midi-vectors/` start with `out-`, each one sentence of the device → host table or the settings in `docs/midi-spec.md`, from three detents on knob 1 arriving as CC 24 at 67 to a host's own CC that never comes back. `check:midi-vectors` replays them over its RTP-MIDI session. Nine more in `docs/rest-api-vectors/` start with `midi-` and read `GET` and `POST /api/midi` and the `midi` block in `/api/status` under `check:rest-vectors`. + ## [3.11.0] - 2026-10-04 ### Firmware diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3cf1200a..291c1073 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to Patternflow -Patternflow is a device, a firmware, a website and a pile of documentation, and people contribute to all of them. This page says where each kind of contribution goes, what checks it meets, and the few rules that are not negotiable. Where to *ask* things is [SUPPORT.md](SUPPORT.md); how we treat each other is [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md); what is where is [docs/REPOSITORY.md](docs/REPOSITORY.md). +Patternflow is a device, a firmware, a website and a pile of documentation, and people contribute to all of them. This page says where each kind of contribution goes, what checks it meets, and the few rules that are not negotiable. Where to *ask* things is [SUPPORT.md](.github/SUPPORT.md); how we treat each other is [CODE_OF_CONDUCT.md](.github/CODE_OF_CONDUCT.md); what is where is [docs/REPOSITORY.md](docs/REPOSITORY.md). ## Where your contribution goes @@ -14,12 +14,14 @@ Patternflow is a device, a firmware, a website and a pile of documentation, and | A fix to the firmware **core** (`patternflow.ino`, `src/`) | the file itself — for a bug or a real improvement to the device, never to make room for a feature | | the same, and the change must still be right on a build that has no features at all | | An enclosure or mechanical **remix** | `hardware/case/remixes/-/`, with a README that states Author, License, what it is based on, which board it fits, and the print or cut settings | one new folder; the official folders stay as they are | the README fields are checked | | A **bridge** to host software (TouchDesigner, Node-RED, Max, a DAW…) | `integrations//` with a README, plus a row in `integrations/README.md`. It talks to the panel through the contracts in `docs/` and never imports firmware or web code | one new folder | the README has to name the contract it is built against | -| A change to a **wire contract** | [`docs/rest-api.md`](docs/rest-api.md), [`osc-spec.md`](docs/osc-spec.md), [`midi-spec.md`](docs/midi-spec.md), [`mqtt-spec.md`](docs/mqtt-spec.md), [`audio-ws-spec.md`](docs/audio-ws-spec.md), [`pfst-v2-spec.md`](docs/pfst-v2-spec.md) | the spec: bump its version line and add a Version history row | `docs-links` | -| Web app work | `web/` — start at [`web/README.md`](web/README.md) and [`web/ARCHITECTURE.md`](web/ARCHITECTURE.md) | | `web-ci`: lint, typecheck, the `check:*` suites, `next build` | +| A change to a **wire contract** | [`docs/rest-api.md`](docs/rest-api.md), [`osc-spec.md`](docs/osc-spec.md), [`midi-spec.md`](docs/midi-spec.md), [`mqtt-spec.md`](docs/mqtt-spec.md), [`audio-ws-spec.md`](docs/audio-ws-spec.md), [`pfst-v2-spec.md`](docs/pfst-v2-spec.md) | the spec: bump its version line and add a Version history row. A vector that quotes a sentence you change changes with it, in the folder beside the spec ([`contract-vectors.md`](docs/contract-vectors.md)) | `docs-links`, and `drift`, which replays every vector against the [virtual panel](tools/virtual-panel/README.md) | +| Web app work | `web/` — start at [`web/README.md`](web/README.md) and [`web/ARCHITECTURE.md`](web/ARCHITECTURE.md) | | `web-ci`: lint, typecheck, `check:ci` (the smoke suites and the drift checks), `next build` | | A **pattern** | **not this repository.** Make it in the [Pattern Lab](https://patternflow.work/pattern-lab), press **Publish**, and it is on the [Community](https://community.patternflow.work/community) for every Patternflow to play, under the license you choose. [PATTERN_GUIDE.md](PATTERN_GUIDE.md) walks the loop. The presets bundled in the firmware and the Basics pack are curated by the maintainer from the repository's own sources | | | Parts sourcing tips, better photos of a build step, a clearer sentence: all of those are the first row. +Every pull request, whatever its row, also runs `drift`. It fails when two things the tree keeps by hand stop agreeing, the BOM and the parts table in `BUILD_GUIDE.md` for one, and when a word of the maintainer's own writing changes ([docs/rfc-cleanup.md §2.9](docs/rfc-cleanup.md#29-human-written-text-lock) names the files). + ## Sending it 1. Fork the repository and branch from `dev` (`fix/…`, `feat/…`, `docs/…`). diff --git a/README.md b/README.md index c81b3535..31b6f71f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Started by media artist SeungHun Lee ([@engmung](https://github.com/engmung)) in - **[Build](https://patternflow.work/guide/build):** you're soldering one from bare parts, about US$100–200 of them. Or read [BUILD_GUIDE.md](BUILD_GUIDE.md). - **[Play](https://patternflow.work/guide/play):** yours is built. Flash it, put it on your Wi-Fi, learn the four knobs. Or read [First light](#first-light), below. - **[Make](https://patternflow.work/guide/make):** make your own patterns, with or without a Patternflow. Or read [PATTERN_GUIDE.md](PATTERN_GUIDE.md). -- **[Audio](https://patternflow.work/guide/audio):** it plays; now add sound and MIDI with the Audio edition. Or read [AUDIO_GUIDE.md](AUDIO_GUIDE.md). +- **[Audio](https://patternflow.work/guide/audio):** it plays; now add sound and MIDI with the Audio edition. Or read [AUDIO_GUIDE.md](docs/AUDIO_GUIDE.md). ## Where it began @@ -97,7 +97,7 @@ The **[Build Guide](BUILD_GUIDE.md)** is the official route: the v3.9 board in a **Buy the LED panel the [BOM](BUILD_GUIDE.md#off-the-board) links.** It's verified, and the case is measured around its screw holes. Another 128 × 64 panel can match the spec line for line and still stay completely dark, because its driver chips are wrong for the board; **[Panel Compatibility](docs/panel-compatibility.md)** says how to tell before you pay. -Other routes lead to the same instrument. No printer? **[Simone Majocchi's case](hardware/case/remixes/simonepda-lasercut/README.md)** is laser-cut from acrylic or MDF, and [two more printed cases](hardware/case/remixes/README.md) sit beside the official one. No board order? The **[breadboard build](https://patternflow.work/build/breadboard)** wires the same electronics with jumper wires, and that's a real Patternflow, not a temporary prototype. Every other combination is on the **[Assembly Map](docs/assembly/README.md)**. Holding a v2.x board? [BUILD_GUIDE_v2.md](BUILD_GUIDE_v2.md), with everything it needs bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). The current board's known issues and design notes are [BUILD_GUIDE §10](BUILD_GUIDE.md#10-known-issues--design-notes). +Other routes lead to the same instrument. No printer? **[Simone Majocchi's case](hardware/case/remixes/simonepda-lasercut/README.md)** is laser-cut from acrylic or MDF, and [two more printed cases](hardware/case/remixes/README.md) sit beside the official one. No board order? The **[breadboard build](https://patternflow.work/build/breadboard)** wires the same electronics with jumper wires, and that's a real Patternflow, not a temporary prototype. Every other combination is on the **[Assembly Map](docs/assembly/README.md)**. Holding a v2.x board? [BUILD_GUIDE_v2.md](docs/legacy/BUILD_GUIDE_v2.md), with everything it needs bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). The current board's known issues and design notes are [BUILD_GUIDE §10](BUILD_GUIDE.md#10-known-issues--design-notes). **When yours lights up, share it** in [Discord](https://discord.gg/Vr9QtsxeTk) or through the [Share your build](../../issues/new?template=share_build.yml) form, and I'll add your pin to the build map. The goal is simple: cover it with pins. @@ -151,7 +151,7 @@ The default firmware is the instrument and nothing else, which leaves the most m | :--- | :--- | :--- | :--- | | **MIDI over Wi-Fi** | The panel is a MIDI port in any DAW (Ableton, Logic, Bitwig); its knobs and buttons map like any controller's (Ctrl-M in Live), and CC and Program Change play it back | Audio | [MIDI in Ableton](docs/midi-ableton.md) · [spec](docs/midi-spec.md) | | **OSC, both ways** | Knobs, presses and pattern switches stream out to Max, TouchDesigner or Resolume; incoming OSC moves the panel exactly like a hand on the knob | Audio | [spec](docs/osc-spec.md) · [Max for Live bridge](integrations/ableton) | -| **Audio-react** | A browser tab's audio in four bands you shape, or a small microphone soldered to the DevKit; every pattern reacts with no audio code of its own | Audio | [AUDIO_GUIDE.md](AUDIO_GUIDE.md) · [extension](tools/patternflow-audio-extension) | +| **Audio-react** | A browser tab's audio in four bands you shape, or a small microphone soldered to the DevKit; every pattern reacts with no audio code of its own | Audio | [AUDIO_GUIDE.md](docs/AUDIO_GUIDE.md) · [extension](tools/patternflow-audio-extension) | | **MQTT** | Both ways, on the broker you already run; two panels on one broker follow each other | Performance | [spec](docs/mqtt-spec.md) | | **HTTP** | Knob state, pattern selection, brightness, status | every firmware | [API](docs/rest-api.md) | @@ -237,10 +237,10 @@ Folder by folder, and what you want to do → where to start: **[docs/REPOSITORY **Guide (interactive):** [Start here](https://patternflow.work/guide) · [Build it](https://patternflow.work/guide/build) · [Play it](https://patternflow.work/guide/play) · [Make patterns](https://patternflow.work/guide/make) · [Sound and MIDI](https://patternflow.work/guide/audio), in English and Korean
-**Build:** [Build Guide](BUILD_GUIDE.md) · [Assembly Map](docs/assembly/README.md) · [Panel Compatibility](docs/panel-compatibility.md) · [Hardware files](hardware/README.md) · [v2.x board](BUILD_GUIDE_v2.md)
-**Play:** [Pattern Guide](PATTERN_GUIDE.md) · [Custom Patterns](firmware/CUSTOM_PATTERNS.md) · [Audio Guide](AUDIO_GUIDE.md) · [MIDI in Ableton](docs/midi-ableton.md)
+**Build:** [Build Guide](BUILD_GUIDE.md) · [Assembly Map](docs/assembly/README.md) · [Panel Compatibility](docs/panel-compatibility.md) · [Hardware files](hardware/README.md) · [v2.x board](docs/legacy/BUILD_GUIDE_v2.md)
+**Play:** [Pattern Guide](PATTERN_GUIDE.md) · [Custom Patterns](firmware/CUSTOM_PATTERNS.md) · [Audio Guide](docs/AUDIO_GUIDE.md) · [MIDI in Ableton](docs/midi-ableton.md)
**Extend:** [Feature Guide](FEATURE_GUIDE.md) · [Editions](docs/EDITIONS.md) · [Firmware build](firmware/README.md) · [Web architecture](web/ARCHITECTURE.md) · [HTTP API](docs/rest-api.md) · [OSC Spec](docs/osc-spec.md) · [MIDI Spec](docs/midi-spec.md) · [MQTT Spec](docs/mqtt-spec.md) · [Director → MIDI](docs/director-midi.md)
-**Project:** [Contributing](CONTRIBUTING.md) · [Support](SUPPORT.md) · [Repository map](docs/REPOSITORY.md) · [Changelog](CHANGELOG.md) · [License Summary](docs/LICENSE-SUMMARY.md) · [Manifesto](docs/manifesto.md)
+**Project:** [Contributing](CONTRIBUTING.md) · [Support](.github/SUPPORT.md) · [Repository map](docs/REPOSITORY.md) · [Changelog](CHANGELOG.md) · [License Summary](docs/LICENSE-SUMMARY.md) · [Manifesto](docs/manifesto.md)
**Links:** [patternflow.work](https://patternflow.work) · [Community](https://community.patternflow.work/community) · [Crowd Supply](https://www.crowdsupply.com/engmung/patternflow) · [Releases](../../releases) · [Discord](https://discord.gg/Vr9QtsxeTk) · [Instagram](https://www.instagram.com/patternflow.work) ## What's new @@ -266,7 +266,7 @@ Folder by folder, and what you want to do → where to start: **[docs/REPOSITORY ## Contributing -Builds, documentation fixes, part sourcing tips, enclosure remixes, firmware features and host-software bridges are all welcome. **[CONTRIBUTING.md](CONTRIBUTING.md)** has one table of where each kind goes and what CI runs on it. Questions and ideas go to the **[Discord](https://discord.gg/Vr9QtsxeTk)**, and **[SUPPORT.md](SUPPORT.md)** says where to ask what. Patterns don't come here: publish them from the Pattern Lab to the Community. Galleries, installations and press: [patternflow.work/contact](https://patternflow.work/contact). +Builds, documentation fixes, part sourcing tips, enclosure remixes, firmware features and host-software bridges are all welcome. **[CONTRIBUTING.md](CONTRIBUTING.md)** has one table of where each kind goes and what CI runs on it. Questions and ideas go to the **[Discord](https://discord.gg/Vr9QtsxeTk)**, and **[SUPPORT.md](.github/SUPPORT.md)** says where to ask what. Patterns don't come here: publish them from the Pattern Lab to the Community. Galleries, installations and press: [patternflow.work/contact](https://patternflow.work/contact). ## Story so far diff --git a/docs/.human-written b/docs/.human-written new file mode 100644 index 00000000..1e088aa5 --- /dev/null +++ b/docs/.human-written @@ -0,0 +1 @@ +# Paths the maintainer locks beyond docs/rfc-cleanup.md §2.9, one per line from the repository root; a directory locks what is under it. web/scripts/human-lock.ts reads this file. diff --git a/AUDIO_GUIDE.md b/docs/AUDIO_GUIDE.md similarity index 90% rename from AUDIO_GUIDE.md rename to docs/AUDIO_GUIDE.md index a6e8536e..197c04ff 100644 --- a/AUDIO_GUIDE.md +++ b/docs/AUDIO_GUIDE.md @@ -15,7 +15,7 @@ OSC ([here](#osc--ableton-today-anything-tomorrow)). The same ground step by step, on a 3D Patternflow in the browser: [patternflow.work/guide/audio](https://patternflow.work/guide/audio). -Like the [Feature Guide](FEATURE_GUIDE.md), this page ends with a section +Like the [Feature Guide](../FEATURE_GUIDE.md), this page ends with a section written for AI coding agents; everything above it is for people. --- @@ -28,16 +28,16 @@ Any of these puts the same firmware on your panel: → Audio → **Install to my panel**. Your browser hands the image to a panel that already runs Patternflow, over Wi-Fi; patterns, networks and settings stay. A DevKit that has never run Patternflow takes the core first, from - **Flash Patternflow** ([BUILD_GUIDE §8](BUILD_GUIDE.md#8-firmware)), and + **Flash Patternflow** ([BUILD_GUIDE §8](../BUILD_GUIDE.md#8-firmware)), and then this. - A release's `patternflow-audio.ino.bin` via the console's `/update` page. - Build it yourself: `./firmware/bundles/build.sh audio` (add `flash ` to send it over Wi-Fi). The edition bundles four features: **OSC** (Max/TouchDesigner/Ableton, see -[`docs/osc-spec.md`](docs/osc-spec.md)), **audio** (the streaming path the +[`docs/osc-spec.md`](osc-spec.md)), **audio** (the streaming path the extension uses), **audio_in** (the microphone and the mapping engine) and -**MIDI** (the panel as a network MIDI port, [`docs/midi-spec.md`](docs/midi-spec.md)). +**MIDI** (the panel as a network MIDI port, [`docs/midi-spec.md`](midi-spec.md)). ## The Chrome extension @@ -47,11 +47,11 @@ locally, and streams four knob lanes to the panel over your LAN. **Install:** it's not on the Web Store — load it straight from this repo. `chrome://extensions` → enable *Developer mode* → *Load unpacked* → pick -[`tools/patternflow-audio-extension/`](tools/patternflow-audio-extension/). +[`tools/patternflow-audio-extension/`](../tools/patternflow-audio-extension/). After pulling a newer copy, press *Reload* on its card there. Reload keeps the mapping you already had, so the starting point described below is a new install's. (Every control is described in its -[README](tools/patternflow-audio-extension/README.md), and so is how to get +[README](../tools/patternflow-audio-extension/README.md), and so is how to get that starting point on an older mapping.) **Use:** open a tab with sound → click the extension → enter your panel's @@ -87,7 +87,7 @@ Boxes map to knobs 1:1 and that's fixed on purpose — K2 *is* knob 2. ## The on-board microphone - + A small PDM microphone soldered to the DevKit lets the panel react to the room itself — no browser, no phone, nothing else running. This is an @@ -187,7 +187,7 @@ and input gain; it leaves the Microphone switch and Auto range as they are. ## The phone app (for filming) -[`tools/patternflow-audio-android/`](tools/patternflow-audio-android/) is a +[`tools/patternflow-audio-android/`](../tools/patternflow-audio-android/) is a small Android app that captures whatever the phone is playing and drives the panel with it. It exists for one job: **filming content** — play a track on the phone (reels, whatever), and the panel reacts to the same audio the @@ -195,7 +195,7 @@ video records. For everyday listening the extension or the mic is the better path. It's not on any store — build and install it yourself; the -[README](tools/patternflow-audio-android/README.md) has the full recipe. It +[README](../tools/patternflow-audio-android/README.md) has the full recipe. It opens the panel's own editor page for configuration, so everything above about boxes and curves applies unchanged. @@ -206,13 +206,13 @@ protocol macOS calls *Network MIDI*). Once a session is up the panel is an ordinary MIDI port in any DAW: four knobs in (CC 20–23 absolute, CC 24–27 relative), four buttons (notes 60–63), a pattern selector (Program Change), and the same four knobs, buttons and pattern changes out as they happen. -The contract is [`docs/midi-spec.md`](docs/midi-spec.md); the walk-through +The contract is [`docs/midi-spec.md`](midi-spec.md); the walk-through with screenshots — connecting on macOS, Windows (rtpMIDI) and Linux, mapping in Ableton, the per-knob sensitivity slider, and telling the panel your computer's address so it reconnects itself after a reboot — is -[`docs/midi-ableton.md`](docs/midi-ableton.md). +[`docs/midi-ableton.md`](midi-ableton.md). -This is the missing half of [`docs/director-midi.md`](docs/director-midi.md): +This is the missing half of [`docs/director-midi.md`](director-midi.md): the Director's `.mid` export writes CC 20–23, so drop the clip on a MIDI track, set the track's output to the panel's port, and the show plays on the panel from Live's transport. The switch on the console's **MIDI** page turns it @@ -223,11 +223,11 @@ sensitivity and message counts under `midi`. The edition also speaks **OSC** — plain OSC 1.0 over UDP, both directions. The ready-made client is the **Max for Live bridge** in -[`integrations/ableton/`](integrations/ableton/): load it in Ableton and +[`integrations/ableton/`](../integrations/ableton/): load it in Ableton and your set and the panel talk to each other. But the bridge is just one client of a written contract, -[`docs/osc-spec.md`](docs/osc-spec.md) — anything that can send a UDP OSC +[`docs/osc-spec.md`](osc-spec.md) — anything that can send a UDP OSC message can drive the panel the same way. TouchDesigner, VCV Rack and Processing speak it natively; Blender does through an OSC add-on or a few lines of Python. Send `/patternflow/ping` once and the device learns your @@ -264,12 +264,12 @@ page says which source has the knobs right now. ## For the AI agent -The hard rules — the same ones humans follow — are in [`AGENTS.md`](AGENTS.md#hard-rules-do-not-violate); the contributor flow is [`CONTRIBUTING.md`](CONTRIBUTING.md). +The hard rules — the same ones humans follow — are in [`AGENTS.md`](../AGENTS.md#hard-rules-do-not-violate); the contributor flow is [`CONTRIBUTING.md`](../CONTRIBUTING.md). You were pointed here to work on Patternflow's audio path. The map: **Firmware — the mapping engine and mic** -([`firmware/patternflow/features/audio_in/`](firmware/patternflow/features/audio_in/)): +([`firmware/patternflow/features/audio_in/`](../firmware/patternflow/features/audio_in/)): | file | role | | --- | --- | @@ -277,19 +277,19 @@ You were pointed here to work on Patternflow's audio path. The map: | `core_audio_pdm.h` | PDM mic driver (GPIO 43/44). Its header comment is the authoritative wiring + driver-choice record. | | `core_audio_fft.h` | FFT and spectrum buckets. | | `core_audio_in_map.h` | Bands, curve LUTs (33-point, interpolate-only), attack/damping glide, auto-range, the defaults a reset restores, NVS persistence. Manual mode maps linearly in amplitude between a band's two edges; the page's dB axis is a conversion at the page's edge. | -| `core_audio_in_http.h` | `/audio-in` (editor page), `GET /api/audio-in` (the configuration, read once), `POST /api/audio-in` (any subset: a request changes only the fields it carries), `POST /api/audio-in/reset` (everything, or one band with `band=N`), the `?levels=1` live poll, and the frame endpoint the phone posts spectra to. The contract is [`docs/rest-api.md`](docs/rest-api.md#microphone-audio-edition); the phone app is a second client of it, so fields are added, never renamed. | +| `core_audio_in_http.h` | `/audio-in` (editor page), `GET /api/audio-in` (the configuration, read once), `POST /api/audio-in` (any subset: a request changes only the fields it carries), `POST /api/audio-in/reset` (everything, or one band with `band=N`), the `?levels=1` live poll, and the frame endpoint the phone posts spectra to. The contract is [`docs/rest-api.md`](rest-api.md#microphone-audio-edition); the phone app is a second client of it, so fields are added, never renamed. | | `audio_in_index.h` | **Generated** — never edit (chain below). | -**Firmware — MIDI** ([`firmware/patternflow/features/midi/`](firmware/patternflow/features/midi/)): +**Firmware — MIDI** ([`firmware/patternflow/features/midi/`](../firmware/patternflow/features/midi/)): `core_midi.h` is the mapping (transport-agnostic: handlers in, a sink out), `core_midi_rtp.h` the RTP-MIDI listener on UDP 5004/5005 over lathoub's -AppleMIDI library. Contract: [`docs/midi-spec.md`](docs/midi-spec.md). +AppleMIDI library. Contract: [`docs/midi-spec.md`](midi-spec.md). `tools/rtpmidi-probe/` exercises the whole map from a PC with no MIDI driver. **Firmware — the stream path** -([`firmware/patternflow/features/audio/`](firmware/patternflow/features/audio/)): +([`firmware/patternflow/features/audio/`](../firmware/patternflow/features/audio/)): `core_audio_ws.h` is a WebSocket server on port 81. The wire contract is -[`docs/audio-ws-spec.md`](docs/audio-ws-spec.md) — clients build against the +[`docs/audio-ws-spec.md`](audio-ws-spec.md) — clients build against the spec, not the source. Probe `GET /api/status` for `"audio"` in `caps`. A lane is released 500 ms after the last message that set it, so a sender that means to hold a value resends it; the spec says which senders in this tree do. @@ -319,7 +319,7 @@ Preview without building: `python firmware/toolchain/console_serve.py` assembles `/audio-in` from those sources on every request and answers its API the way the firmware does; `/mock?audio=music`, `silence`, `off`, `fresh`, `nomic`, `stalled`, `phone`, `ext` and `noread` pick what the panel hears -([`console/README.md`](firmware/patternflow/console/README.md#the-page-that-is-assembled-audio-in)). +([`console/README.md`](../firmware/patternflow/console/README.md#the-page-that-is-assembled-audio-in)). The page has a budget, `BUDGET` in `build_audio_in_page.py`: 17,500 bytes gzipped as the panel sends it. It is the heaviest page in the console and every byte crosses the panel's Wi-Fi, so whole-line comments and indentation @@ -335,7 +335,7 @@ every request rides `PF.poll`/`PF.status`, nothing is saved until the panel's mapping has been read, and a save sends only the fields that changed. **Chrome extension** -([`tools/patternflow-audio-extension/`](tools/patternflow-audio-extension/)): +([`tools/patternflow-audio-extension/`](../tools/patternflow-audio-extension/)): `mapping.js` is the mapping model, one object (`PFMap`): defaults, the curves, level → knob. `editor.js` is the single shared editor module, and `editor-adapter.js` is its extension adapter (chrome storage, the capture's @@ -350,7 +350,7 @@ the panel. The extension keeps its cream skin; only the panel's page wears the console's. **Android app** -([`tools/patternflow-audio-android/`](tools/patternflow-audio-android/)): +([`tools/patternflow-audio-android/`](../tools/patternflow-audio-android/)): `Analyzer.kt` mirrors the extension's analysis and mapping (`offscreen.js` and `mapping.js`); `DeviceLink.kt` speaks the WS contract + syncs panel config over `/api/audio-in` + posts monitor frames; @@ -364,9 +364,9 @@ its WebView is set up without the part that shows a `window.confirm` dialog, which is why the page's Reset mapping is a button pressed twice. Open the app's editor once before releasing a change to `/audio-in`. -Rules of the road: firmware changes follow [FEATURE_GUIDE.md](FEATURE_GUIDE.md) +Rules of the road: firmware changes follow [FEATURE_GUIDE.md](../FEATURE_GUIDE.md) (hooks, checkers, `build.sh all`); protocol changes update the spec in the -same PR; API changes extend [`docs/rest-api.md`](docs/rest-api.md) and never +same PR; API changes extend [`docs/rest-api.md`](rest-api.md) and never rename what is there; editor changes run `console_pages.py build` so the panel's page follows — the editor's body is one file for both surfaces, which is the point. diff --git a/docs/README.md b/docs/README.md index ca4b90a9..0ce9a088 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,15 +1,16 @@ # docs/ -What is here, and what is not. The rule for the split: **a guide somebody follows start to finish lives at the repository root** (`BUILD_GUIDE.md`, `BUILD_GUIDE_v2.md`, `PATTERN_GUIDE.md`, `FEATURE_GUIDE.md`, `AUDIO_GUIDE.md`); **a contract, a reference, a walk-through for one task, or a record lives here**; and a folder explains itself in its own README. The folder-by-folder map of the whole repository is [`REPOSITORY.md`](REPOSITORY.md). +What is here, and what is not. The rule for the split: **the repository root keeps three guides and no others** (`BUILD_GUIDE.md`, `PATTERN_GUIDE.md`, `FEATURE_GUIDE.md`), so a first-time visitor reads it at a glance; **a contract, a reference, a walk-through for one task (the audio guide is one), or a record lives here**; and a folder explains itself in its own README. The folder-by-folder map of the whole repository is [`REPOSITORY.md`](REPOSITORY.md). ## Contracts — other software is built against these, not against the firmware source -- [`rest-api.md`](rest-api.md) — the device's HTTP API (`/api/*`), the console pages, and the table for choosing between HTTP, OSC, MIDI and MQTT -- [`osc-spec.md`](osc-spec.md) — OSC over UDP (DAWs, Max, TouchDesigner) -- [`midi-spec.md`](midi-spec.md) — the panel as a network MIDI port -- [`mqtt-spec.md`](mqtt-spec.md) — MQTT topics, both directions, the roles, and why the channel decides whether a write sticks -- [`audio-ws-spec.md`](audio-ws-spec.md) — the audio-react WebSocket the browser extension and the phone app speak +- [`rest-api.md`](rest-api.md) — the device's HTTP API (`/api/*`), the console pages, and the table for choosing between HTTP, OSC, MIDI and MQTT, with test vectors in [`rest-api-vectors/`](rest-api-vectors/) +- [`osc-spec.md`](osc-spec.md) — OSC over UDP (DAWs, Max, TouchDesigner), with test vectors in [`osc-vectors/`](osc-vectors/) +- [`midi-spec.md`](midi-spec.md) — the panel as a network MIDI port, with test vectors in [`midi-vectors/`](midi-vectors/) and those for `/api/midi` in `rest-api-vectors/` +- [`mqtt-spec.md`](mqtt-spec.md) — MQTT topics, both directions, the roles, and why the channel decides whether a write sticks, with test vectors in [`mqtt-vectors/`](mqtt-vectors/) and those for `/api/mqtt` in `rest-api-vectors/` +- [`audio-ws-spec.md`](audio-ws-spec.md) — the audio-react WebSocket the browser extension and the phone app speak, with test vectors in [`audio-ws-vectors/`](audio-ws-vectors/) and those for `/api/audio` in `rest-api-vectors/` - [`pfst-v2-spec.md`](pfst-v2-spec.md) — the `.pfs` show table, with test vectors in [`pfst-v2-vectors/`](pfst-v2-vectors/) +- [`contract-vectors.md`](contract-vectors.md) — how a vector of the first five is written, and how `npm run check:rest-vectors` and the four like it replay them against the [virtual panel](../tools/virtual-panel/README.md), a Patternflow with no board - [`panel-compatibility.md`](panel-compatibility.md) — a buying guide first (which HUB75 panels light up and which stay dark), then the reference for other sizes ## How the firmware is put together @@ -19,6 +20,7 @@ What is here, and what is not. The rule for the split: **a guide somebody follow ## Walk-throughs — one task, start to finish +- [`AUDIO_GUIDE.md`](AUDIO_GUIDE.md) — sound into the panel - [`midi-ableton.md`](midi-ableton.md) — the panel as a MIDI port in Ableton Live, with screenshots - [`director-midi.md`](director-midi.md) — how a Director show becomes a MIDI clip - [`../firmware/CUSTOM_PATTERNS.md`](../firmware/CUSTOM_PATTERNS.md) — the hands-on pattern route (the everyday one is [`PATTERN_GUIDE.md`](../PATTERN_GUIDE.md)) @@ -28,27 +30,34 @@ What is here, and what is not. The rule for the split: **a guide somebody follow - [`assembly/`](assembly/README.md) — the assembly map: which enclosure with which electronics, and where each route is documented. The full guide is [`../BUILD_GUIDE.md`](../BUILD_GUIDE.md); the files to order are described in [`../hardware/README.md`](../hardware/README.md); enclosures other people built are [`../hardware/case/remixes/`](../hardware/case/remixes/README.md) - The same build in 3D, step by step, is [patternflow.work/guide/build](https://patternflow.work/guide/build). Its firmware chapter hands over to the Play guide's [Flash chapter](https://patternflow.work/guide/play#flash), which is also where to look when the first flash or the Wi-Fi step goes wrong (the written version is [`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware)) +- [`legacy/BUILD_GUIDE_v2.md`](legacy/BUILD_GUIDE_v2.md) — the build guide for the legacy v2.x board - [`build-guide/images/`](build-guide/images/) — photos for the build guides (`images/` is the v2 set, `images/v3/` the current board) - [`LICENSE-SUMMARY.md`](LICENSE-SUMMARY.md) — MIT for code, CC BY-SA 4.0 for hardware, docs and bundled patterns, and what that means for a pattern you publish or a remix you share ## On the site, not in this folder -The interactive guide at [patternflow.work/guide](https://patternflow.work/guide) has four parts, each in English and Korean: [Build](https://patternflow.work/guide/build) (soldering one from bare parts), [Play](https://patternflow.work/guide/play) (the first hour with a built one: flash, knobs, patterns, console), [Make](https://patternflow.work/guide/make) (community and Pattern Lab) and [Audio](https://patternflow.work/guide/audio) (the Audio edition: a browser tab, the microphone, MIDI with a DAW). They cover the same ground as the guides in the root, and every step has a **Stuck here?** link that opens an issue with that step filled in. +The interactive guide at [patternflow.work/guide](https://patternflow.work/guide) has four parts, each in English and Korean: [Build](https://patternflow.work/guide/build) (soldering one from bare parts), [Play](https://patternflow.work/guide/play) (the first hour with a built one: flash, knobs, patterns, console), [Make](https://patternflow.work/guide/make) (community and Pattern Lab) and [Audio](https://patternflow.work/guide/audio) (the Audio edition: a browser tab, the microphone, MIDI with a DAW). They cover the same ground as the written guides, and every step has a **Stuck here?** link that opens an issue with that step filled in. ## Contributing - [`../CONTRIBUTING.md`](../CONTRIBUTING.md) — one table: what you have → where it goes → what CI runs on it -- [`../SUPPORT.md`](../SUPPORT.md) — where to ask what +- [`../.github/SUPPORT.md`](../.github/SUPPORT.md) — where to ask what - [`REPOSITORY.md`](REPOSITORY.md) — the folder-by-folder map, and the list of files that outside things link to +- [`voice.md`](voice.md) — how the docs sound in English and in Korean, with the words that stay out in [`voice-banned.txt`](voice-banned.txt) ## Running the project - [`RELEASING.md`](RELEASING.md) — the `dev` → `main` routine, cutting a release, what the workflows attach - [`SERVICES.md`](SERVICES.md) — the production hosts (community, build worker) and their systemd units. Korean. +## Drafts — Part 2 only, and nothing in them is built + +- [`rfc-any-resolution.md`](rfc-any-resolution.md) — a panel of another size on one ESP32-S3, chosen on a console page and applied at the next boot instead of in a rebuild. Classic HUB75 only, within the driver's DMA memory +- [`rfc-linux-player.md`](rfc-linux-player.md) — the site's pattern engine as a Linux program, onto a HUB75 HAT on a Raspberry Pi, a receiving card or a screen. Not firmware, and not an edition + ## Records -- [`investigations/`](investigations/) — dated post-mortems -- [`releases/`](releases/) — long-form notes for three July 2026 releases, kept as written; notes since 3.2 are on [GitHub Releases](https://github.com/engmung/Patternflow/releases), and `CHANGELOG.md` at the root is the complete record +- [`investigations/`](investigations/) — dated post-mortems, and the cleanup's plans. `npm run doc-map` in `web/` writes the two lists here: `2026-10-doc-map.csv`, every link in the tree and whether it resolves, and `2026-10-doc-parity.csv`, every Korean/English pair and which side is missing or short +- [`releases/`](releases/) — every release's changelog section as written, one file each (`vX.Y.Z.md`), back to v1.0.0; `CHANGELOG.md` at the root keeps the three newest in full and links the rest. `v3.0.0-notes.md` and `v3.1.0-notes.md` are long-form notes for two July 2026 releases, beside the v3.0.0 launch checklist; notes since 3.2 are on [GitHub Releases](https://github.com/engmung/Patternflow/releases) - [`manifesto.md`](manifesto.md) — why Patternflow exists, and (section 6) how we treat each other - [`images/`](images/), [`media/`](media/) — assets the guides and the site reference diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6aa9548e..cd7bcde5 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -19,7 +19,8 @@ the `[Unreleased]` section of `CHANGELOG.md` written: python firmware/toolchain/release.py cut v3.9.4 --audio v0.5.4 --performance v0.2.4 ``` -bumps the versions, dates the changelog section, updates `AGENTS.md`, runs +bumps the versions, dates the changelog section and files it in +`docs/releases/`, updates `AGENTS.md`, runs `shelf.sh` for the core and each named edition, points the flasher manifest and the `/editions` cards at the new images, runs the web checks, commits `release: v3.9.4` and tags it. Name only the editions that should be re-cut; @@ -63,7 +64,7 @@ What the two commands do, step by step - and the way to do it by hand. 1. Make sure all intended changes are committed, and `./firmware/bundles/build.sh all` is green if anything under `firmware/` moved. 2. Bump the version the firmware reports: `PF_IMPROV_FW_VERSION` in `firmware/patternflow/net_config.h` (written `X.Y.Z`, no `v`). `shelf.sh` refuses a core image whose define disagrees with the version it is being shelved as. -3. Turn `CHANGELOG.md`'s `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` and open a fresh `[Unreleased]` above it. +3. Turn `CHANGELOG.md`'s `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` and open a fresh `[Unreleased]` above it. Copy the new section to `docs/releases/vX.Y.Z.md` (the file name always carries the `v`), with each relative link two folders up (`../../`). The root keeps three releases in full: the fourth-newest section leaves it and becomes a line at the top of *Older releases*, linking its file. 4. Update the version the docs claim: the "current" line in `AGENTS.md`, the *Moving fast* note in `README.md`, and any guide that names a release. 5. If hardware changed: confirm `BUILD_GUIDE.md`'s parts table, `hardware/bom/bom_v*.csv` and the schematic agree; regenerate the Gerber zip, renders and schematic exports with the recipe in `hardware/pcb/README.md`; add a `### Hardware` entry to the changelog section; update the board table in `hardware/README.md` and the version line in `docs/assembly/README.md`; attach the Gerber zip, the BOM CSV and the STLs to the release. 6. Stage the images the site serves: @@ -102,7 +103,7 @@ Work happens on `dev`, then lands on `main` through a pull request. `main` is pr ## Current release line -`CHANGELOG.md` is the record — one section per release, newest first — and the [releases page](https://github.com/engmung/Patternflow/releases) carries the notes and the flashable images. The shape of the line, for orientation: +Every release has its own file in `docs/releases/`, `vX.Y.Z.md`, back to `v1.0.0`. `CHANGELOG.md` holds the three newest in full, newest first, and a line for each older one. The [releases page](https://github.com/engmung/Patternflow/releases) carries the notes and the flashable images. The shape of the line, for orientation: - `v1.x` -- first public buildable release, then the multi-pattern firmware and browser flasher. - `v2.x` -- the v2.0 board (GPIO0 cold-boot fix, cleaned silkscreen), custom pattern workflow, the web platform. `v2.1.0` is the last release for v2.x hardware. diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md index 828b4743..d05a54e3 100644 --- a/docs/REPOSITORY.md +++ b/docs/REPOSITORY.md @@ -9,13 +9,14 @@ A map for people. (AI coding agents get the same map with more rules in [`../AGE | `firmware/` | The ESP32-S3 firmware. `patternflow/` is the sketch: `patternflow.ino`, `config.h` (pins, brightness, limits, calibration), `net_config.h` (Wi-Fi, OTA, update defaults), `src/` (the engine: HUB75 driver, encoders, canvas, colour, noise, Wi-Fi, web console — the **core**, compiled unchanged into every published firmware), `features/` (everything optional: OSC, audio, the microphone, MQTT, MIDI, shows, weather, clock, BLE — attached through hooks the core exposes), `presets/` (the curated patterns), `abi/` (the frozen contract a `.pfm` module is compiled against), `console/` (the device's web pages as plain HTML). `bundles/` names the compositions — the shelf editions `audio` and `performance`, plus `clock` and `midi`, which CI keeps compiling but the shelf does not carry — and holds `build.sh` and `shelf.sh`. `toolchain/` is the repo-level scripts: build a module, make the Basics pack, and the `check_*.py` that CI runs. `modules/` is the module toolchain's working folder, not a place patterns are contributed to. `encoder_test/` is a standalone sketch for checking the four encoders, and `CUSTOM_PATTERNS.md` the hands-on pattern route | [`firmware/README.md`](../firmware/README.md) for building; [`EDITIONS.md`](EDITIONS.md) before touching anything | | `hardware/` | `pcb/` (KiCad source, Gerbers per revision, schematic PDF), `case/` (STLs by printer bed size, knob plates, the Blender source, legacy v2 cases, the old v1/v2 laser-cut drawings in `legacy_lasercut/`, and `remixes/` — enclosures other people designed and built, one folder each), `bom/` (the CSV that is the BOM source of truth) | [`hardware/README.md`](../hardware/README.md) | | `web/` | The Next.js site at patternflow.work: landing page, the interactive guide (`/guide`, with `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`), Live Editor, Pattern Lab, the community (only on the community host), the browser flasher (the **Flash Patternflow** button on the Pattern tab and in the Play guide; the images it installs are `public/flash/`), edition shelf, feature catalogue, device update handoff, journal, roadmap. The JS presets in `src/lib/presets/` are the source of truth for the firmware's preset headers. The breadboard build guide is a React page here, `src/app/build/breadboard/` | [`web/README.md`](../web/README.md), then [`web/ARCHITECTURE.md`](../web/ARCHITECTURE.md) | -| `docs/` | Contracts other software is built against, how the firmware is put together, walk-throughs, the assembly map, project records and media | [`README.md`](README.md) | -| `tools/` | Clients Patternflow ships that run on their own: the audio-react browser extension (also the authoring source of the device's `/audio-in` page), the Android capture app, an RTP-MIDI probe. Also `case-models/`, the scripts that make the /build page's 3D model of each case, the official one and every remix on its case switch | [`tools/README.md`](../tools/README.md) | +| `shared/` | `constants.json`, the values the firmware and the site both carry: the panel geometries, the knob count, the encoder's 24 detents a turn, the bus maximum and the button timings. `npm run gen:shared` in `web/` writes `firmware/patternflow/src/core_shared.h` and `web/src/lib/shared/constants.ts` from it. Neither is edited by hand, and `check:shared` fails while either is stale. The folder exists because the lab counted 20 detents a turn, against the encoder's 24, until 31 July 2026 | [`constants.json`](../shared/constants.json) | +| `docs/` | Contracts other software is built against, each with a folder of test vectors beside it, how the firmware is put together, walk-throughs, the audio guide (`AUDIO_GUIDE.md`), the assembly map, the legacy v2.x build guide (`legacy/`), the writing voice (`voice.md` and `voice-banned.txt`), project records (`releases/`, one file per release) and media | [`README.md`](README.md) | +| `tools/` | Clients Patternflow ships that run on their own: the audio-react browser extension (also the authoring source of the device's `/audio-in` page), the Android capture app, an RTP-MIDI probe. Also `case-models/`, the scripts that make the /build page's 3D model of each case, the official one and every remix on its case switch. `virtual-panel/` is the other end of the contracts: a Patternflow with no board, on the firmware's ports, that the vectors in `docs/` are replayed against | [`tools/README.md`](../tools/README.md) | | `integrations/` | Bridges that run inside or beside someone else's software (Ableton / Max for Live today) and talk to the panel only through the contracts in `docs/` | [`integrations/README.md`](../integrations/README.md) | -| `.github/` | Issue and pull-request templates (including the guide's **Stuck here?** form and **Share your build**), the CI workflows (web, every firmware composition, the core-names-no-feature checks, doc links, community folders carrying their README, console-page sync, firmware release assets, Discord dev-log) and their helper scripts | the header comment of each workflow | -| root | `README.md`, the five guides (`BUILD_GUIDE.md`, `BUILD_GUIDE_v2.md`, `PATTERN_GUIDE.md`, `FEATURE_GUIDE.md`, `AUDIO_GUIDE.md`), `CHANGELOG.md`, `CONTRIBUTING.md`, `SUPPORT.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `CITATION.cff`, the two license texts and the `LICENSE` pointer, `AGENTS.md` (+ `CLAUDE.md`, a one-line shim) | | +| `.github/` | Issue and pull-request templates (including the guide's **Stuck here?** form and **Share your build**), `SUPPORT.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, the CI workflows (web, every firmware composition, the core-names-no-feature checks, doc links, the drift checks, community folders carrying their README, console-page sync, firmware release assets, Discord dev-log) and their helper scripts | the header comment of each workflow | +| root | `README.md`, the three guides (`BUILD_GUIDE.md`, `PATTERN_GUIDE.md`, `FEATURE_GUIDE.md`), `CHANGELOG.md`, `CONTRIBUTING.md`, `CITATION.cff`, the two license texts and the `LICENSE` pointer, `AGENTS.md` (+ `CLAUDE.md`, a one-line shim) | | -The rule for where a document lives: **a guide somebody follows start to finish is at the root; a contract, a reference or a record is in `docs/`; a folder explains itself in its own README.** +The rule for where a document lives: **the root keeps three guides and no others, so a first-time visitor reads it at a glance; a contract, a reference, a walk-through for one task or a record is in `docs/`; a folder explains itself in its own README.** ## What you want to do → where to start @@ -25,8 +26,8 @@ The rule for where a document lives: **a guide somebody follows start to finish | Use the one you built | [patternflow.work/guide/play](https://patternflow.work/guide/play) — flashing, the knobs, installing patterns and the console, on a Patternflow you can turn. The first flash goes through download mode (hold BOOT, tap RST, let go of BOOT), and the Wi-Fi step usually does not appear the first time: the [Flash chapter](https://patternflow.work/guide/play#flash) and [`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware) say what to do, in order | | Make a pattern | [`PATTERN_GUIDE.md`](../PATTERN_GUIDE.md) — the Pattern Lab, the community, and the device. No local toolchain involved. The same loop as a tutorial: [patternflow.work/guide/make](https://patternflow.work/guide/make). [`firmware/CUSTOM_PATTERNS.md`](../firmware/CUSTOM_PATTERNS.md) is the older, more hands-on route | | Write a firmware feature or cut an edition | [`FEATURE_GUIDE.md`](../FEATURE_GUIDE.md) → [`EDITIONS.md`](EDITIONS.md) → [`features/README.md`](../firmware/patternflow/features/README.md) → [`bundles/README.md`](../firmware/bundles/README.md) | -| Control the panel from other software | [`rest-api.md`](rest-api.md) has the table for choosing between HTTP, OSC, MIDI and MQTT; then the spec for the one you chose | -| Put sound into it | [`AUDIO_GUIDE.md`](../AUDIO_GUIDE.md). The browser tab, the microphone and MIDI with a DAW, step by step: [patternflow.work/guide/audio](https://patternflow.work/guide/audio) | +| Control the panel from other software | [`rest-api.md`](rest-api.md) has the table for choosing between HTTP, OSC, MIDI and MQTT; then the spec for the one you chose. [`tools/virtual-panel/`](../tools/virtual-panel/README.md) answers on the same ports with no board behind it | +| Put sound into it | [`AUDIO_GUIDE.md`](AUDIO_GUIDE.md). The browser tab, the microphone and MIDI with a DAW, step by step: [patternflow.work/guide/audio](https://patternflow.work/guide/audio) | | Work on the site | [`web/README.md`](../web/README.md) → [`web/ARCHITECTURE.md`](../web/ARCHITECTURE.md) | | Contribute anything | [`CONTRIBUTING.md`](../CONTRIBUTING.md) — one table of where each kind of contribution goes | | Cut a release, run the servers | [`RELEASING.md`](RELEASING.md), [`SERVICES.md`](SERVICES.md) | @@ -41,11 +42,11 @@ Filenames are lowercase with underscores. Tags are `vX.Y.Z`; the firmware source ## Files that other things link to — check before renaming -The site, the firmware and the release notes hold absolute `blob/main/…` links into this tree, and none of them is checked by CI. Before renaming or moving any of these, grep `web/src`, `web/content`, `firmware/patternflow/console` and `firmware/patternflow/features/*/*_index.h`: +The site, the firmware and the release notes hold absolute `blob/main/…` links into this tree, and a pull request that breaks one fails `check:links-absolute`. Before renaming or moving any of these, grep `web/src`, `web/content`, `firmware/patternflow/console` and `firmware/patternflow/features/*/*_index.h`: -- `README.md`, `BUILD_GUIDE.md` (the site links its section anchors — §1, §2, §4, §8, §8.2 and §10 when this was written — so a renamed heading breaks them), `AUDIO_GUIDE.md`, `FEATURE_GUIDE.md`, `PATTERN_GUIDE.md` -- `docs/assembly/README.md`, `docs/midi-ableton.md`, `docs/midi-spec.md` (the last two are baked into the Audio edition's console pages) +- `README.md`, `BUILD_GUIDE.md` (the site links its section anchors — §1, §2, §4, §8, §8.2 and §10 when this was written — so a renamed heading breaks them), `FEATURE_GUIDE.md`, `PATTERN_GUIDE.md` +- `docs/AUDIO_GUIDE.md`, `docs/assembly/README.md`, `docs/midi-ableton.md`, `docs/midi-spec.md` (the last two are baked into the Audio edition's console pages) - `docs/EDITIONS.md`, `docs/rest-api.md`, `firmware/README.md`, `hardware/bom/bom_v3.9.csv` - `web/src/app/compliance/` — the `/compliance` URL is printed on the box. Never move, redirect or noindex it. -`.github/scripts/check_links.py` checks the *relative* links in every tracked markdown file and runs on every pull request that touches one. +`.github/scripts/check_links.py` checks the *relative* links in every tracked markdown file and runs on every pull request that touches one. `check:links-absolute` (`web/scripts/doc-map.ts --check absolute`, in `drift.yml`) checks the absolute ones on every pull request, against the tree or the tag a link names. diff --git a/docs/assembly/README.md b/docs/assembly/README.md index da89ad3d..beb91b41 100644 --- a/docs/assembly/README.md +++ b/docs/assembly/README.md @@ -1,7 +1,7 @@ # Build Patternflow — Assembly Map > **Which version am I building?** Current hardware is the **v3.9 board** (the v3.0 board with the USB-C footprint removed; same case, same guide): follow **[BUILD_GUIDE.md](../../BUILD_GUIDE.md)** and take the files from the tree — [`hardware/README.md`](../../hardware/README.md) says exactly which Gerber, BOM and STL to use. The [v3.0.0 release](https://github.com/engmung/Patternflow/releases/tag/v3.0.0) bundles the previous revision's files. -> Own a **v2.x board**? Use **[BUILD_GUIDE_v2.md](../../BUILD_GUIDE_v2.md)** and the **[v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0)** instead. **v2 and v3 parts are not interchangeable** — the boards do not fit each other's cases. +> Own a **v2.x board**? Use **[BUILD_GUIDE_v2.md](../legacy/BUILD_GUIDE_v2.md)** and the **[v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0)** instead. **v2 and v3 parts are not interchangeable** — the boards do not fit each other's cases. > 🔦 **Buying the LED panel? Read [LED Panel Compatibility](../panel-compatibility.md) first.** Patternflow scans the panel directly from the ESP32-S3, so the **driver IC** decides whether it lights up — "HUB75E" on the listing does not, and a spec-matching panel with S-PWM "video wall" drivers stays completely dark with no firmware fix. Since that part number is almost never in the listing, the practical check is the **buyer reviews**: someone running it off an ESP32 or Raspberry Pi is the best evidence you'll get. @@ -51,4 +51,4 @@ Custom patterns no longer need a local toolchain or a reflash: a pattern built i | Version | Guide | Everything bundled | | --- | --- | --- | | **v3.9 board** (current; v3.0 is the same board with a USB-C footprint you leave empty) | [BUILD_GUIDE.md](../../BUILD_GUIDE.md) | the files in [`hardware/`](../../hardware/README.md); the [v3.0.0 release](https://github.com/engmung/Patternflow/releases/tag/v3.0.0) bundles the v3.0 revision | -| v2.1.0 (legacy) | [BUILD_GUIDE_v2.md](../../BUILD_GUIDE_v2.md) | [v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0) | +| v2.1.0 (legacy) | [BUILD_GUIDE_v2.md](../legacy/BUILD_GUIDE_v2.md) | [v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0) | diff --git a/docs/audio-ws-vectors/README.md b/docs/audio-ws-vectors/README.md new file mode 100644 index 00000000..c9ec8b65 --- /dev/null +++ b/docs/audio-ws-vectors/README.md @@ -0,0 +1,13 @@ +# Audio WebSocket vectors + +Each file here is one sentence of [audio-ws-spec.md](../audio-ws-spec.md) and the messages that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:audio-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `audio` feature, and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A file's name starts with the part of the spec it reads. `messages-` is a row of the message table, or the rule for anything else. `lanes-` is what a lane is and what outranks it, and `release-` is the 500 ms release. `switch-` is the AUD switch. `/api/audio` is REST, so its vectors are in [rest-api-vectors](../rest-api-vectors/README.md) and start with `audio-`. + +A message is the text of one frame, `"a=0.8,-,-,0.2"`, and `{ "close": 1000 }` closes the socket with that code. Node 20 has no WebSocket client, so the suite brings one, `web/scripts/drift/_ws.ts`, and opens one socket a vector before the first step. After each message it sends a ping and waits for the pong, so the panel holds the message before the clock moves. The panel reads what has arrived at the end of a frame, 16.7 ms at 60 fps. A vector gives a lane 20 ms before it reads `/api/status`, and a `d=` 50 ms, since its clicks reach the pattern a frame later. A fresh panel has Audio-React on, as a board with nothing saved boots, so no vector switches it on first. + +`messages-d.json` reads "normalized" as a share of a parameter's whole range, which rest-api.md puts at 48 detents. A `d=` of 0.5 is 24 clicks. + +Three vectors keep the spec where the firmware does something else. `messages-a.json` sends `a=-0.5,0.25,-,-`, and the firmware takes the leading `-` for a skipped slot and puts 0.5 on the second lane (the `a=` parse in `core_audio_ws.h`). `messages-unknown.json` sends `off=x`, which the firmware reads as `off=0`. `lanes-hand.json` sends a `d=` to a knob a lane drives, and the firmware holds that knob five seconds as if a hand had turned it (`applyLaneMotion()` in `patternflow.ino`). + +Some of the spec has no vectors. The virtual panel has no microphone and no weather, so the WebSocket is the only source its lanes have. A vector talks over one socket, so two clients writing the same knob are not replayed, and neither is a socket that closes while another stays open. The one-in-flight pacing and the extension's resend timer are rules for a client, and a panel has nothing to show for them. diff --git a/docs/audio-ws-vectors/lanes-bus.json b/docs/audio-ws-vectors/lanes-bus.json new file mode 100644 index 00000000..7dd5dbd4 --- /dev/null +++ b/docs/audio-ws-vectors/lanes-bus.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/audio-ws-spec.md#semantics--what-a-lane-is", + "says": "Priority per knob, highest first (see `abi/pf_params.h`):", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, false, false] } } }, + { "send": { "http": "POST /api/params?p1=700" } }, + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [700, "", "", ""], "paramActive": [true, false, false, false], "laneActive": [false, true, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/lanes-close.json b/docs/audio-ws-vectors/lanes-close.json new file mode 100644 index 00000000..86bd585c --- /dev/null +++ b/docs/audio-ws-vectors/lanes-close.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/audio-ws-spec.md#semantics--what-a-lane-is", + "says": "the firmware also releases lanes when the socket closes", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true], "audioClients": 1 } } }, + { "send": { "ws": { "close": 1000 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, false, false, false], "audioClients": 0 } } } + ] +} diff --git a/docs/audio-ws-vectors/lanes-hand.json b/docs/audio-ws-vectors/lanes-hand.json new file mode 100644 index 00000000..ef21309e --- /dev/null +++ b/docs/audio-ws-vectors/lanes-hand.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/audio-ws-spec.md#semantics--what-a-lane-is", + "says": "A physical turn of an encoder takes that knob back and holds it for five seconds; keep streaming and the lane resumes when the hold expires.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, false, false] } } }, + { "send": { "hand": { "knob": 1, "turn": 1 } } }, + { "send": { "ws": "d=1,v=0.25" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, true, false, false] } } }, + { "send": { "wait": 4700 } }, + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, true, false, false] } } }, + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 300 }, "expect": { "status": { "laneActive": [true, true, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-a.json b/docs/audio-ws-vectors/messages-a.json new file mode 100644 index 00000000..ba3f74ad --- /dev/null +++ b/docs/audio-ws-vectors/messages-a.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "A literal `-` in a slot leaves that lane untouched: `a=0.8,-,-,0.2`.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.25, 0.5, 0.75, 1], "laneActive": [true, true, true, true] } } }, + { "send": { "wait": 200 } }, + { "send": { "ws": "a=0.8,-,-,0.2" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.8, 0.5, 0.75, 0.2], "laneActive": [true, true, true, true] } } }, + { "send": { "wait": 400 }, "expect": { "status": { "laneActive": [true, false, false, true] } } }, + { "send": { "ws": "a=-0.5,0.25,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": ["", 0.25, 0.75, 0.2] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-d.json b/docs/audio-ws-vectors/messages-d.json new file mode 100644 index 00000000..b0e0c37a --- /dev/null +++ b/docs/audio-ws-vectors/messages-d.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Add a normalized delta `F` (−1..1) to knob `N` — encoder-style motion rather than a level.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "d=1,v=0.5" } }, + { "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [0, 24, 0, 0] } }, "status": { "laneActive": [false, false, false, false] } } }, + { "send": { "ws": "d=1,v=-0.25" } }, + { "send": { "ws": "d=3,v=-1" } }, + { "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [0, 12, 0, -48] } } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-k.json b/docs/audio-ws-vectors/messages-k.json new file mode 100644 index 00000000..7d0b43f1 --- /dev/null +++ b/docs/audio-ws-vectors/messages-k.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Set lane `N` (0..3) to `F` (clamped to `0..1`).", + "features": ["audio"], + "steps": [ + { "send": { "ws": "k=0,v=0.25" } }, + { "send": { "ws": "k=2,v=1.5" } }, + { "send": { "ws": "k=3,v=-2" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.25, "", 1, 0], "laneActive": [true, false, true, true] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-off-n.json b/docs/audio-ws-vectors/messages-off-n.json new file mode 100644 index 00000000..e0c93db6 --- /dev/null +++ b/docs/audio-ws-vectors/messages-off-n.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Release knob `N` back to encoder control.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true] } } }, + { "send": { "ws": "off=2" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, false, true] } } }, + { "send": { "ws": "off=0" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, true, false, true] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-off.json b/docs/audio-ws-vectors/messages-off.json new file mode 100644 index 00000000..ed702e33 --- /dev/null +++ b/docs/audio-ws-vectors/messages-off.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Release all four.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true] } } }, + { "send": { "ws": "off" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, false, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-unknown.json b/docs/audio-ws-vectors/messages-unknown.json new file mode 100644 index 00000000..03995d99 --- /dev/null +++ b/docs/audio-ws-vectors/messages-unknown.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Anything else is **silently ignored** — that is the compatibility rule.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 } }, + { "send": { "ws": "x=1" } }, + { "send": { "ws": "k=4,v=0.5" } }, + { "send": { "ws": "d=4,v=1" } }, + { "send": { "ws": "off=x" } }, + { "send": { "ws": "hello" } }, + { "send": { "wait": 50 }, "expect": { "status": { "lanes": [0.25, 0.5, 0.75, 1], "laneActive": [true, true, true, true] }, "input": { "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "ws": "k=1,v=0" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.25, 0, 0.75, 1] } } } + ] +} diff --git a/docs/audio-ws-vectors/release-500ms.json b/docs/audio-ws-vectors/release-500ms.json new file mode 100644 index 00000000..d4d37d3d --- /dev/null +++ b/docs/audio-ws-vectors/release-500ms.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/audio-ws-spec.md#holding-a-value--the-500-ms-release", + "says": "A lane is handed back to its encoder 500 ms after the last message that set it.", + "features": ["audio"], + "steps": [ + { "send": { "http": "POST /api/audio?on=1" } }, + { "send": { "ws": "a=0.8,-,-,0.2" } }, + { "send": { "wait": 400 }, "expect": { "status": { "laneActive": [true, false, false, true] } } }, + { "send": { "wait": 200 }, "expect": { "status": { "laneActive": [false, false, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/release-resend.json b/docs/audio-ws-vectors/release-resend.json new file mode 100644 index 00000000..114351b0 --- /dev/null +++ b/docs/audio-ws-vectors/release-resend.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/audio-ws-spec.md#holding-a-value--the-500-ms-release", + "says": "So a client that means to keep driving **resends its last message at least every 250 ms**, changed or not.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.5,-,-,-" } }, + { "send": { "wait": 250 } }, + { "send": { "ws": "a=0.5,-,-,-" } }, + { "send": { "wait": 250 } }, + { "send": { "ws": "a=0.5,-,-,-" } }, + { "send": { "wait": 250 }, "expect": { "status": { "laneActive": [true, false, false, false] } } }, + { "send": { "wait": 300 }, "expect": { "status": { "laneActive": [false, false, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/switch-off.json b/docs/audio-ws-vectors/switch-off.json new file mode 100644 index 00000000..c2cc95d5 --- /dev/null +++ b/docs/audio-ws-vectors/switch-off.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/audio-ws-spec.md#the-switch-on-the-panel", + "says": "Switched off, the messages are parsed and nothing is driven — an open socket is not evidence that the panel is listening.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/audio?on=0" }, "expect": { "status": { "audioRuntime": false, "laneActive": [false, false, false, false] } } }, + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "ws": "d=0,v=0.5" } }, + { "send": { "wait": 50 }, "expect": { "status": { "laneActive": [false, false, false, false], "audioClients": 1 }, "input": { "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "http": "POST /api/audio?on=1" } }, + { "send": { "ws": "k=0,v=0.25" } }, + { "send": { "wait": 20 }, "expect": { "status": { "audioRuntime": true, "laneActive": [true, false, false, false] } } } + ] +} diff --git a/docs/contract-vectors.md b/docs/contract-vectors.md new file mode 100644 index 00000000..ed05b768 --- /dev/null +++ b/docs/contract-vectors.md @@ -0,0 +1,81 @@ +# Contract vectors + +A vector is one sentence from a contract and the steps that show a panel doing what it says. Each is a JSON file in a folder beside its spec: `rest-api-vectors/` for [rest-api.md](rest-api.md), and `osc-vectors/`, `midi-vectors/`, `mqtt-vectors/` and `audio-ws-vectors/` for the other four. Each folder has a suite in `web/scripts/drift/`, run as `npm run check:rest-vectors` in `web/` and the same way for the other four. It gives every vector a fresh [virtual panel](../tools/virtual-panel/README.md) and prints what the vector expects beside what the panel did. [`pfst-v2-vectors/`](pfst-v2-vectors/) came first and works differently: three `.pfs` files and a table in its spec. + +```json +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "`sleep` is the state **before** the transition — the request is only queued here.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep?on=1" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } } + ] +} +``` + +`contract` is the spec and the heading the sentence sits under, written the way a link to it is. `says` is the sentence, copied out of the spec. Markdown and line breaks do not count, every word does. When the spec rewords it, the vector fails until someone brings it along. `features` are the features the panel is built with, by their directories in `firmware/patternflow/features/`, and `[]` is the default build. A REST vector for the Audio edition's routes names `audio` there. + +## Steps + +Each step sends one thing: + +| send | | +| --- | --- | +| `http` | `"POST /api/sleep?on=1"`, or `{ "request": "POST /api/sleep", "form": "on=1", "headers": { "Host": "patternflow.local" } }` | +| `hand` | `{ "knob": 2, "turn": -3 }` turns knob 2 three detents back. `{ "knob": 4, "down": true }` presses its button, and `false` lets go | +| `wait` | `500` moves the panel's clock 500 ms | +| `osc`, `midi`, `mqtt`, `ws` | a message on that transport, written as in the examples below; the suite's sender puts it on the wire | + +The clock stands still between steps. Only `wait` and `out` move it, so a 500 ms release costs no real time. Knobs count from 1, as every contract does. + +A step can then expect four things, each read once the step is over. `reply` is what the send answered: `status`, `headers` with their names in lower case, and `body`, parsed when it is JSON. `status` is `GET /api/status`. `input` is the panel's `/vp/state`, the knob deltas and presses the running pattern has been given since start, its name card and the first error it threw. A delta that comes over OSC or MIDI never shows in `knobs`, which counts a hand's clicks, so `input` is where it shows. `out` is what the panel sent. + +## Shapes + +An expectation is a shape. A literal has to be equal. `""`, `""`, `""`, `""`, `""`, `""` and `""` stand for any value of that type. An object names the keys it needs and lets the others pass, as rest-api.md asks of every client; `""` as a key's value forbids the key. An array has to be as long as its shape, and each element is a shape of its own. + +## What the panel sends + +`out` is a list. Each entry names one transport and a message, and the messages are matched in order on each transport. `{ "osc": ["/patternflow/version", ""], "after": 29, "within": 30 }` is that message, sent later than 29 ms and no later than 30 ms after the step's send. `within` is the step's own length when it is left out: the `wait`, or 0 for anything else. Before reading anything the clock runs on to the last `within`, and it stops at every `after` and `within` on the way, so a message is timed to the window it falls in. `""` in place of a message is silence on that transport for the window. + +OSC, a ping and the start of the announce it is answered with. A message is its address and then its arguments: + +```json +{ "contract": "docs/osc-spec.md#host--device", "features": ["osc"], + "says": "Learn/refresh sender as the remote host and reply with a full announce (hello, version, ip, pattern index/name, modes).", + "steps": [{ "send": { "osc": ["/patternflow/ping"] }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"], "within": 50 }] } }] } +``` + +MIDI, a relative CC reaching the pattern. A message is its bytes, and 176 is a control change on channel 1: + +```json +{ "contract": "docs/midi-spec.md#host--device", "features": ["midi"], + "says": "`64` = no motion, `65` = +1 detent, `63` = −1", + "steps": [{ "send": { "midi": [176, 24, 65] } }, { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [1, 0, 0, 0] } } } }] } +``` + +MQTT, sleep from home automation. A message is a topic and a payload: + +```json +{ "contract": "docs/mqtt-spec.md#topics", "features": ["mqtt"], + "says": "Publishing `1` to `/sleep` puts the panel to sleep, `0` wakes it, and `sleep/state` mirrors the result", + "steps": [{ "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "1" } }, + "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 50 }], "status": { "sleep": true } } }] } +``` + +The audio WebSocket, two lanes let go half a second after the last message. A message is the frame's text: + +```json +{ "contract": "docs/audio-ws-spec.md#holding-a-value--the-500-ms-release", "features": ["audio"], + "says": "A lane is handed back to its encoder 500 ms after the last message that set it.", + "steps": [{ "send": { "http": "POST /api/audio?on=1" } }, { "send": { "ws": "a=0.8,-,-,0.2" } }, + { "send": { "wait": 400 }, "expect": { "status": { "laneActive": [true, false, false, true] } } }, + { "send": { "wait": 200 }, "expect": { "status": { "laneActive": [false, false, false, false] } } }] } +``` + +## Spec, not firmware + +A vector follows its spec. Where the firmware does something else, the vector keeps the spec, and the pull request that adds it lists the firmware line for an issue. Where the spec contradicts itself, the vector follows the section that defines the route or the message. Where the spec says nothing, how OSC rounds 2.5 for one, there is no vector. + +A suite brings a `Sender` for each transport other than HTTP, in a file of its own whose name starts with `_`. For each vector it opens a channel that sends a step's message and keeps a list of what the panel sent back. `_vectors.ts` does the rest, and it does not change once its suites rely on it. diff --git a/docs/investigations/2026-10-cleanup-baseline.md b/docs/investigations/2026-10-cleanup-baseline.md new file mode 100644 index 00000000..92c1ec5a --- /dev/null +++ b/docs/investigations/2026-10-cleanup-baseline.md @@ -0,0 +1,83 @@ +# Cleanup baseline + +*Step 0 of [rfc-cleanup.md](../rfc-cleanup.md), §2.2. Run on 14 October 2026.* + +The cleanup starts from `8226f7ea5da15b0e0e55b15fa9ec7af136eef080` on `dev`, +the commit that added [rfc-cleanup-progress.md](../rfc-cleanup-progress.md). +Project v3.11.0, Audio v0.7.0, Performance v0.4.0. The last commit to touch +`firmware/` or `web/` before it is `caaf2c1`, on 12 October. + +The web commands ran in a cloud container on that tree with nothing added: +Node 22.22.0, npm 10.9.4, `npm ci` from `web/package-lock.json`. CI runs Node 20. + +## The five commands + +| command | result | output | +| --- | --- | --- | +| `firmware/bundles/build.sh all` | pass, on CI | five compositions built and scanned, see below | +| `npm run check:ci` | pass | 25 suites, last line `All module cache checks passed.`, 50 s | +| `npm run check:panels` | pass | 35 test files, 478 tests, 19 s | +| `npm run typecheck` | pass | `tsc --noEmit` prints nothing, 27 s | +| `npm run lint` | pass | 0 errors, 12 warnings, 50 s | + +None of the five is red, so there is no fix PR to land before Step 1. + +The container cannot build firmware. PlatformIO fetches the espressif32 platform +and the Xtensa toolchain from `api.registry.platformio.org`, and the container's +network policy refuses that host (403 on CONNECT). So `build.sh all` ran the way +CI runs it, `build.sh one ` per job, in `firmware-build.yml` +dispatched on `dev` at this SHA ([run 38560727407](https://github.com/Patternflow-lab/Patternflow/actions/runs/38560727407)). +Each job scans its image for the feature markers: + +``` +default 1102672 bytes ok +audio 1208656 bytes ok +performance 1526624 bytes ok +clock 1187088 bytes ok +midi 1163440 bytes ok +``` + +`check_footprint.py` says `every edition within tolerance` in all five. The 17 +host-side checks in `firmware-checks.yml` and the two in `console-sync.yml` pass. + +The lint warnings, for Step 3: + +| file | line | rule | +| --- | --- | --- | +| `web/scripts/performance-smoke.ts` | 16 | `@typescript-eslint/no-unused-vars` | +| `web/src/app/build/breadboard/page.tsx` | 394, 435, 476, 514, 556 | `@next/next/no-img-element` | +| `web/src/app/editions/EditionCard.tsx` | 35 | `@typescript-eslint/no-unused-vars` | +| `web/src/app/roadmap/RoadmapMap.tsx` | 272 | `@typescript-eslint/no-unused-vars` | +| `web/src/app/roadmap/RoadmapMap.tsx` | 315 | `react-hooks/exhaustive-deps` | +| `web/src/components/community/PatternCard.tsx` | 8 | `@typescript-eslint/no-unused-vars` | +| `web/src/components/journal/JournalLightbox.tsx` | 86 | `@next/next/no-img-element` | +| `web/src/components/sections/Hero.tsx` | 55 | `@next/next/no-img-element` | + +## The doc map + +`npm run doc-map` in `web/` ([doc-map.ts](../../web/scripts/doc-map.ts)) +wrote [2026-10-doc-map.csv](2026-10-doc-map.csv): 1007 links out of 193 +files, 7 of them from this file, `AGENTS.md` and the progress log as Step 0 +leaves them. 532 are relative, 329 site, 114 absolute-blob and 32 console. +None is absolute-raw; nothing links through `raw.githubusercontent.com`. + +24 of the absolute-blob rows name a release tag, `v1.0.0` to `v3.1.0`, from the +roadmap, the release notes and the hardware READMEs. A shallow clone has no +tags and reports those 24 as `no`, so fetch the tags before running the script. + +The map leaves out links that a file move cannot break: a bare `#anchor` +inside one file, the repository's front page, issues, pull requests and +releases. It also leaves out the site's own route links (`href="/guide"` in a +component), the vendored upstream docs under `hub75/`, `webserver/` and +`pubsubclient/`, and the `*_index.h` headers, which are the console pages again. + +§2.3 expects the site to link `BUILD_GUIDE_v2.md` at §1, §2, §4, §8 and §10. +At this SHA it links it once, from `BuildPanel.tsx`, with no anchor. The +anchored links go to `BUILD_GUIDE.md`. Moving the v2 guide touches that one +site link, the test beside it, and five Markdown files: `README.md`, +`BUILD_GUIDE.md`, `docs/assembly/README.md`, `hardware/README.md` and +`hardware/case/README.md`. + +## Known broken + +None. Every row in the map resolves. diff --git a/docs/investigations/2026-10-cleanup-step1-plan.md b/docs/investigations/2026-10-cleanup-step1-plan.md new file mode 100644 index 00000000..70ec2b07 --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step1-plan.md @@ -0,0 +1,331 @@ +# Cleanup step 1: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.3. Scouted on 14 October 2026 at +`5b9b744` on `dev`, the merge of step 0. Nothing here is implemented.* + +Step 1 has five jobs: settle the root, move the v2 build guide, settle where +the README's narrative lives, split the changelog, and list the +Korean/English pairs. Two of them wait on the maintainer (§2.13 questions 1 +and 2). This plan cuts the step into three +lanes that never edit the same file, so three people can work at once, plus +one closing PR after they land. + +## The lanes + +| lane | PR | what | files it may edit | when | +| --- | --- | --- | --- | --- | +| A | A1 | `BUILD_GUIDE_v2.md` → `docs/legacy/` | `BUILD_GUIDE_v2.md`, `docs/legacy/`, `README.md`, `BUILD_GUIDE.md`, `AGENTS.md`, `docs/README.md`, `docs/REPOSITORY.md`, `docs/assembly/README.md`, `hardware/README.md`, `hardware/case/README.md`, `web/src/components/sections/BuildPanel.tsx`, `web/src/components/sections/BuildPanel.test.tsx`, `web/src/components/guide/stage/build/layout.ts` | now | +| A | A2 | the other root moves | the moved files and their new homes, `CONTRIBUTING.md`, `README.md`, `AGENTS.md`, `docs/README.md`, `docs/REPOSITORY.md`, `.github/ISSUE_TEMPLATE/config.yml`; if `AUDIO_GUIDE.md` moves, also `firmware/README.md`, `tools/patternflow-audio-extension/README.md`, `web/src/app/features/features-data.ts`, `web/src/components/guide/copy/audio.ts` | after question 1, after A1 merges | +| A | A3 | README narrative → `docs/story.md` | `README.md`, `docs/story.md`, `docs/README.md` | after question 2, only if the answer is "move"; after A2 | +| B | B1 | split `CHANGELOG.md` into `docs/releases/` | `CHANGELOG.md`, `docs/releases/`, `docs/RELEASING.md`, `firmware/toolchain/release.py`, `web/src/app/roadmap/roadmap-data.ts` (the `links` arrays only) | now, beside A1 and C1 | +| C | C1 | the parity CSV | `web/scripts/doc-map.ts`, `docs/investigations/2026-10-doc-parity.csv`, `docs/investigations/2026-10-doc-map.csv` | now, beside A1 and B1 | +| C | C2 | the doc map reads `.yml` too (optional) | `web/scripts/doc-map.ts`, `docs/investigations/2026-10-doc-map.csv` | after C1, before A2 if it can | +| C | C3 | close step 1 | `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv`, `docs/README.md`, `docs/REPOSITORY.md`, `docs/rfc-cleanup-progress.md` | after every A and B PR has merged | + +A file sits in one lane. A lane that finds it needs a file from another lane +does not edit it: it writes the change it needs into its PR description under +"for another lane", and the owner (or C3) carries it. + +Three lanes means three people at most, one per lane. Inside a lane the PRs go +in order. At most five cleanup PRs are open at once (§2.11); this plan opens +three at the start. + +Two shared things need a rule of their own: + +- **`docs/investigations/2026-10-doc-map.csv`** is committed only by lane C. + A and B run `npm run doc-map` in `web/` to prove their move left no row at + `resolves = no`, quote the two summary lines in the PR description, then + `git checkout docs/investigations/2026-10-doc-map.csv` before committing. + Run `git fetch --tags` first, or the 24 tag-ref rows read `no`. +- **The map documents** (`AGENTS.md`, `docs/README.md`, `docs/REPOSITORY.md`) + belong to lane A while it is moving files, and to C3 at the end. Lane B + needs a sentence changed in `docs/README.md` (finding 4); it hands that + sentence to C3. + +## What the tree says + +What the scouting found, and why the lanes are drawn where they are. The +first three the RFC did not know about. + +**1. `docs/releases/` already exists.** It holds `v3.0.0.md` and `v3.1.0.md`, +long-form notes for those releases, and `v3.0.0-launch.md`, the launch +checklist. The split wants `docs/releases/v3.0.0.md` and `v3.1.0.md` for the +changelog sections. Nothing in the tree links either file; `docs/README.md` +links the folder, and `v3.0.0-launch.md` names `v3.0.0.md` inside a code +block, as the command that was run. + +**2. None of the three pair kinds §2.3 names exists.** There is no `*.ko.md` +file. The console pages are English only. `features-data.ts` has no +`detailKo`. The pairs the repository does keep: + +- `web/content/journal/.mdx` (Korean) and `.en.mdx` (English): + 37 slugs, every one paired. +- `web/src/app/roadmap/roadmap-data.ts`: `title`/`titleKo` and + `detail`/`detailKo`, 84 items. +- `web/src/lib/atlas/data.ts`: `nm`/`nmEn`, `tex`/`texEn`, `knob`/`knobEn`, + `risk`/`riskEn`, `label`/`labelEn`, `vert`/`vertEn`, `legend`/`legendEn`, + `impl`/`implEn`. Here the bare field is Korean. +- The guide's copy, an `en` and a `ko` object per file: + `web/src/components/guide/copy.ts`, `copy/{audio,build,community,hub,lab,make}.ts`, + `desk/words.ts`, `desk/community/words.ts`, `desk/ai/answers.ts`. The type + makes both sides carry the same keys, so only "shorter by a third" can show. +- The guide's routes: `web/src/app/guide/**/page.tsx` and their `ko/` twins, + five pairs. + +`docs/SERVICES.md` is Korean only, and `docs/manifesto.md` and +`CODE_OF_CONDUCT.md` mix both languages in one file. None of the three has a +twin to compare against. + +**3. The doc map does not read `.yml`.** `.github/ISSUE_TEMPLATE/config.yml` +links `SUPPORT.md` by absolute URL and is not in the map. Mentions in +backticks are not links either, and the map skips them: +`AGENTS.md`, `docs/README.md` and `docs/REPOSITORY.md` all name the root files +that way. A move PR runs `git grep ` as well as reading the map. + +**4. The changelog.** 947 lines: a header, `[Unreleased]`, then 30 release +sections from `[3.11.0] - 2026-10-04` down to `[v1.0.0] - 2026-04 (initial +public release)`. Headings are not uniform: two carry a `v` and a title +(`[v1.1.0] - 2026-04 (Multi-pattern Update)`), and the July ones have no day +(`[3.2.0] - 2026-07`). The sections hold six relative links, one each (to +`BUILD_GUIDE.md`, three investigations, `panel-compatibility.md`, +`pfst-v2-spec.md`) that need `../../` once a section sits in +`docs/releases/`, and eight root-relative ones that do not change. + +Four places say the root file is the whole record and stop being true after +the split: the changelog's own header, `docs/RELEASING.md` ("one section per +release, newest first"), `docs/README.md` line 52, and the banners of the two +long-form notes in `docs/releases/`. + +`firmware/toolchain/release.py` is the only code that reads it. +`bump_changelog` (`cut`) turns `[Unreleased]` into a dated section; +`changelog_section` (`publish`) reads that section back for the dev → main PR. +Nothing in CI and nothing in `firmware-release.yml` reads it; `shelf.sh` +names it in one error message. `roadmap-data.ts` links it twice, once with +the label "CHANGELOG (2.0.0)", and 2.0.0 will not be in the root file after +the split. + +**5. The v2 guide.** Step 0's baseline already counted the links in: five +Markdown files and `BuildPanel.tsx` with its test, none anchored. Beyond the +links, it is named in backticks in `AGENTS.md` (the repository map and hard +rule 5, "`BUILD_GUIDE_v2.md` section 10"), in `docs/README.md` (the rule +"a guide somebody follows start to finish lives at the repository root"), in +`docs/REPOSITORY.md`, and in a comment in `layout.ts`. It carries 31 relative +links out, 22 of them to `docs/build-guide/images/`. + +`roadmap-data.ts` names it in a `detail` and a `detailKo` string ("… +BUILD_GUIDE_v2.md for existing builds."). That is locked text (§2.9) and it +records the past; it stays. The changelog names it the same way, as history. + +`web/next.config.ts` redirects paths on patternflow.work. The site has no +route that serves the v2 guide; the one link goes to github.com, which +`next.config.ts` cannot redirect. So A1 adds no redirect. + +**6. The root.** 17 tracked files that do not start with a dot. The default +list in §2.3 keeps ten (`README.md`, the three guides, `CONTRIBUTING.md`, +`CHANGELOG.md`, `AGENTS.md`, the three `LICENSE` files). The other seven: + +| file | what decides where it can live | +| --- | --- | +| `BUILD_GUIDE_v2.md` | §2.3 moves it to `docs/legacy/` whatever the answer to question 1. Lane A1. | +| `CLAUDE.md` | The one-line shim that loads `AGENTS.md` for Claude Code, which reads `CLAUDE.md` from the folder a session starts in and the folders above it. Under `docs/`, a session started at the root would run without the hard rules. | +| `CITATION.cff` | GitHub's "Cite this repository" reads it only from the root. Nothing links it. | +| `CODE_OF_CONDUCT.md`, `SECURITY.md`, `SUPPORT.md` | GitHub finds each of them in the root, `docs/` or `.github/`, so a move keeps the community profile and the security tab. 6 links in, plus the absolute one in `config.yml`. | +| `AUDIO_GUIDE.md` | `docs/README.md`'s rule puts it at the root; the default list does not. 6 links in, two of them absolute from the site (`features-data.ts`, `guide/copy/audio.ts`). | + +**7. The README's narrative.** "Where it began" (line 39, 12 lines), "The +community responded" (line 51, 23 lines), "Story so far" (line 271, 23 +lines). Under "Story so far" sits `### Sponsor`, PCBWay's credit, which is +not narrative. Nothing links the three anchors. The sections carry five +relative paths: two images (`./docs/media/community-builds.jpg`, +`./docs/media/web-build-map.png`), `integrations/README.md`, +`docs/EDITIONS.md`, and `#contributing`, an anchor in the README itself. A +verbatim move rewrites those five and nothing else. + +**8. Where the RFC and the worker rules disagree.** §2.11 names branches +`cleanup/-` and asks for a three-line PR description; the hub +hands each worker its branch name and reads it back, and asks for a longer +description. §2.1 caps a PR at 300 changed lines; the changelog split copies +about 920 lines. The worker rules say a worker does not edit `CHANGELOG.md` +unless its task says so; B1's task is to edit it. + +## Defaults this plan takes + +Each is the reasonable reading where the RFC is silent or the tree surprised +it. A lane follows them unless the maintainer answers otherwise (last +section). + +- **D1. The two long-form notes give up their names.** `docs/releases/v3.0.0.md` + becomes `v3.0.0-notes.md` and `v3.1.0.md` becomes `v3.1.0-notes.md`, by + `git mv` with no text change, next to `v3.0.0-launch.md`. `vX.Y.Z.md` is + then always the changelog section. Their banners and the launch checklist's + code block stay as written. +- **D2. Every release gets a file, the newest three too.** The root keeps the + header, `[Unreleased]`, the three newest sections in full, and a list + linking every older file. `release.py cut` writes `docs/releases/vX.Y.Z.md` + for the new release and replaces the fourth-newest section in the root + with its line in that list. The three newest then exist twice; finding that + drift is a step 2 check, not part of step 1 (question 9). +- **D3. Verbatim text does not count toward the 300 lines** when the PR proves + it is verbatim. For a `git mv` git shows the rename. For the changelog the + PR runs a reassembly check (B1 below) and quotes its output. Every other + line in the PR stays under 300. +- **D4. Branches, labels, descriptions.** Lanes use the branch the hub gives + them. The RFC's three lines (what moved, which §2.1 commands ran, which + doc-map rows it touched) go into the description as their own section. + Labels `cleanup` and `step-1` go on if the tools allow it. +- **D5. Community files move to `docs/`, not `.github/`**, because §2.3 says + "move under `docs/`" and GitHub reads both. Only if question 1 moves them. +- **D6. No redirect and no stub for the v2 guide** (finding 5). An outside + link to `blob/main/BUILD_GUIDE_v2.md` breaks. A1 lists any it can find (the + fork's release bodies, if the tools reach them) for the maintainer, who can + edit a release body; a worker does not. + +## Lane A: moves at the root + +**A1, now.** `git mv BUILD_GUIDE_v2.md docs/legacy/BUILD_GUIDE_v2.md`, then: + +1. Rewrite the 31 relative links inside it for its new depth + (`docs/build-guide/…` → `../build-guide/…`, `hardware/…` → + `../../hardware/…`). `` tags too. No word changes. +2. Point the five Markdown links at the new path, and the site link in + `BuildPanel.tsx` (`…/blob/main/docs/legacy/BUILD_GUIDE_v2.md`). Change the + expected `href` in `BuildPanel.test.tsx` to match. The test keeps checking + the link; only the path it expects moves. +3. The backtick mentions: `AGENTS.md` (repository map; hard rule 5 becomes + "`docs/legacy/BUILD_GUIDE_v2.md` section 10"), `docs/README.md` (drop it + from the root-guides list in the rule, add one line for `legacy/`), + `docs/REPOSITORY.md` (root row, and the `docs/` row), and the + `layout.ts` comment. +4. Leave alone: the `detail`/`detailKo` strings in `roadmap-data.ts`, every + mention in `CHANGELOG.md`, and `docs/releases/v3.0.0.md`'s link to + `v3.0.0:BUILD_GUIDE_v2.md` (a tag ref, still valid). + +Checks: `python .github/scripts/check_links.py`; `npm run doc-map` (0 do not +resolve, CSV discarded); `git diff -M --stat dev` shows the rename; and +because the PR touches `web/src/` and `AGENTS.md`, `npm run check:ci`, +`check:panels`, `typecheck`, `lint` in `web/`, and +`python firmware/toolchain/check_versions.py` (it parses `AGENTS.md`, and +`firmware-checks.yml` runs on any `AGENTS.md` change). `build.sh all` does not +apply: no file under `firmware/` changes. + +**A2, after question 1.** Whatever the answer moves, with the same steps: +`git mv`, rewrite the moved file's own relative links, every inbound link +from the map, every backtick mention from `git grep`, and the absolute URL in +`.github/ISSUE_TEMPLATE/config.yml` if `SUPPORT.md` moves. `CHANGELOG.md`'s +mentions stay (history). If `AUDIO_GUIDE.md` moves, the PR touches `web/src/` +and runs the four web commands. + +**A3, after question 2, only if the answer is "move".** Copy the three +sections verbatim to `docs/story.md` and rewrite the five paths in finding 7 +for the new folder (`./docs/media/…` → `media/…`, `integrations/README.md` → +`../integrations/README.md`, `docs/EDITIONS.md` → `EDITIONS.md`, +`#contributing` → `../README.md#contributing`). Leave one line in the README +pointing at it, and keep `### Sponsor` with its images in the README. The +maintainer approves on the PR (§2.9: moving locked text). Lane A's last PR. + +Done when: the root holds only the files question 1 agreed, and every move +carried its inbound links in the same PR. + +## Lane B: the changelog + +**B1, now.** One PR. Its task text must say that editing `CHANGELOG.md` is the +job. + +1. D1: `git mv` the two long-form notes to `-notes.md`. +2. Write `docs/releases/vX.Y.Z.md` for each of the 30 sections: the heading + line and the body exactly as they are in the root, with only the six + relative link paths rewritten. File names always carry the `v` + (`[3.11.0]` → `v3.11.0.md`, `[v1.1.0]` → `v1.1.0.md`). +3. Root `CHANGELOG.md` (D2): header, `[Unreleased]` untouched (the hub writes + PR lines into it), `[3.11.0]`, `[3.10.5]`, `[3.10.4]` in full, then one line + per older release linking its file. Rewrite the header sentence that says + this file is the complete record; it is the only new prose. +4. `release.py`: `cut` also writes `docs/releases/vX.Y.Z.md` and drops the + fourth-newest section from the root to a list line; add the new file to the + paths `cut` commits. `publish` keeps reading the newest section from the + root, where it still is. Update the docstring. +5. `docs/RELEASING.md`: step 3 of the checklist and "Current release line" + say what a release now writes. +6. `roadmap-data.ts`: point the "CHANGELOG (2.0.0)" link at + `docs/releases/v2.0.0.md`. Only `href` values in `links`; no `detail` or + `detailKo` string. +7. For C3: the replacement for `docs/README.md` line 52 ("`CHANGELOG.md` at + the root is the complete record"), and whether `docs/REPOSITORY.md`'s + `docs/` row should name `releases/`. + +Checks: + +- **Reassembly.** A scratch script (not committed) rebuilds the original + `CHANGELOG.md` from the root's header and `[Unreleased]` plus every + `docs/releases/vX.Y.Z.md` in order, with the link rewrite undone, and + `diff`s it against `git show dev:CHANGELOG.md`. Empty diff, quoted in the + PR. A second diff shows the three sections left in the root are equal to + their files. +- **`release.py` dry run** in a throwaway worktree + (`git worktree add`, then `cut v3.11.1 --no-build --no-commit + --no-web-checks --any-branch`; `[Unreleased]` has entries, so the dry run + dates them), showing the new file written and + `[3.10.4]` leaving the root. Remove the worktree after. + `python -m py_compile firmware/toolchain/release.py`. +- `check_links.py`, `npm run doc-map` (CSV discarded), and for + `roadmap-data.ts` the four web commands. The PR touches + `firmware/toolchain/`, so `firmware-build.yml` and `firmware-checks.yml` + run on it in CI; that is the `build.sh all` record, since this container + cannot reach the PlatformIO registry (baseline). No `.pfm` cache key can + move: the toolchain revision hashes `abi/*.h`, the shared `src/` sources, + `module.ld`, `build_module.py` and `port_preset.py` + (`web/src/lib/firmware/builderRev.ts`), and `release.py` is none of them. + +Done when: 30 files exist, the reassembly diff is empty, `release.py` and +`RELEASING.md` describe the same release, and `docs-links.yml` is green. + +## Lane C: the parity list, then the close + +**C1, now.** `doc-map.ts` gains a second output, +`docs/investigations/2026-10-doc-parity.csv`, one row per pair. A suggested +shape: `kind, en, ko, en_chars, ko_chars, state`, where `state` is `ok`, +`missing-en`, `missing-ko`, `en-short` or `ko-short` (one side under two +thirds of the other, counting characters with markup stripped). Sources are +the five kinds in finding 2. The CSV header or the script's doc comment says +that the three kinds §2.3 lists do not exist yet, so a later `*.ko.md` lands +in the same output. Characters are a rough measure across two scripts; the +column records the numbers so the maintainer can judge rather than trust the +flag. No translation, no edit to any source file. Regenerate the doc map too, +since the same command writes both. + +Checks: `npm run doc-map` twice gives byte-identical CSVs; `typecheck` and +`lint` in `web/` (a script under `web/scripts/`, not `web/src/`). + +**C2, optional.** Add `.yml` and `.yaml` under `.github/` to the doc map's +sources, so `config.yml`'s link shows up (finding 3). Small; lands before A2 +if it can. + +**C3, last.** After every A and B PR has merged: regenerate both CSVs, carry +B1's sentence into `docs/README.md` and `docs/REPOSITORY.md`, check §2.3's +"done when" list (root, inbound links, `docs-links.yml`, the parity CSV), and +write step 1 into `docs/rfc-cleanup-progress.md` the way step 0 is written +there. + +## Questions for the maintainer + +The first two are §2.13's own, numbered as there, and A2 and A3 wait on +them. + +1. **Which files stay at the root?** Suggested: the default list plus + `CLAUDE.md` and `CITATION.cff` (both work only at the root), and + `AUDIO_GUIDE.md` (`docs/README.md`'s rule calls it a root guide). + `CODE_OF_CONDUCT.md`, `SECURITY.md`, `SUPPORT.md` move to `docs/` (D5). +2. **Do the README's narrative sections stay, or move to `docs/story.md`?** + No default. Agents do not decide this (§2.3). + +The rest come from the scouting, numbered on from §2.13's six. Each has the +default the lanes use until there is an answer. + +7. **The two long-form release notes give their names to the split** (D1)? +8. **Verbatim text outside the 300-line count** when the PR proves it (D3)? +9. **A check that the root's three newest sections match their files** (D2): + add it to step 2's table? +10. **Parity sources** are the five kinds that exist, not the three §2.3 + names (finding 2)? +11. **No stub or redirect for the v2 guide** (D6). Release bodies that link + the old path are yours to edit. diff --git a/docs/investigations/2026-10-cleanup-step2-plan.md b/docs/investigations/2026-10-cleanup-step2-plan.md new file mode 100644 index 00000000..a4d45af0 --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step2-plan.md @@ -0,0 +1,279 @@ +# Cleanup step 2: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.4, with the §2.9 lock and the +§2.10 voice lint. Scouted on 14 October 2026 at `5955052` on `dev`, after step +1's moves, the split and the parity list had merged. Nothing here is +implemented.* + +Step 2 is seven drift checks (§2.4). The step 1 plan sent one more here, a +check on the changelog split (its D2). Two others are checks the RFC gives no +step: the lock on human-written text (§2.9), and the voice lint (§2.10) with +the `AGENTS.md` rule that points at it. This plan cuts all of it into fourteen +PRs. No two of them edit the same file. + +## The PRs + +| k | what | files it may edit | merges after | §2.13 | +| --- | --- | --- | --- | --- | +| 1 | the runner, its library, every npm name, and `check:features` | `web/package.json`, `web/scripts/drift.ts`, `web/scripts/drift/_lib.ts`, `web/scripts/drift/features.ts` | — | — | +| 2 | `check:links-absolute` | `web/scripts/doc-map.ts`, `web/scripts/drift/links-absolute.ts` | — | — | +| 3 | `check:bom` and `check:bom-schematic` | `web/scripts/drift/_bom.ts`, `web/scripts/drift/bom.ts`, `web/scripts/drift/bom-schematic.ts` | 1 | — | +| 4 | `check:editions` | `web/scripts/drift/editions.ts` | 1 | — | +| 5 | `check:presets` | `web/scripts/drift/presets.ts` | 1 | — | +| 6 | `check:roadmap` | `web/scripts/drift/roadmap.ts` | 1, 10, and 14 if it opens | — | +| 7 | `check:changelog` (step 1 plan, D2) | `web/scripts/drift/changelog.ts` | 1 | — | +| 8 | `check:human-lock` (§2.9) | `web/scripts/human-lock.ts`, `web/scripts/lib/pr-base.ts`, `docs/.human-written` | — | 2, 5 | +| 9 | `check:voice` (§2.10) | `web/scripts/voice-lint.ts` | 8 | 6 | +| 10 | the drift workflow | `.github/workflows/drift.yml` | 1 | — | +| 11 | `AGENTS.md`: the §2.10 rule and the new commands | `AGENTS.md` | 9, 10 | 6 | +| 12 | the docs that say what CI runs | `CONTRIBUTING.md`, `web/README.md`, `web/ARCHITECTURE.md`, `docs/REPOSITORY.md` | 10 | — | +| 13 | close step 2 | `docs/rfc-cleanup-progress.md`, `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv` | every other k | — | +| 14 | roadmap drift, only if k6's first CI run finds any | `web/src/app/roadmap/roadmap-data.ts` (`status` and `issues` only) | — | — | + +Step 2 starts when step 1 is closed (§2.12). k9 and k11 also need step 1's +voice PR on `dev`, which no lane of the step 1 plan carries: k9 reads +`docs/voice-banned.txt` and k11 names `docs/voice.md`. + +A file sits in one row. A PR that finds it needs a file from another row +writes the change into its description under "for another k", and the owner +(or k13) carries it. Three people can work at once. Start with k1, k2 and k8, +which wait on nothing; after k1 merges, the rest of the checks open in any +order, three at a time, inside §2.11's limit of five. + +Two shared things have a rule of their own: + +- `web/package.json` belongs to k1. It adds every name this step needs, so no + later PR touches it, and `check:ci` picks up each check the day its file + lands (D1). No PR in this step adds a dependency. +- The doc-map CSVs are committed only by k13. Every other PR runs + `npm run doc-map` in `web/` after `git fetch --tags`, quotes the two summary + lines, and runs `git checkout` on both CSVs before committing. + +`AGENTS.md` and `.github/` each get a PR of their own (k11, k10). No PR in this +step edits `CHANGELOG.md`: each one's line rides in its description, and the +root's three newest sections already equal their files (finding 9). + +## What the tree says + +1. `check:ci` runs on fewer PRs than the checks need. `web-ci.yml` starts on + `web/**` and `hardware/case/**` only. A PR that edits only the BOM, the + build guide, a feature folder, a bundle or the changelog runs no web job, + so a check wired only into `check:ci` would never see the drift it exists + for. k10 adds a workflow that runs the checks on every PR. +2. Absolute links. With tags fetched, `npm run doc-map` reads 1051 links from + 208 files, every one resolving. 118 are absolute, from 26 files, 24 of them + on a tag (`v1.0.0` to `v3.1.0`). There are no raw.githubusercontent.com + links. `doc-map.ts` already finds and resolves all of them, `${REPO}` + templates included, so k2 asks it rather than writing a second reader. + `git grep` finds no tracked file outside the map's sources that carries a + repository URL. `actions/checkout` fetches no tags, so a tag row cannot be + checked in CI without `fetch-depth: 0`. +3. BOM and guide. Both have seven on-board rows and four off-board rows. The + names are worded apart ("Rotary encoder w/ switch" against "Rotary encoder + with push switch"), so a check on names would fail at HEAD; ref, quantity + and MPN agree. The sockets row is `U1 (sockets)` in the CSV and `—` in the + guide, and its notes say it is not a refdes. Off the board there is no ref, + the CSV writes `6-12` where the guide writes `6–12`, and every word of the + CSV's part name appears in the guide's (`USB cable (sacrificial)`, + "Sacrificial USB cable (it gets cut)"). +4. Schematic. `hardware/pcb/schematic.pdf` is KiCad output in stroke fonts, + so no designator sits in its streams as a plain string. `pdftotext` 24.02 + reads them all the same: U1, SW1 to SW4, J1, J3, J4 and C11, which is + exactly the BOM's nine. The other refdes-shaped tokens are pin names (S1 to + S3, IO4 to IO48, ESP32). The text layer carries designators, so §2.4's + warn-only clause is not needed. Nothing in `web/package.json` reads PDF. +5. Features. `features-data.ts` has ten ids; nine are the folders under + `firmware/patternflow/features/`, and the tenth is `core`, which the type's + own comment allows ("Directory under firmware/patternflow/features/, or + "core""). +6. Editions. Three cards (`core`, `performance`, `audio`) and four bundles + (`audio`, `clock`, `midi`, `performance`). `clock` and `midi` are not + drift: `features-data.ts` names each as a try-out + (`/flash/bin/clock-v0.1.5/`, `/flash/bin/midi-v0.1.0/`). The five folders + in `web/public/flash/bin/` are the three cards' images and the two + try-outs. `release.py` and `check_versions.py` each keep + `EDITIONS = ("audio", "performance")` by hand. `check_versions.py` + already checks versions and that the named images exist. +7. Presets. `check_presets.py` (`firmware-checks.yml`) already compares + `web/src/lib/presets/` with `firmware/patternflow/presets/`: 34 twins, 14 + web-only on purpose, `calib` firmware-only. `index.ts` imports all 48 web + presets. What no script looks at is how a twin reaches a panel: + `pattern_registry.h` compiles in `origin` and `calib`, and the other 33 + are the Basics pack, whose `basics.json` keeps a `presets` map of 33 + entries. The feature presets (`show/preset_black.h`, + `weather/preset_weather.h`) live in `features/`, not `presets/`. +8. Roadmap. 83 nodes, 13 with an `issues` list, 14 distinct numbers, all in + `engmung/Patternflow`. Every one of the 13 is `done`; the four `planned` + nodes have no issues. This session's GitHub access covers only the fork, + so the state of those issues was not read. Some may be open on purpose: + #221 holds USB-C "until a redesign passes", and its node records the + withdrawal, which did ship. +9. Changelog. The root keeps `[3.11.0]`, `[3.10.5]` and `[3.10.4]`, and each + equals its file in `docs/releases/` byte for byte. Below them, "Older + releases" links one file per line. +10. The lock. Every region §2.9 names exists: the README's three headings + (lines 39, 51, 271), Part 1 of `rfc-cleanup.md` and of + `rfc-core-and-variants.md`, 74 `.mdx` files in `web/content/journal/`, + `docs/manifesto.md`, 33 files in `docs/releases/` (the `-notes` and + `-launch` files too), and the roadmap's `detail`/`detailKo` strings. + `docs/.human-written` does not exist, and answer 5 adds nothing to it. +11. Voice. `docs/voice.md` and `docs/voice-banned.txt` are not on `dev`; + step 1's voice PR is writing them. The §2.10 block is not in `AGENTS.md` + and no step of the RFC owns appending it. +12. No PR here edits `firmware/` or `web/src/` except k14, so `build.sh all` + does not apply. `web/scripts/` is outside `next build`, and + `npm run typecheck` is what covers it. + +## Defaults this plan takes + +- D1. One runner. Each drift check is a file in `web/scripts/drift/`. + `web/scripts/drift.ts` runs every file there whose name does not start with + `_`, and `check:ci` calls it once, as `check:drift`. The RFC's names stay: + `npm run check:bom` runs that one file. +- D2. Skipped, not failed. A side a check cannot read where it runs (a tag + the clone lacks, no `gh` login, no `pdftotext`) prints why and passes. With + `PF_DRIFT_STRICT=1`, which k10's workflow sets, a skip fails. A contributor + running `check:ci` at their desk is not stopped by a missing tag; CI is. +- D3. Exceptions are named where the tree already names them. `core` comes + from the `Feature` type; try-outs from `tryOut`; the web-only and + firmware-only presets from `check_presets.py`, read rather than copied. + A check with an exception of its own keeps it in a list with a reason per + entry, the way `check_presets.py` does. +- D4. `check:bom-schematic` is strict from its first day (finding 4) and runs + `pdftotext` from poppler. That keeps `package-lock.json` out of the step. +- D5. The lock reads the PR's base from `PF_LOCK_BASE` (the workflow passes + the base SHA). Without it, the base is the merge-base with `origin/dev`, + then `origin/main`; with neither, it skips. A new locked file passes. A + locked file moved with its plain text unchanged passes and prints that the + move needs the maintainer's approval on the PR (§2.9); any other change to + locked text fails. +- D6. `check:voice` is not in `check:ci`. §2.10 runs it before a PR opens, + and a person reads the text before merge. With no arguments it reads the + lines a branch adds against the same base as the lock. +- D7. Branches, labels and descriptions as in the step 1 plan's D4. + +## The PRs in detail + +Every PR runs `npm run typecheck`, `npm run lint` and `npm run check:drift` in +`web/` (k2 and k8 run their script directly until k1 lands), and quotes one +failing run made on a throwaway edit, reverted before commit. A check is +proved when it fails on drift, not only when it passes. + +k1. `drift.ts` runs each check in its own process, prints one line per check +(passed, failed, skipped), and exits 1 if any failed; a name argument runs +one. `_lib.ts` is the base the other checks import: `ROOT`, `tracked()`, +`read()`, `compare(name, left, right)` (each side a label and a list; prints +both sides and what is on one side only, as §2.4 asks), `skip(name, why)` +(D2) and `done()`. In `package.json`: `check:drift`; `check:links-absolute`, +`check:bom`, `check:bom-schematic`, `check:features`, `check:editions`, +`check:presets`, `check:roadmap` and `check:changelog`, each +`tsx scripts/drift.ts `, which says so and fails while the file is not +written; `check:human-lock` (`tsx scripts/human-lock.ts`) and `check:voice` +(`tsx scripts/voice-lint.ts`); and `&& npm run check:drift` at the end of +`check:ci`. `features.ts` compares the ids with the folders (finding 5). +Runs the whole `check:ci`. + +k2. `doc-map.ts` gets a `--check absolute` mode: the same rows, nothing +written, exit 1 on an absolute row that does not resolve. A row on a ref the +clone lacks is a skip (D2). The mode also fails on a tracked file outside the +map's sources that carries a repository URL, since the map would not read it. +`links-absolute.ts` runs that mode and imports nothing from k1. Proof that the +map did not move: both CSVs byte-identical before and after +(`git diff --exit-code`). + +k3. `_bom.ts` reads `bom_v3.9.csv` and expands `SW1-SW4`. `bom.ts` matches +the on-board rows by MPN, then quantity and ref, with the sockets row as in +finding 3; the off-board rows in order, by quantity and the words of the part +name. Dashes compare equal. `bom-schematic.ts` runs +`pdftotext -layout hardware/pcb/schematic.pdf -` and compares the tokens with +a refdes prefix (U, SW, J, C, and R, D, Q, L, F for a part not yet on the +board) with the BOM's refs. This is hard rule 2 as a check; no file under +`hardware/` or `BUILD_GUIDE.md` changes. + +k4. `editions.ts` compares five lists (finding 6): the cards bar `core`, the +bundle folders, the try-outs, the folders in `web/public/flash/bin/` against +the images the cards, try-outs and manifest name, and the two `EDITIONS` +tuples. Versions stay `check_versions.py`'s job. + +k5. `presets.ts` reads `WEB_ONLY` and `FIRMWARE_ONLY` out of +`check_presets.py`. Drift: a `pattern-*.ts` that `index.ts` does not import, +or the reverse; a twin in neither `pattern_registry.h` nor `basics.json`'s +`presets`; a pack entry with no twin (finding 7). + +k6. `roadmap.ts` asks `gh api repos/engmung/Patternflow/issues/` for each +number. Drift: `done` with an open issue, `planned` with a closed one. A +worker cannot read that repository from a session scoped to the fork, so the +PR proves the logic against a stub `gh` on `PATH` giving each answer, and its +own run in k10's workflow is the first real one. If that run finds drift, +k6 stays open and k14 goes first (question 12). + +k7. `changelog.ts`: each release section in the root equals its +`docs/releases/vX.Y.Z.md` once the relative links are put back (B1's rewrite, +undone); each "Older releases" line names a file that exists; and every +`vX.Y.Z.md` (not `-notes`, not `-launch`) is in the root in full or in the +list. It reads the same layout `release.py cut` writes, so a release stays +green. + +k8. `human-lock.ts` takes the regions in finding 10, plus each path in +`docs/.human-written`. Plain text is the region with Markdown and MDX syntax +(link targets, image paths, tags, emphasis marks, heading marks) and all +whitespace removed; front matter values count as text. The roadmap strings +come out through the TypeScript parser, as `doc-map.ts` already does. A +README region runs from its `##` heading to the next. `lib/pr-base.ts` finds +the base (D5) and the files changed since it, for k9 too. +`docs/.human-written` starts with a comment line and no path (answer 5). No +locked text changes in this PR. + +k9. `voice-lint.ts` reads its banned list from `docs/voice-banned.txt` and +flags the patterns §2.10 names: banned words and phrases (English by word, +Korean as written), a list of three adjectives or clauses, an opening that +says what the document is about, and a closing summary. It prints +`file:line` and the rule. Arguments are files; none means the added lines +since the base (D6). + +k10. `.github/workflows/drift.yml`, named "Drift checks", on every pull request +and on push to `main`. Checkout with `fetch-depth: 0` (tags, and the base for +the lock); Node 20 and `npm ci` in `web/`; poppler if `pdftotext` is missing; +`npm run check:drift` with `PF_DRIFT_STRICT=1` and +`GH_TOKEN: ${{ github.token }}`; on a pull request, `npm run check:human-lock` +with `PF_LOCK_BASE` set to the base SHA, in a step that runs once +`web/scripts/human-lock.ts` exists. Permissions `contents: read` and +`issues: read`. Each check that lands after it runs strict on its own PR. + +k11. Append §2.10's block to `AGENTS.md` as the RFC writes it. Under "Common +commands", one line each for `check:drift` (in `check:ci`, and strict in +`drift.yml`), `check:human-lock` (on pull requests) and `check:voice` (by +hand, before a PR with new prose). `firmware-checks.yml` runs on any +`AGENTS.md` change, so the PR runs `python firmware/toolchain/check_versions.py` +too. + +k12. The four files say what CI runs: `CONTRIBUTING.md`'s table row for web +work, `web/README.md`'s command list, the tests paragraph of +`web/ARCHITECTURE.md`, and the line on `check_links.py` in +`docs/REPOSITORY.md`. One sentence each where one is enough. + +k13. After every other k has merged: regenerate both CSVs, check §2.4's "done +when" (seven scripts, in CI, passing at HEAD) and the three added here, and +write step 2 into `docs/rfc-cleanup-progress.md` the way step 0 is written. + +k14. Only if k6's first CI run finds drift. The PR lists each mismatch and +changes only what the maintainer decides for it: a node's `status`, or a +number in its `issues`. The `detail` and `detailKo` strings are locked. An +issue the maintainer calls open on purpose goes into k6's own list instead, +with the reason given. It is the one PR here that touches `web/src/`, so it +runs `check:ci`, `check:panels`, `typecheck` and `lint` (§2.1). + +## Questions for the maintainer + +Numbered on from the step 1 plan's eleven. Each has the default the PRs use +until there is an answer. + +12. A roadmap node marked `done` whose issue is still open: drift, or allowed? + Default: k14 asks per item, and nothing goes on k6's list without your + reason. +13. Your own edit to locked text (a typo in a journal post) turns + `check:human-lock` red. Default: no override file. You merge over the red + row or push the edit yourself. +14. Should `check:voice` gate CI? Default: no, it is run by hand (D6). +15. Keep `check_presets.py` and `check:presets` as two scripts? Default: yes. + k5 reads the Python file's lists, so they cannot disagree. diff --git a/docs/investigations/2026-10-cleanup-step3-plan.md b/docs/investigations/2026-10-cleanup-step3-plan.md new file mode 100644 index 00000000..61049e6c --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step3-plan.md @@ -0,0 +1,295 @@ +# Cleanup step 3: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.5. Scouted on 14 October 2026 at +`2240b04` on `dev`, after the step 2 plan merged and before step 1 closed. +Nothing here is implemented.* + +§2.5 gives step 3 six jobs: lint and typecheck at zero, every legacy path named +or gone, minor and patch dependency updates, the HUB75 fork written down as a +patch and then brought up to upstream, the `TODO` and `FIXME` markers, and one +source for the constants the firmware and the site both carry. Answer 4 of +§2.13 keeps major updates out of this window. Scouting found one fix the step +exists for: the MIDI page still counts 20 detents a turn (finding 9). This plan +cuts all of it into 27 PRs. + +## The PRs + +| k | what | files it may edit | merges after | §2.13 | +| --- | --- | --- | --- | --- | +| 1 | lint: four unused names and one hook dependency | `web/scripts/performance-smoke.ts`, `web/src/app/editions/EditionCard.tsx`, `web/src/app/roadmap/RoadmapMap.tsx`, `web/src/components/community/PatternCard.tsx` | — | — | +| 2 | lint: seven `` warnings get a named exception | `web/src/app/build/breadboard/page.tsx`, `web/src/components/journal/JournalLightbox.tsx`, `web/src/components/sections/Hero.tsx` | — | — | +| 3 | CI fails on a lint warning | `.github/workflows/web-ci.yml` | 1, 2 | — | +| 4 | Pattern Lab's v1 keys | `web/src/lib/lab/persist.ts`, `web/src/lib/lab/legacyDraft.ts`, `web/src/lib/lab/serialize.ts`, `web/src/lib/lab/store/project.ts`, `web/src/lib/lab/director/bake.ts` | — | — | +| 5 | the site's redirects and old routes | `web/next.config.ts`, `web/src/lib/campaignRoutes.ts`, `web/src/proxy.ts`, `web/src/app/business/page.tsx`, `web/src/app/community/layout.tsx` | — | — | +| 6 | old formats the community and the build service still accept | `web/src/components/community/FeedControls.tsx`, `web/src/lib/community/deck.ts`, `web/src/lib/community/zipDownload.ts`, `web/src/lib/community/server/deckZip.ts`, `web/src/lib/community/server/builds.ts`, `web/src/lib/community/server/schema.ts`, `web/src/app/api/community/builds/route.ts`, `web/src/lib/firmware/moduleRunner.ts` | — | — | +| 7 | `shared/constants.json`, its generator, both generated files, `check:shared` | `shared/constants.json`, `web/scripts/gen-shared.ts`, `web/scripts/drift/shared.ts`, `firmware/patternflow/src/core_shared.h`, `web/src/lib/shared/constants.ts`, `web/package.json` | — | — | +| 8 | the firmware reads `core_shared.h` | `firmware/patternflow/config.h`, `firmware/patternflow/net_config.h`, `firmware/patternflow/src/core_encoders.h`, `firmware/patternflow/src/core_bus.h`, `firmware/patternflow/patternflow.ino` | 7 | — | +| 9 | the site reads `constants.ts` | `web/src/lib/pattern/harness.ts`, `web/src/lib/pattern/controls.ts`, `web/src/lib/pattern/matrix.ts`, `web/src/lib/guide/panelScreens.ts`, `web/src/lib/guide/deviceSim.ts`, `web/public/pattern-sandbox.html` | 7 | — | +| 10 | a fix, not a cleanup: MIDI counts 24 detents a turn | `firmware/patternflow/console/midi.html`, `firmware/patternflow/features/midi/midi_index.h`, `firmware/patternflow/features/midi/core_midi.h`, `docs/midi-spec.md` | — | — | +| 11 | the HUB75 fork as `UPSTREAM.md` and `patternflow.patch` | `firmware/patternflow/src/hub75/UPSTREAM.md`, `firmware/patternflow/src/hub75/patternflow.patch`, `firmware/patternflow/src/hub75/VENDORED.md`, `firmware/patternflow/src/core_display.h`, `firmware/patternflow/src/core_canvas.h` | — | — | +| 12 | upstream fixes, one PR per commit on k11's list | the upstream files that commit changes under `firmware/patternflow/src/hub75/`, and that folder's `UPSTREAM.md` and `patternflow.patch` | 11, then the PR before it | — | +| 13 | no new `TODO` or `FIXME` | `web/scripts/drift/markers.ts` | — | — | +| 14 | dependencies: lint and test tooling | `web/package-lock.json` | 3, 7 | 4 | +| 15 | dependencies: Drizzle | `web/package-lock.json` | 14 | 4 | +| 16 | dependencies: Tailwind | `web/package-lock.json` | 15 | 4 | +| 17 | dependencies: React Three | `web/package-lock.json` | 16 | 4 | +| 18 | dependencies: zustand | `web/package-lock.json` | 17 | 4 | +| 19 | dependencies: mediabunny | `web/package-lock.json` | 18 | 4 | +| 20 | dependencies: `@google/genai` | `web/package-lock.json` | 19 | 4 | +| 21 | dependencies: posthog-js | `web/package-lock.json` | 20 | 4 | +| 22 | dependencies: better-auth | `web/package-lock.json` | 21 | 4 | +| 23 | dependencies: React | `web/package.json`, `web/package-lock.json` | 22 | 4 | +| 24 | dependencies: Next | `web/package.json`, `web/package-lock.json` | 23 | 4 | +| 25 | `AGENTS.md`: `shared/`, the fork's two files, the new commands | `AGENTS.md` | 3, 7, 11 | — | +| 26 | the docs that map the tree | `docs/REPOSITORY.md`, `web/ARCHITECTURE.md`, `firmware/README.md` | 4, 7, 9, 11 | — | +| 27 | close step 3 | `docs/rfc-cleanup-progress.md`, `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv` | every other k | — | + +Step 3 opens when step 2 is closed (§2.12). Step 2's runner (its k1) runs every +file in `web/scripts/drift/` from `check:ci` and its workflow (k10) on every +pull request, so k7 and k13 reach CI by adding a file. Every PR here runs its +voice lint (k9) on added prose, and none edits text its §2.9 lock (k8) covers. +Neither check is in the tree today; this plan does not write them again. + +A file sits in one row, with two exceptions that run in order. The lockfile +belongs to k14 to k24, which open one at a time, and `web/package.json` to k7, +then k23, then k24. `UPSTREAM.md` and `patternflow.patch` belong to k11, then to +each k12 PR in turn. Rows that can be open at the same time share no file. +Start with any three of k1, k2, k4, k5, k6, k7, k10, k11 and k13, which wait on +nothing. §2.11's limit of five holds, and the dependency lane is one of them. + +Only k27 commits the doc-map CSVs. Every other PR runs `npm run doc-map` in +`web/` after `git fetch --tags`, quotes its two summary lines, and checks both +CSVs out again. `AGENTS.md` and `.github/` get a PR each (k25, k3). No PR edits +`CHANGELOG.md`: each one's line rides in its description. + +## What the tree says + +1. Lint and typecheck. `npm run lint` gives the baseline's twelve warnings: + four `no-unused-vars` and one `exhaustive-deps` in four files, seven + `no-img-element` in three. `npm run typecheck` prints nothing. `web-ci.yml` + gates on errors only ("warnings still pass"), so a fixed warning can return. +2. The `` warnings. `next/image` changes the markup a page serves (a + `srcset`, lazy loading, the `/_next/image` route), which §2.1(1) rules out. + The tree already makes nine line exceptions to this rule, three with a + reason after `--` (`LabWindow.tsx`, `DeskStage.tsx`, `AttachmentList.tsx`). +3. The hook warning. `clampPan` in `RoadmapMap.tsx` reads `width`, `zoom` and a + ref, and the wheel effect lists `[width, zoom]`. As a `useCallback` on those + two it can join the list, and the effect runs on the same renders as now. + `zoomTo` and `REASON_LABEL` have no reader. +4. Pattern Lab. `patternflow_lab_draft_v1` was written by the lab's autosave + from 25 July (`e19fe19`) until Pattern Lab v2 replaced it on 27 July + (`9be6e98`); `patternflow_ramp_v1` is older. Only `migrateLegacyDraft` reads + them. The comments say "v1 lab" and "read once", with no date, and "read + once" is wrong: stashing a session removes the project key + (`store/project.ts:67`), and the next open, finding none, migrates again. + `persist.ts` lines 8 to 11 say the migration lifts the draft and the + gallery; it lifts the draft and the ramp. `patternflow_lab_gallery_v1` is + called legacy and is the live gallery key. `saveDraft` has no caller. + `bakeShow` v1 "stays for … old players" and names none. +5. Redirects. `/variants` and `/flash` in `next.config.ts` name what links to + them, and both are still linked (`web_update_index.h:214`, + `.github/scripts/flashing-md.sh:61`). The `/variants` comment dates the + console links to 3.8.0; `docs/releases/v3.7.0.md` has them already. With no + comment: the two `?lang=en` journal redirects, which `proxy.ts` also handles + while setting the language cookie; the `/en/en` collapse in `proxy.ts`; the + eight campaign rewrites in `campaignRoutes.ts`; and `app/business/page.tsx`, + a redirect to `/contact` from May that nothing in the repository links. + `community/layout.tsx` gives "an old or mistyped URL" as its reason. +6. Community and the build service. `deck.ts` migrates `pf-module-cart` to + `pf-deck` (`f3f163b`, 29 July) and names the old writer but not the date. + `zipDownload.ts` keeps `duplicate-name` "for older servers", those before + `e261978` (24 September). `builds/route.ts` answers a `"bin"` build with + 410, while `server/builds.ts`, `server/schema.ts` and `moduleRunner.ts` + describe `"bin"` as live, and `moduleRunner.ts` cites a `buildRunner.ts` + that is gone. `FeedControls.tsx` strips the parameters of "an old bookmark". +7. Dependencies. `npm outdated` shows a minor or patch release inside its major + for every family in k14 to k24, Next 16.4.1 and React 19.3.1 among them. + Waiting on answer 4: TypeScript 7, vitest 5, ESLint 10, jsdom 29, jest-dom + 7, better-sqlite3 13 and its types 9, dockview 8, feed 6, `@types/node` 26 + (CI runs Node 20), and three.js 0.186, which `^0.184.0` does not allow. +8. The HUB75 fork. `src/hub75/` is ESP32-HUB75-MatrixPanel-DMA v3.0.13, + vendored on 31 July (`42bfc10`). `VENDORED.md` explains each change and + `UPSTREAM_library.properties` keeps the version; neither names a commit. The + changes go past `blitRGB888` and `resumeDMAoutput`: the buffer chain, the + 300 Hz frame padding, the OE windows inside upstream's `setBrightnessOE`, + the rounding in `pfBuildSpread`, and the on-time accessors `core_power.h` + reads, which carry no marker. `VENDORED.md`'s "Nothing else is modified" and + the "one addition" comments in `core_display.h` and `core_canvas.h` are out + of date. Upstream is outside this session's reach, so the platform files + have not been compared. `build.sh:189` and `sync_ino_to_src.py:31` clone + upstream's HEAD into `lib/HUB75`, which `platformio.ini` ignores. +9. Constants. `3b63d10` (31 July) moved the lab from 20 detents a turn to the + encoder's 24 (PEC11R-4220F-S0024), and `5b9d1b0` (v3.9.4) moved the sandbox. + The MIDI feature came later and still says 20: `console/midi.html:206` + computes its "per turn" figure as `20*g[0]/g[1]`, and `core_midi.h:69` and + `docs/midi-spec.md:119` say it in words. The firmware has no detents-a-turn + constant; its 48 (`PF_LANE_MOTION_SCALE`, `_TEMPLATE.h`) is 24 times + `TURNS_PER_FULL_RANGE`. Copies that agree: the 128×64 panel, four knobs, the + bus maximum of 1000 (also a literal in the frozen `abi/pf_params.h`), ten + bus units a click, four edges a detent, the 1000 ms long press, the 50 ms + debounce. `panelScreens.ts` mirrors twenty timings from `patternflow.ino` + by line number, most now wrong. `build_module.py` hard-codes 128 and 64. + Written for other readers: `docs/rest-api.md:382`, the Ableton bridge's + `DEFAULT_SWEEP = 48`, and the lab's AI prompt in `PatternPanel.tsx`, which + says "0.05 per detent" where the lab's step for a 0 to 1 knob is 0.5/24. +10. Generating. The cache key (`builderRev.ts`) hashes `abi/*.h`, three + toolchain files and four `src/` headers (`core_color.h`, `core_math.h`, + `core_noise.h`, `core_module_loader.h`). A new `core_shared.h`, included + from any other header, moves no key. `check_boundaries.py` reads + `src/**/*.h` as core, so its macros take a `PF_SHARED_` prefix; a + `PF_MIDI_` name fails rule R3. No workflow but step 2's watches `shared/`. + Images carry `__DATE__` and `__TIME__` (`core_build.h:53`). +11. Markers. `git grep -E "TODO|FIXME"` finds upstream's own `TODO` at + `hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.cpp:126`, a `.gitignore` pattern + and the RFC's sentence. First-party code has none, and had none at seven + commits sampled on `dev` from May to October. The five §2.5 counts are not + in this repository. + +## Defaults this plan takes + +- D1. No behaviour change, except where §2.5 asks for one: the dependency + updates and the upstream fixes. k10 is a fix, reviewed as one (§2.2). Every + other PR proves its output did not move, with `check:sandbox-sync` and + `check:modcache` in `check:ci`, and D5 for the firmware. +- D2. Legacy is kept and named unless the maintainer says it goes (questions + 18, 19). A name says what wrote the data or posted the link, and when: + "written by the lab's autosave from 25 to 27 July 2026", not "v1". Code with + no reader goes. +- D3. A lint exception gives its reason on its own line, as `LabWindow.tsx` + does. `eslint.config.mjs` is not touched. +- D4. A dependency family is packages that release together or wrap one + another; a package with none is its own. Each PR takes what `npm outdated` + shows inside the current major on the day it opens, a 0.x minor counting as + a major. Caret ranges move in the lockfile (`npm update `); the pinned + `next`, `eslint-config-next`, `react` and `react-dom` move in `package.json`. +- D5. Two builds differ in their stamp, so k8 proves equality in two commits. + The first puts a `static_assert` beside each literal it will replace, + comparing it with the generated macro, and CI builds all five compositions. + The second swaps and drops the asserts, and the sizes `firmware-build.yml` + prints match the first commit's. No hashed header includes `core_shared.h`. +- D6. One generator, `web/scripts/gen-shared.ts` (`npm run gen:shared`), + writes both outputs under a GENERATED line. The check regenerates in memory + and compares, and also reads the copies that cannot be generated: the 1000 in + the frozen `abi/pf_params.h` and the 128 and 64 in `build_module.py`, which + is hashed. The copies written for other readers (finding 9) stay by hand. +- D7. `shared/constants.json` holds §2.5's four groups: the three panel + geometries with their sizes and the default; the knob count, detents a turn + and turns per full range; edges a detent, the bus maximum, bus units a + click, the long press and the debounce. The other timings wait (question 23). +- D8. Generated text is outside the 300-line count when the PR proves it + regenerates: `patternflow.patch`, the lockfile, `pattern-sandbox.html`, + `midi_index.h`. The step 1 plan's question 8 asks the same for moved text. +- D9. Branches, labels and descriptions as in the step 1 plan's D4. + +## The PRs in detail + +A PR in `web/` runs `typecheck`, `lint`, `check:ci`, and `check:panels` when +it touches `web/src/` (§2.1). A PR in `firmware/` takes `build.sh all` from +`firmware-build.yml`, since the container cannot reach PlatformIO (baseline). + +k1 to k3. k1 drops the unused `normalizePerformance` and `useMediaQuery` +imports, `REASON_LABEL` and `zoomTo`, and wraps `clampPan` as finding 3 says. +k2 puts `eslint-disable-next-line @next/next/no-img-element -- ` above +each ``: the build photos and the hero poster are fixed files shown as +they are, and the lightbox shows whichever journal image was clicked. k3 runs +the Lint step as `npm run lint -- --max-warnings=0`, with its comment to match, +and proves it exits 1 on a throwaway unused variable. + +k4 to k6. A comment on each thing findings 4 to 6 list, giving its writer or +its link and the date (`git log -S` finds both). k4 also corrects `persist.ts`'s +header, renames `legacyGallery` to `gallery` with the key string unchanged, +removes `saveDraft`, and puts the repeated migration in its description, not +its code (question 18). k5 changes "3.8.0" to "3.7.0" and removes nothing; +`doc-map.ts` reads redirect sources as routes, so the map must not change. k6 +says the `"bin"` route answers 410 and drops `buildRunner.ts`; the schema's +default stays, since changing it is a migration. + +k7. The JSON (D7), the generator and the check (D6), the two outputs, and +`gen:shared` and `check:shared` (`tsx scripts/drift.ts shared`) in +`package.json`. Nothing includes or imports the outputs yet. Proof: +`gen:shared` twice gives identical files, and `check:shared` fails on a hand +edit to either output and on a changed literal in `abi/pf_params.h`. + +k8. `config.h` takes `PANEL_GEOM_*` and their sizes (its `#ifndef` overrides +stay), `net_config.h` writes `PF_LANE_MOTION_SCALE` as detents times turns +(still overridable), `core_encoders.h` takes its edges, debounce and long +press, `core_bus.h` `PF_BUS_MAX` and the `/ 10`, `patternflow.ino` +`MODE_HOLD_MS`. Proof as D5. Every use of `PANEL_RES_W` and `PANEL_RES_H` +stays; step 5 counts them. + +k9. The five files take their values from `@/lib/shared/constants` and keep +every name they export (`docs/rest-api.md:382` names `ENCODER_CLICKS_PER_TURN`). +`panelScreens.ts` cites firmware constants by name, not line. If +`check:sandbox-sync` reports a change, rebuild and show the diff is only the +constant's new home. + +k10. `midi.html` counts 24, `console_pages.py build` regenerates +`midi_index.h`, and `core_midi.h` and `midi-spec.md` say 24 (so 1 is 24 steps +a turn and 4 is 6). `outDiv` and `outMul` keep their ranges and defaults, and +the `midi-v0.1.0` try-out stays frozen. Runs `console_pages.py check` and +`check_boundaries.py`. + +k11. With upstream checked out read-only at tag 3.0.13 (question 20), +`UPSTREAM.md` names the repository, the tag and its commit, the date of the +copy, the upstream files left out or renamed, upstream's own `TODO`, and the +command that rebuilds the patch. `patternflow.patch` is `diff -ruN` of +upstream's `src/` against this folder, over the files both carry. +`VENDORED.md`'s "What this costs" and the two core comments point at the patch +instead of counting additions. The PR lists, oldest first, the upstream commits +after 3.0.13 that touch no hunk of the patch. Proof: the patch applied to a +clean upstream checkout reproduces the folder, `diff -r` empty. + +k12. One PR per commit on that list, in its order: the change, the commit +added to `UPSTREAM.md`, the patch rebuilt. `check_blit.py` and `check_oe.py` +(they lift code out of the driver) and `firmware-build.yml` green, and a +flash-and-look on the reference panel the maintainer notes in the PR (§2.5). + +k13. `markers.ts` fails on a `TODO` or `FIXME` in tracked files under +`firmware/`, `web/src/`, `web/scripts/`, `tools/` and `integrations/`. It skips +itself and the vendored `hub75/`, `webserver/` and `pubsubclient/`, the three +`doc-map.ts` already skips. It passes at HEAD. Upstream's marker stays: editing +it would add a hunk to the patch. + +k14 to k24. One family each (D4), in the table's order: the smallest first, +Next last, because `eslint-config-next` moves with it and can bring warnings. +Each quotes `npm outdated` for its family before and after, runs +`npm run build` beside the four commands, and shows `pattern-sandbox.html` +unchanged. A family with nothing outdated on its day closes with nothing done. + +k25 and k26. `AGENTS.md` gets `shared/` in its repository map, the fork's two +files beside the vendored driver, `npm run gen:shared` under "Common commands", +and the lint gate; the PR runs `check_versions.py`, which parses `AGENTS.md`. +k26 gives `docs/REPOSITORY.md` a top-level row for `shared/`, points +`web/ARCHITECTURE.md` at the constants' source (line 59) and k4's key name +(line 80), and points `firmware/README.md`'s vendored-driver note (lines 158 to +162) at `UPSTREAM.md`. + +k27. After every other k: regenerate both CSVs, check §2.5's "done when" (lint +and typecheck clean, every legacy path named or gone, `UPSTREAM.md` and the +patch, `check:shared` in CI), and write step 3 into +`docs/rfc-cleanup-progress.md` the way step 0 is written. + +## Questions for the maintainer + +Numbered on from the step 2 plan's fifteen. Each has the default the PRs use +until there is an answer. + +16. The five markers §2.5 counts are not here (finding 11). Where were they? + Default: k13 holds first-party code at none. +17. Is a lint exception with its reason "relaxing the rule"? Default: no (D3). +18. May the v1 draft and ramp migration go? It serves browsers that opened the + lab between 25 and 27 July. Default: kept and named, and the repeated + migration (finding 4) gets a fix PR of its own. +19. `/business` and the two `?lang=en` redirects: keep or remove? Default: + keep and name. +20. k11 and k12 read `mrcodetastic/ESP32-HUB75-MatrixPanel-DMA`, which a + session scoped to the fork cannot. Default: the hub attaches it read-only. +21. Remove the two clones of upstream's HEAD the build ignores (finding 8)? + Default: not in this step; `firmware/README.md` says they satisfy headers. +22. k10 changes what the MIDI page shows. In this window, reviewed as a fix? + Default: yes. +23. Should `shared/constants.json` also carry the twenty timings + `panelScreens.ts` mirrors? Default: no, only §2.5's four groups (D7). +24. The lab's AI prompt says "0.05 per detent" (finding 9). Default: a fix + after the cleanup, outside this table. +25. Generated text outside the 300-line count (D8)? Default: yes. diff --git a/docs/investigations/2026-10-cleanup-step4-plan.md b/docs/investigations/2026-10-cleanup-step4-plan.md new file mode 100644 index 00000000..f03c4f83 --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step4-plan.md @@ -0,0 +1,295 @@ +# Cleanup step 4: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.6. Scouted on 14 October 2026 at +`e3b977f` on `dev`, after the step 3 plan and `docs/voice.md` merged and before +step 1 closed. Nothing here is implemented.* + +§2.6 gives step 4 two jobs. Five contracts each get a folder of vectors and a +script that replays them, and a virtual panel speaks all five so the scripts +have a device to replay against. Answer 3 of §2.13 puts REST first. Two things +in the tree change how the RFC reads: the harness it names +(`web/src/lib/harness/`) is not a folder, and the status field a vector would +read for most knob inputs does not move for them (findings 2 and 4). This plan +cuts the step into 24 PRs. + +## The PRs + +| k | what | files it may edit | merges after | §2.13 | +| --- | --- | --- | --- | --- | +| 1 | `DeviceSim` gives a socket what it reads and writes | `web/src/lib/guide/deviceSim.ts`, `web/src/lib/guide/deviceSim.test.ts` | — | — | +| 2 | the virtual panel: process, clock, frame, fixture routes, the seam; every npm name | `tools/virtual-panel/README.md`, `tools/virtual-panel/tsconfig.json`, `tools/virtual-panel/main.ts`, `tools/virtual-panel/panel.ts`, `tools/virtual-panel/http.ts`, `tools/virtual-panel/png.ts`, `web/package.json` | — | — | +| 3 | the vector format and the replay library | `docs/contract-vectors.md`, `web/scripts/drift/_vectors.ts` | 2 | — | +| 4 | REST: device state | `tools/virtual-panel/transports/rest-state.ts` | 1, 2 | 3 | +| 5 | REST: patterns | `tools/virtual-panel/transports/rest-patterns.ts` | 2 | 3 | +| 6 | REST vectors: status, params, sleep; `check:rest-vectors` | `docs/rest-api-vectors/README.md`, `docs/rest-api-vectors/state-*.json`, `web/scripts/drift/rest-vectors.ts` | 3, 4 | 3 | +| 7 | REST vectors: the rest of the core, and what a build without a feature does not serve | `docs/rest-api-vectors/device-*.json`, `docs/rest-api-vectors/patterns-*.json`, `docs/rest-api-vectors/absent-*.json` | 5, 6 | 3 | +| 8 | `check:virtual-panel`: every preset runs | `web/scripts/drift/virtual-panel.ts` | 5 | — | +| 9 | MQTT: the client, topics, roles, channels | `tools/virtual-panel/transports/mqtt.ts` | 1, 2 | — | +| 10 | MQTT: `/api/mqtt` and its status fields | `tools/virtual-panel/transports/mqtt-http.ts` | 9 | — | +| 11 | MQTT vectors: topics and roles; the test broker; `check:mqtt-vectors` | `docs/mqtt-vectors/README.md`, `docs/mqtt-vectors/topics-*.json`, `web/scripts/drift/_broker.ts`, `web/scripts/drift/mqtt-vectors.ts` | 3, 4, 10 | — | +| 12 | MQTT vectors: channels, the snapshot, `/api/mqtt` | `docs/mqtt-vectors/channels-*.json`, `docs/rest-api-vectors/mqtt-*.json` | 6, 11 | — | +| 13 | OSC | `tools/virtual-panel/transports/osc.ts` | 1, 2 | — | +| 14 | OSC vectors; `check:osc-vectors` | `docs/osc-vectors/`, `web/scripts/drift/_osc.ts`, `web/scripts/drift/osc-vectors.ts` | 3, 4, 13 | — | +| 15 | MIDI: the RTP-MIDI session and what comes in | `tools/virtual-panel/transports/midi.ts` | 1, 2 | — | +| 16 | MIDI: what goes out, `/api/midi` | `tools/virtual-panel/transports/midi-out.ts` | 15 | — | +| 17 | MIDI vectors in; the test initiator; `check:midi-vectors` | `docs/midi-vectors/README.md`, `docs/midi-vectors/in-*.json`, `web/scripts/drift/_rtpmidi.ts`, `web/scripts/drift/midi-vectors.ts` | 3, 4, 15 | — | +| 18 | MIDI vectors out, `/api/midi` | `docs/midi-vectors/out-*.json`, `docs/rest-api-vectors/midi-*.json` | 6, 16, 17 | — | +| 19 | the audio WebSocket and `/api/audio` | `tools/virtual-panel/transports/audio-ws.ts` | 1, 2 | — | +| 20 | audio vectors; `check:audio-vectors` | `docs/audio-ws-vectors/`, `docs/rest-api-vectors/audio-*.json`, `web/scripts/drift/_ws.ts`, `web/scripts/drift/audio-vectors.ts` | 3, 4, 6, 19 | — | +| 21 | `AGENTS.md`: the virtual panel, the vector folders, the commands | `AGENTS.md` | 1 to 20 | — | +| 22 | the docs that map the tree | `docs/README.md`, `docs/REPOSITORY.md`, `CONTRIBUTING.md`, `tools/README.md`, `tools/virtual-panel/README.md`, `web/ARCHITECTURE.md`, `web/README.md` | 1 to 20 | — | +| 23 | spec lines the vectors could not follow, once ruled on | `docs/rest-api.md`, `docs/midi-spec.md`, and each vector that quotes a line it changes | 6, 7, 11, 12, 14, 17, 18, 20 | — | +| 24 | close step 4 | `docs/rfc-cleanup-progress.md`, `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv` | every other k | — | + +Step 4 opens when step 3 is closed (§2.12). Step 2's runner (its k1) runs every +file in `web/scripts/drift/` from `check:ci`, and its workflow (k10) runs them +strict on every pull request, so a suite reaches CI by adding a file and no PR +edits `check:ci` or `.github/`. k2 adds every npm name the step needs to +`web/package.json`. Prose follows [voice.md](../voice.md) and goes through step +2's voice lint (its k9, `web/scripts/voice-lint.ts`); no PR edits text step 2's +lock (its k8, `web/scripts/human-lock.ts`) covers. Neither script is in the +tree today, and this plan does not write them again. + +A file sits in one row, with two exceptions that run in order. +`tools/virtual-panel/README.md` belongs to k2, then to k22. A vector that +quotes a line k23 corrects is edited by k23, which opens after every vector PR. +Rows that can be open at the same time share no file. Start with k1 and k2, +which wait on nothing; after k2, k3 and k5; after both, k4, k9, k13, k15 and +k19, inside §2.11's limit of five. A contract's vectors can be drafted beside +its transport and merge after it. Only k24 commits the doc-map CSVs: every +other PR runs `npm run doc-map` after `git fetch --tags`, quotes its two +summary lines and checks both CSVs out again. `AGENTS.md` gets a PR of its own +(k21). No PR edits `CHANGELOG.md`; each one's line rides in its description. + +## What the tree says + +1. The model. `docs/pfst-v2-vectors/` holds three real `.pfs` files. What they + should give is printed in `pfst-v2-spec.md` §5, and `check:pfstvectors` + runs an executable model of the spec's player rule over them, not the + firmware. Nothing replays the other five contracts. +2. The harness. `web/src/lib/harness/` does not exist. Patterns run in + `PatternRuntime` (`web/src/lib/pattern/harness.ts`), plain TypeScript with + no DOM. Beside it, `DeviceSim` (`web/src/lib/guide/deviceSim.ts`) already + plays the firmware rules the contracts lean on, for the guide's 3D board: + the bus (ten units a click, a 250 ms grace), the lanes (500 ms release, a + five-second hand hold), sleep, select, the Basics order. It keeps bus and + lane values private, and its one way to move a knob is `turn`, a hand. + `console_demo.py` (the guide's `pf-demo.js`) and `console_serve.py` also + answer HTTP; neither speaks UDP, and the second disagrees with the spec. +3. Ports and packages. HTTP on 80, the audio WebSocket on 81, OSC in on 9001 + with every reply sent to 9000 (`osc_config.h:38,41`), RTP-MIDI on 5004 and + 5005; for MQTT the panel is a broker's client. Linux keeps ports below 1024 + from an unprivileged process, and Actions runners are not root. The + lockfile has `ws` only as something two packages pull in, and no MQTT, OSC + or PNG package. Node 20, which CI runs, has no WebSocket server. +4. What a client can read. `knobs` in `/api/status` is the physical click count + (`patternflow.ino:1214`), so an OSC delta, MIDI CC 24 to 27, an MQTT + `knob/N` or an audio `d=` never shows there. `params`, `paramActive`, + `lanes`, `laneActive`, `active` and `sleep` do. A held MIDI note, an MQTT + `message` and everything the panel sends are seen only on the wire or on + the panel. `tools/rtpmidi-probe` asserts nothing, and its CC 24 step waits + for `knobs` to move. +5. REST against itself. `rest-api.md` says HTTP cannot write knobs (lines 596, + 604) and documents `POST /api/params` (160). It sends knob reads to + `/api/mqtt` (595) and to `/api/status` (321). It calls MIDI the only + absolute transport (604). Select answers with the state before the switch + (38), and its example with the pick (259). A `pN` is required (165) and + `dN` alone is allowed (178). Every error has a JSON body (610) and the 404 + a plain one (21). The clock is "Performance edition" (491) and "Utility + edition" (620). The caps line (110) leaves out `audio-in`, `clock` and + `midi`. +6. REST against the firmware. Undocumented: `rN=1` releases a channel, a `dN` + past ±100 is a 400, `/api/patterns/file` and `/api/knobs` have replies the + spec does not list, and status carries `variantVersion`, `hostAlias`, + `txDbm` and `nvs`. `/api/display` clamps neither `screen` nor `level`. +7. The other four against the firmware. OSC rounds a float half away from zero, + and an unknown address still teaches the panel its host. MIDI reads CC 24 + to 27 as binary offset (`core_midi.h:287`), as `midi-spec.md` line 57 says + and line 40 does not; CC 120, 121 and 123 clear held notes. An empty MQTT + `param/N` does not free a channel set in the last 250 ms + (`core_bus.h:107`), and any `knob/N` drops the hold and moves 16 at most. The + audio socket reads `a=-0.5` as a skipped slot and puts 0.5 on the next + lane, takes `off=x` as `off=0`, and frees lanes when the last client + leaves, not when a socket closes. Every delta starts the five-second hold + (`patternflow.ino:1333`), where `audio-ws-spec.md` line 47 says a physical + turn. MIDI over USB needs the S3's OTG controller, and a Node process has no + USB device side; `midi_test.cpp` (CI) checks that receive map natively. +8. Presets. `firmware/patternflow/presets/` has 35 headers: 34 with a JS twin, + and `calib`, firmware-only by `FIRMWARE_ONLY` in `check_presets.py`. + `DeviceSim` with the Basics pack runs the same 34. `basics.zip` carries each + module's sidecar, `absoluteReady` and knob names included. + +## Defaults this plan takes + +- D1. The virtual panel is `DeviceSim` behind sockets. A rule `DeviceSim` has + is not written again; what it lacks, k1 adds without changing what the guide + shows. Numbers come from `web/src/lib/shared/constants.ts` (step 3, k7), and + width and height from one place, so §2.7's parameterised harness changes one + line here. +- D2. No new dependency. Each protocol is the subset its spec names, on Node's + own `http`, `dgram`, `net`, `crypto` and `zlib`. The lockfile does not move. +- D3. Ports. Defaults are the firmware's numbers, so the browser extension or + an rtpMIDI driver can point at a virtual panel. Each takes an override, and + the suites pass 0 for any free port, so they run without root. +- D4. Time. `npm run virtual-panel` runs in real time. A suite starts the panel + on a manual clock and calls `advance(ms)`, which lets the sockets drain + first, so the 500 ms release and the eight-second snapshot take no waiting. +- D5. One seam, fixed by k2. A transport is a file in + `tools/virtual-panel/transports/`, attached when the caller asks for its + feature (REST always). It gets the `DeviceSim`, the clock, the HTTP routes, a + hook into `/api/status`, its cap, its ports, and events for a hand turn, a + button, a pattern change and sleep. A transport that needs more says so + under "for another k"; none edits `panel.ts`. +- D6. Fixture routes. `GET /frame.png` as §2.6 says, and under `/vp/` what no + contract reaches: a hand, the clock, and what the pattern is given and shows + (knob deltas, buttons, the banner, a thrown error). The firmware has none. +- D7. The vector format, fixed by k3 in `docs/contract-vectors.md`. One JSON + file per vector: `contract` (spec and anchor), `says` (the sentence it comes + from), `features`, and `steps`, each a `send` (`http`, `osc`, `midi`, + `mqtt`, `ws`, `hand`, `wait`) and an `expect`: `reply` (status, headers, + body), `status` (fields of `/api/status`), `out` (what the panel sent, and + how soon) or `input` (D6). A shape is a literal, a type such as + `""`, or an object naming the keys it needs; other keys pass + (`rest-api.md` line 5), and `""` forbids one. A vector whose `says` + is gone from its spec fails, Markdown and whitespace aside, so a spec edit + brings its vectors along. +- D8. Spec, not firmware. Vectors and the virtual panel follow the spec. Where + the spec contradicts itself (finding 5), the vector follows the section + that defines the route or message, and the PR names the other line for k23. + Where the firmware disagrees (findings 6 and 7), the vector keeps the spec. + Where the spec says nothing (`rN`, how a float rounds), there is no vector. +- D9. Issues. Each vector PR lists the firmware disagreements it encodes under + "for an issue": the vector, the spec line, the firmware line (question 26). +- D10. The suites are drift checks; a file starting with `_` is a library. A + suite starts a fresh panel per vector, talks to it only through its ports, + and prints both sides through `compare` in `_lib.ts`. Once k3 merges, + `_vectors.ts` is frozen, and a suite keeps its sender in its own `_` file. +- D11. Not replayed: Wi-Fi, the hotspot, firmware update, install, delete and + format (no volume), the microphone, the clock feature, shows, weather, MIDI + over USB. Each vector folder's README says so. +- D12. Branches, labels and descriptions as in the step 1 plan's D4. Vector JSON + is written by hand and counts toward the 300 lines. `typecheck` and `lint` + read `web/` only; k2 adds `tools/virtual-panel/` to `typecheck`, not `lint`. + +## The PRs in detail + +k1 touches `web/src/`, so it runs `typecheck`, `lint`, `check:ci` and +`check:panels` (§2.1). Every other PR runs `typecheck` and its own check, and +quotes one failing run made on a throwaway edit, reverted before commit. No PR +touches `firmware/`. + +k1. `DeviceSim` gains reads of the bus values and holds, the lane values and +the click counts; a knob delta that is not a hand, which takes no lane and +frees no channel (the specs give that to a hand, `mqtt-spec.md` line 38, +`midi-spec.md` line 40); the release of one channel (an empty `param/N`) and +of one lane (`off=N`); and a lane source for the WebSocket. Proof: +`deviceSim.test.ts` passes as it was, a new case covers each addition, and +`check:sandbox-sync` shows the sandbox unchanged. + +k2. `panel.ts` holds `start(options)`: the features (none is the default +build), the pack (Basics), a port per transport, the clock. It returns the +bound ports, `advance(ms)` and `close()`; `main.ts` takes the same as flags. +`http.ts` serves one request at a time (`rest-api.md` line 18), reads the query +and a form body only (26), and gives anything unrouted the plain-text 404 with +`no-store` (21). `tsconfig.json` extends `web/tsconfig.json`, `@/` mapped. +In `web/package.json`: `virtual-panel` (`tsx ../tools/virtual-panel/main.ts`), +`tsc --noEmit -p ../tools/virtual-panel` in `typecheck`, and +`check:rest-vectors`, `check:mqtt-vectors`, `check:osc-vectors`, +`check:midi-vectors`, `check:audio-vectors` and `check:virtual-panel`, each +`tsx scripts/drift.ts `, which fails and says why until its file lands. +Proof: `/frame.png` shows Origin, and a `/vp/turn` changes the next one. + +k3. `docs/contract-vectors.md` is D7 with one example per transport. +`_vectors.ts` reads a folder, starts a panel per vector, runs its steps with +its own senders (`http`, `hand`, `wait`) and those a suite passes in, matches +shapes, checks `says`, and prints both sides. + +k4 and k5. `rest-state.ts`: `GET /api/status` with every field the table +documents (`rest-api.md` lines 72 to 112), caps from its features, and what +transports add; `/api/params`, `/api/sleep`, `/api/display`, `/api/knobs`, +`DELETE /api/crash` (its 404) and the favicon. `rest-patterns.ts`: +`GET /api/patterns` (Origin as the preset, the rest as modules), select by +`index`, `name` and `step` with its 404 and CORS, and `/api/patterns/file` for +`ext=json` (the sidecars in `basics.zip`, read as `console_demo.py` reads +them) and `ext=thumb` (`PFT1`, the last frame in RGB565). + +k6 and k7. k6: `/api/status` (the documented keys, by type), `/api/params` (a +subset, the reply, a hold a hand takes back), `/api/sleep` (each spelling of +`on`, the 400 and its text, the state before the switch), the README and +`rest-vectors.ts`. k7: display, knobs, crash, the favicon, the 404, +`no-store`, the pattern routes, and `absent-*`: on the default build +`/api/mqtt`, `/api/midi` and `/api/audio` answer 404 and `caps` is +`["patterns","params","sleep"]` (lines 110, 613). + +k8. `virtual-panel.ts` lists the headers in `firmware/patternflow/presets/`, +drops `FIRMWARE_ONLY` as `check_presets.py` names it, and needs the rest on the +panel's list. It selects each over REST, advances two seconds, reads a 128×64 +PNG, and fails on a thrown error `/vp/state` reports. + +k9 to k12. `mqtt.ts` is an MQTT 3.1.1 client at QoS 0 with retain, keepalive +and no wildcards (`mqtt-spec.md` line 12), the roles and channels (13, 14), +the topics (22 to 28), and the eight-second snapshot. `mqtt-http.ts` is +`/api/mqtt` (`rest-api.md` lines 343 to 367) and the `mqtt` fields in status. +`_broker.ts` is enough of a broker to connect, subscribe, publish and retain. +k11: the topics in each role, the words `sleep` takes, `param/N` and its empty +payload, `knob/N` as a difference, `pattern` by name or slug. k12: channels 1 +to 4 forcing Subscriber, Broadcast taking no snapshot, a snapshot's `param` +overwriting a write, and `/api/mqtt`. + +k13 and k14. `osc.ts` listens on 9001 and replies to the sender's address on +9000: `i` and `f` arguments, 256 bytes, no `#bundle`, eight datagrams a frame, +the announce burst, the heartbeat, a hand's `delta` and `clicks`, buttons and +pattern changes. `_osc.ts` packs and reads OSC. The vectors: a ping and its +burst, a delta as an int and as a float any rounding settles (2.6), a pattern +index, `content/toggle` doing nothing, an unknown address, a `#bundle`. + +k15 to k18. `midi.ts` is the session listener on 5004 and 5005 (invitation, +clock sync, `BY`), RTP-MIDI's command list, the channel rule and the receive +table (`midi-spec.md` lines 39 to 42). `midi-out.ts` is the send table (57 to +60), with `outDiv`, `outMul` and the 50 ms pacing, plus `/api/midi` and `midi` +in status. `_rtpmidi.ts` is an initiator shaped like `rtpmidi_probe.py`. In: +CC 20 at 0, 1, 64 and 127 (0, 8, 504, 1000), CC 24 at 65 and 63, where lines +40 and 57 agree, notes 60 to 63, Program Change, another channel. Out: a +hand's turn in `abs` and `rel`, the pacing, Program Change, no echo. + +k19 and k20. `audio-ws.ts` is a WebSocket server on 81, the grammar in +`audio-ws-spec.md` lines 27 to 31, the 500 ms release, `audioRuntime` and +`/api/audio`; `_ws.ts` a client for Node 20. The vectors: `a=` with `-` slots, +`k=` clamped, `d=`, `off=N` and `off`, an unknown prefix, a lane let go 500 ms +after its last message and kept while it is resent, a hand taking a lane for +five seconds, and messages ignored while `audioRuntime` is off. + +k21 and k22. `AGENTS.md` maps `tools/virtual-panel` and a vector folder beside +each contract (adding the missing `mqtt-spec.md`) and lists the new commands; +it runs `check_versions.py`, which parses the file. k22: `docs/README.md` +names each contract's vectors as it names pfst's, and the format; +`docs/REPOSITORY.md` and `tools/README.md` name the virtual panel, the one +thing in `tools/` that is not a client, and `tools/virtual-panel/README.md` +lists its transports; `CONTRIBUTING.md`'s wire-contract row says a spec change +moves its vectors; `web/ARCHITECTURE.md` (line 121) and `web/README.md` name +the suites. + +k23. Only for the lines the maintainer rules on (question 27): it corrects the +losing line in `rest-api.md` or `midi-spec.md`, adds a version-history row, and +re-quotes each vector that quoted it. A docs fix, reviewed as one. + +k24. After every other k: regenerate both CSVs, check §2.6's "done when" (five +suites in CI against the virtual panel, every preset running) and §2.12's +third item (REST and MQTT, the full preset list), and write step 4 into +`docs/rfc-cleanup-progress.md` the way step 0 is written. + +## Questions for the maintainer + +Numbered on from the step 3 plan's 25, each with the default the PRs use. + +26. Who opens the issues the vector PRs list (D9)? A session scoped to the fork + cannot write upstream. Default: you, from each PR's list. +27. Where the spec contradicts itself, does the route's own section win (D8)? + Select is the sharpest case: line 38 says the old state, and the example + and the firmware give the new pick. Default: yes, and k23 fixes the other. +28. The five-second hold: the spec gives it to a hand, the firmware to any + delta (finding 7). An integration tested against the virtual panel meets a + different panel there. Default: an issue, and the spec stands. +29. The suites reach the virtual panel only through its ports, so a + `--panel
` could walk a real device too, except for vectors that + use `/vp/` or the manual clock. Default: not in this step. diff --git a/docs/investigations/2026-10-cleanup-step5-plan.md b/docs/investigations/2026-10-cleanup-step5-plan.md new file mode 100644 index 00000000..eb5e5358 --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step5-plan.md @@ -0,0 +1,289 @@ +# Cleanup step 5: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.7. Scouted on 15 October 2026 at +`d4668ad` on `dev`, after the step 4 plan merged and while step 1 was closing. +Nothing here is implemented.* + +§2.7 gives step 5 no code. It asks for two RFC drafts, Part 2 only: +`docs/rfc-any-resolution.md`, the firmware path, and `docs/rfc-linux-player.md`, +the player path. Each ends with questions for the maintainer, and §2.12 counts +both toward the gate (its item 6). Step 5 is also the one step that runs beside +others: it may start once step 2 is merged, while steps 3 and 4 are still open. +Two things in the tree shape the drafts. The panel size is set or assumed in far +more places than the 36 §2.7 counts (finding 1). And the site already runs a +pattern at any size, while the device and the site's simulator of it do not +(findings 2 and 6). This plan cuts the step into four PRs. + +## The PRs + +| k | what | files it may edit | merges after | §2.13 | +| --- | --- | --- | --- | --- | +| 1 | where the panel size lives, as a record | `docs/investigations/2026-10-panel-geometry.md` | — | — | +| 2 | the any-resolution draft | `docs/rfc-any-resolution.md` | 1 | 3, 5 | +| 3 | the Linux player draft | `docs/rfc-linux-player.md` | 1 | 3, 5 | +| 4 | close step 5 | `docs/rfc-cleanup-progress.md`, `docs/README.md`, `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv`; in `docs/rfc-any-resolution.md` and `docs/rfc-linux-player.md`, the one line naming the other draft | 1, 2, 3; and step 3's k27 and step 4's k22 and k24 if one is open | — | + +Step 5 opens when step 2 is closed (§2.12), so its PRs can be open beside step +3's and step 4's. k1, k2 and k3 each make a new file and share none with any PR +of any step. k1 waits on nothing. k2 and k3 can be written beside it and merge +after it, because both link its record. Three people can work at once, one per +row, inside §2.11's limit of five open cleanup PRs, counted across steps. + +The shared places belong to k4: `docs/README.md`, the progress log and the two +doc-map CSVs. Step 3's k27 and step 4's k22 and k24 edit the same files, so k4 +opens only when none of them is open. Until k4, each draft names the other in +backticks, and k4 turns both mentions into links. That line in each draft is the +one exception to a file sitting in one row, and it runs in order. Every other PR +runs `npm run doc-map` after `git fetch --tags`, quotes its two summary lines +and checks both CSVs out again. + +No PR here edits `AGENTS.md`, `CHANGELOG.md`, `.github/` or `web/package.json`. +A draft changes no command, no path an agent works in and no workflow, and +`docs-links.yml` and step 2's checks already read a new file under `docs/`. A PR +that finds it needs one of them writes the change under "for another k", and the +change becomes a PR of its own after k4. Each PR's changelog line rides in its +description. Prose follows [voice.md](../voice.md) and goes through step 2's +voice lint (its k9, `web/scripts/voice-lint.ts`). Part 1 of every RFC is text +step 2's lock covers (its k8, `web/scripts/human-lock.ts`), and no PR here +writes any. Neither script is in the tree today, and this plan does not write +them again. + +## What the tree says + +1. The count. §2.7's 25, four and seven count occurrences of `PANEL_RES_W` and + `PANEL_RES_H`, not lines. Under `src/` the 25 sit on 21 lines in six files, + and eight of them are in comments (`core_display.h:18`, `core_mem.h:17`, + four in `core_tables.h`). In `features/` the four are the clock's JSON and + the weather preset. In `abi/` two of the seven are comments. The count leaves + out where the size is set (`config.h`, 11), the presets (142 across all 35 + headers, with arrays sized by it in `preset_0629_2.h` and `preset_calib.h`), + `PFCanvas::W` and `H` (`core_canvas.h:38`, aliased again in + `core_thumbs.h:44`) and the 24 KB canvas buffer they size + (`core_canvas.h:40`). It also leaves out the menus, which ask the driver + (`dma_display->width()`, `core_ui_text.h`), and four sizes typed as numbers: + `core_ui_text.h:16`, `preset_weather.h:491`, `console/clock.html:414` and + `preset_0712_2.h:60`. The module toolchain types 128 and 64 too + (`build_module.py:51`). +2. Geometry today is chosen at compile time. `config.h:81-108` offers three: + one 128×64 module (the default), one 64×64, and two 64×64 chained into + 128×64, which `platformio.ini` builds as `firmware64x2`. Each sets the + module size, the chain and the canvas, and two `#error`s hold the canvas to + module width times chain. The driver IC is a separate line, `PANEL_PROFILE` + (`config.h:168`). One call hands the geometry to the driver + (`core_display.h:20`). A pattern already declares its own grid, with + `setFrame` on the device and `// @matrix` on the site, and both put it on the + panel directly, rotated or letterboxed by the same rule (`matrix.ts:127` + says to keep the two in step). The panel is what no setting changes without a + rebuild. +3. Modules. A `.pfm` is ESP32-S3 machine code built for one size. + `pf_module.h:61-72` stops a build that lacks `-DPF_PANEL_W` and `H`, and the + host and the module each refuse the other at a different size + (`pf_module.h:312`, `core_module_loader.h:1145`). Both ABI structs already + carry `panel_w` and `panel_h` (`pf_abi.h:82` and `117`), and the ABI is + frozen at 1, append only. The cache key is `(source_sha, builder_rev)` + (`web/src/lib/community/server/schema.ts:725`). The size reaches it only + because `builder_rev` hashes `build_module.py`, where 128 and 64 are typed. + The sidecar records `panel_w` and `panel_h`, and the device never reads them. +4. Memory. The driver allocates its row buffers once, at `begin()`, in internal + DMA RAM (`MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA`, + `hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h:195`). `SPIRAM_DMA_BUFFER` is not + set anywhere. A row takes width × colour depth × 2 bytes (`.h:168`), the + depth is the default 8, and the display is double-buffered + (`core_display.h:22`). By that formula 128×64 at 1/32 scan takes 128 KB, a + figure the tree does not state. When a size does not fit, `begin()` fails with + "Not enough memory" (`.cpp:123`). The vendored blit adds limits of its own: + an even width and depth 8 for the fast path, and a timing argument worked out + for 128 words at 16 MHz (`hub75/VENDORED.md:147-188`). +5. Panels. `config.h:153-160` and + [panel-compatibility.md](../panel-compatibility.md) sort driver ICs into three + tiers. Plain shift-register panels and panels that need an init sequence + work. S-PWM panels (ICN2053, FM6353, MBI5051 and the rest) stay dark whatever + is set, because they need a grey-scale clock the library cannot make + (upstream issue 642, closed wontfix). They are built to be fed by a Novastar, + Huidu or Colorlight receiving card, which is one of the outputs §2.7 gives + the Linux player. +6. The site. `PatternRuntime` (`web/src/lib/pattern/harness.ts`) imports + nothing and touches no DOM. It compiles a pattern with `new Function` and + writes an RGBA buffer, and its constructor takes a width and a height, 128 + and 64 by default (lines 1, 2 and 486). `// @matrix` takes any size from 8 to + 512 (`matrix.ts:46`), and the sandbox resizes to it per pattern. 47 of the 48 + JS presets draw from `display.width` and `display.height`, and two of them, + `pattern-0719.ts` and `pattern-0712-2.ts`, also type a size of their own. + Fixed at 128×64: `DeviceSim` (through `panelScreens.ts:42`), the 3D panel's + texture and shader (`LedMatrixTexture.ts:20`, + `components/3d/patterns/common.ts:55`), the guide's preview + (`Extras.tsx:185`) and the AI prompts (`gemini.ts:226`). + `web/src/lib/harness/`, the folder §2.6 names, does not exist (step 4's + finding 2). +7. Steps not yet merged. The any-resolution draft leans on two things that are + only planned: `shared/constants.json`, which will hold the three geometries + (step 3, k7), and the virtual panel, `DeviceSim` behind sockets with its width + and height in one place (step 4, k2 and D1). Step 3's k8 keeps every use of + `PANEL_RES_W` and `PANEL_RES_H` so that step 5 can count them. None of it is + on `dev` today, and step 5 may start before any of it is. +8. Linux. Nothing in the tree runs on Linux, and nothing outside §2.7 names + rpi-rgb-led-matrix or HDMI. Two builds on the site already run Patternflow on + a Raspberry Pi: one with a 64×32 matrix (`InsideGlobe/builds.ts:130`), one a + Pi 4 with a HUB75 HAT and its own firmware (`builds.ts:333`). A `.pfm` loads + only through the device's relocator, and the flasher installs an ESP32-S3 + image with ESP Web Tools (`web/public/flash/manifest.json`), so neither + crosses over, as §2.7 says. Knobs over the web today are the console's four + sliders posting to `/api/params` (`console/home.html:112`). The site's knobs + drive only its own preview, and no page asks for Web MIDI. +9. Contracts. Of the five, only `rest-api.md` states a size: + `"panel": "128x64"` in status (line 48), the thumbnail as a 128×64 frame + (274), the sidecar's `panel_w` and `panel_h` (284) and `/api/clock`'s `w` + and `h` (499). + OSC, MIDI, MQTT and the audio socket never name one. A device at another size + changes REST and nothing else on the wire. +10. Settings. No console page stores a panel setting. The nearest is `/knobs` + (`core_knobs_http.h`), which changes a hardware property without a rebuild + and keeps it in NVS under `pf_knobs` (`core_encoders.h:102`). Brightness and + the power budget are kept the same way. Because the DMA buffers are + allocated at `begin()`, a geometry chosen on a page would take effect at the + next boot. +11. The form. `rfc-cleanup.md` opens with a dated line, a status block and a + paragraph saying Part 1 is the maintainer's and Part 2 the specification. + §2.9 locks Part 1 of every RFC under `docs/`. Step 2's lock passes a new + locked file and fails any later change to it (its D5), so Part 1 written into + a draft after it merges turns `check:human-lock` red. Its finding 10 names + Part 1 of two RFCs. Whether it reads a third depends on k8 listing files or + reading every `docs/rfc-*.md`. + +## Defaults this plan takes + +- D1. The form. Each draft takes `rfc-cleanup.md`'s frame: the title, a dated + line, a status block that says "Draft", the paragraph on Part 1 and Part 2 + copied as written, `# Part 1 — Why` with nothing under it, then + `# Part 2 — The specification` in numbered sections, the last one "Open + questions for the maintainer". The any-resolution status block names §2.12's + gate as what moves it to proposed. The player's names nothing (question 34). + Neither draft goes into `docs/.human-written` (answer 5): §2.9 already locks + Part 1 of every RFC. +- D2. The inventory is a record. k1 lists every place the size is set or + assumed, by file and line, with the SHA and the `git grep` behind each count. + §2.7's three numbers keep rows of their own, so a reader can find 25, four and + seven. The any-resolution draft carries a table per file and links the record + (question 31). +- D3. The tree at the PR's SHA, plans for the rest. Facts come from `dev` when + the PR opens. Where a draft leans on something not yet merged (finding 7), it + names the plan and its k, writes the path in backticks, and says what changes + for the draft if that plan changes. +- D4. No implementation. No code, no prototype branch, no build to measure. A + number the tree does not state, like the DMA bytes for a size, is worked from + the driver's formula with its file and line, and marked as arithmetic. +- D5. Estimates in this cleanup's units. Each proposal section ends with the + files it would touch, how many PRs that is by §2.1's rule (300 lines, one + concern), and whether those files are the core (hard rule 7), the frozen ABI + (append only), a feature or the site. That is the unit the maintainer already + reviews in. +- D6. Words. "Geometry" is `config.h`'s word for module size and chain. "Size" + is the canvas, width × height. The player is a program, not an edition: + edition, feature and core mean what `EDITIONS.md` says they mean. +- D7. Branches, labels and descriptions as in the step 1 plan's D4, with the + label `step-5`. + +## The PRs in detail + +Every PR touches only `docs/`, so the commands in §2.1(1) do not apply. Each runs +`python .github/scripts/check_links.py`, `npm run doc-map` (CSVs checked out +again), `npm run check:voice` on what it adds and `npm run check:human-lock`, +and quotes the output. A person reads each draft before it merges (§2.10). + +k1. `docs/investigations/2026-10-panel-geometry.md`, about 200 lines. A header +gives the SHA and the commands. Then a table per place, with file, line, code or +comment, and what the line does with the size: the 36 occurrences §2.7 counts; +`config.h`; `PFCanvas` and what reads it; the menus; the presets, one row per +file with its count and its arrays; the sizes typed by hand; the module +toolchain and the cache key (finding 3); HTTP and the specs (finding 9); the +driver's allocation and the blit's limits (finding 4); and the site (finding 6). +It proposes nothing. Proof: each count, run again from its quoted command, gives +the same number. + +k2. `docs/rfc-any-resolution.md`, Part 2, under 300 lines, in sections sized by +D5: + +- What changes and what stays: one ESP32-S3 and one chain; the stock 128×64 + build unchanged; `setFrame` and `// @matrix` as they are. +- Where the size lives: the table per file from k1, linked. +- The runtime geometry: a struct of canvas size, module size, chain and driver + profile, filled from `config.h`'s values as compile-time defaults and from NVS + at boot, before `begin()`. Which of the 36 read it instead of the macro, which + stay compile-time and why (the presets' arrays, the macro in the frozen ABI), + and what becomes an allocation (the canvas buffer). +- Modules: one build per size, `build_module.py` taking the size, the cache key + gaining W×H as §2.7 asks, the schema migration that costs and the cheaper way + (question 33), and what the device shows for a module built at another size. +- The console page: a core page, since the panel is the device (hard rule 7), + kept in NVS like `pf_knobs` and applied at the next boot. It offers only + geometries that fit the memory bound, and it says how to get back when a + geometry leaves the panel dark. `/api/status`'s `panel` and the other REST + lines in finding 9. +- The site: `PatternRuntime` already takes a size. What remains is `DeviceSim`, + the 3D panel and the previews (finding 6), with `shared/constants.json` as + their source (step 3, k7). +- The virtual panel at other sizes: its size from step 4's one place, + `/frame.png` at that size, `/api/status` reporting it, REST first (answer 3), + and `check:virtual-panel` run at a second size. +- The limits, stated as limits: classic HUB75 only (tiers 1 and 2), S-PWM + unsupported (finding 5), the memory bound with the formula and a table of the + sizes and depths that fit, and the blit's even width and timing (finding 4). +- The order of the work and its PR count. +- Open questions. + +k3. `docs/rfc-linux-player.md`, Part 2, under 300 lines, in sections sized by +D5: + +- What it is: a Linux process that plays patterns with the site's engine onto a + panel or a screen. Not firmware, and not an edition. +- The engine: `PatternRuntime` under Node (finding 6), the JS presets and the + Basics order, community patterns as JS, and the size taken from the output. +- The outputs, each with what it needs and what it costs. rpi-rgb-led-matrix on + a Pi's GPIO or a HAT, and how a Node process reaches a C++ library (an addon, + a child process or a pipe). A Colorlight-class receiving card over raw + Ethernet, which also lights S-PWM panels and LED walls (finding 5). HDMI. +- The inputs: USB encoders, MIDI (ALSA and RTP-MIDI), OSC on 9001, and the web + knobs as the console has them, sliders posting to `/api/params` (finding 8). +- The contracts: which of the five it speaks and in what order, REST first + (answer 3). What it can share with step 4's virtual panel, which speaks all + five in Node over `DeviceSim`, and what it must not take from it (the `/vp/` + routes, the manual clock). +- What it cannot do: no `.pfm`, no browser flash, and none of the C++ features + (weather, clock, shows, the microphone) unless they are written again. +- Where it lives and how it ships, with the two Pi builds on the site as prior + work (finding 8, question 34). +- The order of the work and its PR count. +- Open questions. + +k4. After k1, k2 and k3, and while none of step 3's k27 or step 4's k22 and +k24 is open: check §2.7's "done when" (both files, every section D5 asks for, +open questions last) and §2.12's sixth item. Turn each draft's backtick mention +of the other into a link. Give `docs/README.md` one line for each draft. +Regenerate both CSVs. Write step 5 into `docs/rfc-cleanup-progress.md` the way +step 0 is written, and mark item 6 met in the gate line. If step 2's lock does +not read the drafts' Part 1 (finding 11), the entry says so, and the fix to +`web/scripts/human-lock.ts` is a PR of its own. + +## Questions for the maintainer + +Numbered on from the step 4 plan's 29. Each has the default the PRs use until +there is an answer. + +30. Part 1 of each draft stays empty until you write it. Written after the draft + merges, it turns `check:human-lock` red (finding 11). Default: you write it + on k2's or k3's branch before merge, which the lock reads as a new file; + otherwise step 2's question 13 applies. +31. The inventory as a record of its own, with the draft carrying a table per + file (D2)? §2.7 puts the inventory in the draft. Default: yes. +32. Which sizes does the any-resolution draft cost the work for? `config.h` has + three geometries, the roadmap names 64×32, 128×64 and a commercial sign, and + the site takes anything from 8 to 512. Default: what one ESP32-S3 can drive + by finding 4's formula. Walls and signs go to the player. +33. §2.7 extends the cache key with W×H. The size already moves `builder_rev` + through `build_module.py`, and a new column in the key is a migration of the + community database. Default: the draft proposes the column, costs the + migration and names the other way beside it. +34. Where does the player live: in this repository, or in its own, as the Home + Assistant integration now does? And does it move to proposed with + any-resolution at the gate? Default: the draft sets out both homes and picks + neither, and it stays a draft until you say. diff --git a/docs/investigations/2026-10-doc-map.csv b/docs/investigations/2026-10-doc-map.csv new file mode 100644 index 00000000..0017a3b3 --- /dev/null +++ b/docs/investigations/2026-10-doc-map.csv @@ -0,0 +1,1145 @@ +from,to,kind,anchor,resolves +.github/CODE_OF_CONDUCT.md,.github/SECURITY.md,relative,,yes +.github/CODE_OF_CONDUCT.md,docs/manifesto.md,relative,6-how-we-treat-each-other,yes +.github/FUNDING.yml,/,site,,yes +.github/ISSUE_TEMPLATE/config.yml,.github/SUPPORT.md,absolute-blob,,yes +.github/ISSUE_TEMPLATE/config.yml,CONTRIBUTING.md,absolute-blob,,yes +.github/ISSUE_TEMPLATE/docs_issue.yml,/build,site,,yes +.github/ISSUE_TEMPLATE/share_build.yml,/inside,site,,yes +.github/ISSUE_TEMPLATE/share_build.yml,CONTRIBUTING.md,absolute-blob,your-build,yes +.github/PULL_REQUEST_TEMPLATE.md,CONTRIBUTING.md,absolute-blob,where-your-contribution-goes,yes +.github/SECURITY.md,/editions,site,,yes +.github/SECURITY.md,/pattern,site,,yes +.github/SECURITY.md,/update,site,,yes +.github/SECURITY.md,docs/rest-api.md,relative,transport,yes +.github/SUPPORT.md,.github/SECURITY.md,relative,,yes +.github/SUPPORT.md,/guide,site,,yes +.github/SUPPORT.md,/guide/play,site,flash,yes +.github/SUPPORT.md,/inside,site,,yes +.github/SUPPORT.md,/pattern,site,,yes +.github/SUPPORT.md,/pattern-lab,site,,yes +.github/SUPPORT.md,BUILD_GUIDE.md,relative,8-firmware,yes +.github/SUPPORT.md,CONTRIBUTING.md,relative,,yes +AGENTS.md,docs/EDITIONS.md,relative,,yes +AGENTS.md,docs/rfc-cleanup.md,relative,,yes +BUILD_GUIDE.md,/,site,,yes +BUILD_GUIDE.md,/editions,site,,yes +BUILD_GUIDE.md,/guide/build,site,,yes +BUILD_GUIDE.md,/guide/play,site,,yes +BUILD_GUIDE.md,/guide/play,site,flash,yes +BUILD_GUIDE.md,/guide/play,site,patterns,yes +BUILD_GUIDE.md,PATTERN_GUIDE.md,relative,,yes +BUILD_GUIDE.md,docs/EDITIONS.md,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/esp32_insert.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/01_printing.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/02_printed_parts.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/03_glue_mount.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/04_seam_gap.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/06_usbc_bad.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/07_usbc_good.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/16_power_path_compare.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/17_screw_terminal_wiring.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/web_flash.jpg,relative,,yes +BUILD_GUIDE.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +BUILD_GUIDE.md,docs/midi-ableton.md,relative,,yes +BUILD_GUIDE.md,docs/panel-compatibility.md,relative,,yes +BUILD_GUIDE.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +BUILD_GUIDE.md,firmware/README.md,relative,,yes +BUILD_GUIDE.md,hardware/bom/README.md,relative,,yes +BUILD_GUIDE.md,hardware/bom/bom_v3.0.csv,relative,,yes +BUILD_GUIDE.md,hardware/bom/bom_v3.9.csv,relative,,yes +BUILD_GUIDE.md,hardware/case,relative,,yes +BUILD_GUIDE.md,hardware/case/bed_256mm/patternflow_v3.3mf,relative,,yes +BUILD_GUIDE.md,hardware/case/remixes/besoiobiy-printed/README.md,relative,,yes +BUILD_GUIDE.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +BUILD_GUIDE.md,integrations/ableton,relative,,yes +CHANGELOG.md,/guide,site,,yes +CHANGELOG.md,/guide/build,site,,yes +CHANGELOG.md,/guide/make,site,,yes +CHANGELOG.md,/guide/play,site,,yes +CHANGELOG.md,docs/releases,relative,,yes +CHANGELOG.md,docs/releases/v1.0.0.md,relative,,yes +CHANGELOG.md,docs/releases/v1.1.0.md,relative,,yes +CHANGELOG.md,docs/releases/v2.0.0.md,relative,,yes +CHANGELOG.md,docs/releases/v2.1.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.0.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.1.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.10.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.10.1.md,relative,,yes +CHANGELOG.md,docs/releases/v3.10.2.md,relative,,yes +CHANGELOG.md,docs/releases/v3.10.3.md,relative,,yes +CHANGELOG.md,docs/releases/v3.2.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.3.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.4.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.4.9.md,relative,,yes +CHANGELOG.md,docs/releases/v3.5.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.5.1.md,relative,,yes +CHANGELOG.md,docs/releases/v3.5.2.md,relative,,yes +CHANGELOG.md,docs/releases/v3.6.3.md,relative,,yes +CHANGELOG.md,docs/releases/v3.7.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.7.1.md,relative,,yes +CHANGELOG.md,docs/releases/v3.8.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.1.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.2.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.3.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.4.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.5.md,relative,,yes +CONTRIBUTING.md,.github/CODE_OF_CONDUCT.md,relative,,yes +CONTRIBUTING.md,.github/SUPPORT.md,relative,,yes +CONTRIBUTING.md,/guide,site,,yes +CONTRIBUTING.md,/inside,site,,yes +CONTRIBUTING.md,/pattern-lab,site,,yes +CONTRIBUTING.md,AGENTS.md,relative,hard-rules-do-not-violate,yes +CONTRIBUTING.md,FEATURE_GUIDE.md,relative,,yes +CONTRIBUTING.md,LICENSE-CC-BY-SA,relative,,yes +CONTRIBUTING.md,LICENSE-MIT,relative,,yes +CONTRIBUTING.md,PATTERN_GUIDE.md,relative,,yes +CONTRIBUTING.md,docs/EDITIONS.md,relative,,yes +CONTRIBUTING.md,docs/LICENSE-SUMMARY.md,relative,,yes +CONTRIBUTING.md,docs/RELEASING.md,relative,,yes +CONTRIBUTING.md,docs/REPOSITORY.md,relative,,yes +CONTRIBUTING.md,docs/audio-ws-spec.md,relative,,yes +CONTRIBUTING.md,docs/contract-vectors.md,relative,,yes +CONTRIBUTING.md,docs/midi-spec.md,relative,,yes +CONTRIBUTING.md,docs/mqtt-spec.md,relative,,yes +CONTRIBUTING.md,docs/osc-spec.md,relative,,yes +CONTRIBUTING.md,docs/pfst-v2-spec.md,relative,,yes +CONTRIBUTING.md,docs/rest-api.md,relative,,yes +CONTRIBUTING.md,docs/rfc-cleanup.md,relative,29-human-written-text-lock,yes +CONTRIBUTING.md,firmware/bundles/README.md,relative,,yes +CONTRIBUTING.md,firmware/patternflow/features/README.md,relative,,yes +CONTRIBUTING.md,tools/virtual-panel/README.md,relative,,yes +CONTRIBUTING.md,web/ARCHITECTURE.md,relative,,yes +CONTRIBUTING.md,web/README.md,relative,,yes +CONTRIBUTING.md,web/src/components/sections/InsideGlobe/builds.ts,relative,,yes +FEATURE_GUIDE.md,.github/workflows/console-sync.yml,relative,,yes +FEATURE_GUIDE.md,.github/workflows/firmware-build.yml,relative,,yes +FEATURE_GUIDE.md,.github/workflows/firmware-checks.yml,relative,,yes +FEATURE_GUIDE.md,AGENTS.md,relative,hard-rules-do-not-violate,yes +FEATURE_GUIDE.md,CONTRIBUTING.md,relative,,yes +FEATURE_GUIDE.md,docs/EDITIONS.md,relative,,yes +FEATURE_GUIDE.md,docs/audio-ws-spec.md,relative,,yes +FEATURE_GUIDE.md,docs/midi-spec.md,relative,,yes +FEATURE_GUIDE.md,firmware/bundles/README.md,relative,,yes +FEATURE_GUIDE.md,firmware/bundles/audio,relative,,yes +FEATURE_GUIDE.md,firmware/patternflow/features/README.md,relative,,yes +FEATURE_GUIDE.md,firmware/patternflow/features/audio_in,relative,,yes +FEATURE_GUIDE.md,firmware/patternflow/features/osc,relative,,yes +FEATURE_GUIDE.md,firmware/patternflow/features/show,relative,,yes +PATTERN_GUIDE.md,/community,site,,yes +PATTERN_GUIDE.md,/guide/make,site,,yes +PATTERN_GUIDE.md,/guide/play,site,flash,yes +PATTERN_GUIDE.md,/pattern,site,,yes +PATTERN_GUIDE.md,/update,site,,yes +PATTERN_GUIDE.md,BUILD_GUIDE.md,relative,,yes +PATTERN_GUIDE.md,BUILD_GUIDE.md,relative,8-firmware,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/01-landing-to-community.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/02-community-home.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/03-the-wall.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/04-card-hover-knobs.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/05-deck.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/06-send-to-my-board.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/07-modules-built.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/08-device-patterns-page.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/09-delete-modules.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/10-pattern-detail.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/11-pattern-lab.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/12-resolution.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/13-color-ramp.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/14-share-to-hardware.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/15-convert-header.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/16-on-to-hardware.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/17-send-over-wifi.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/18-publish.png,relative,,yes +README.md,.github/SUPPORT.md,relative,,yes +README.md,/,site,,yes +README.md,/build/breadboard,site,,yes +README.md,/contact,site,,yes +README.md,/editions,site,,yes +README.md,/features,site,,yes +README.md,/guide,site,,yes +README.md,/guide/audio,site,,yes +README.md,/guide/build,site,,yes +README.md,/guide/make,site,,yes +README.md,/guide/play,site,,yes +README.md,/guide/play,site,flash,yes +README.md,/guide/play,site,flash-5,yes +README.md,/guide/play,site,patterns,yes +README.md,/inside,site,,yes +README.md,/journal,site,,yes +README.md,/pattern,site,,yes +README.md,/pattern-lab,site,,yes +README.md,BUILD_GUIDE.md,relative,,yes +README.md,BUILD_GUIDE.md,relative,1-bill-of-materials-bom,yes +README.md,BUILD_GUIDE.md,relative,10-known-issues--design-notes,yes +README.md,BUILD_GUIDE.md,relative,8-firmware,yes +README.md,BUILD_GUIDE.md,relative,9-final-checks,yes +README.md,BUILD_GUIDE.md,relative,off-the-board,yes +README.md,CHANGELOG.md,relative,,yes +README.md,CONTRIBUTING.md,relative,,yes +README.md,FEATURE_GUIDE.md,relative,,yes +README.md,LICENSE-CC-BY-SA,relative,,yes +README.md,LICENSE-MIT,relative,,yes +README.md,PATTERN_GUIDE.md,relative,,yes +README.md,docs/AUDIO_GUIDE.md,relative,,yes +README.md,docs/EDITIONS.md,relative,,yes +README.md,docs/LICENSE-SUMMARY.md,relative,,yes +README.md,docs/README.md,relative,,yes +README.md,docs/REPOSITORY.md,relative,,yes +README.md,docs/assembly/README.md,relative,,yes +README.md,docs/director-midi.md,relative,,yes +README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +README.md,docs/manifesto.md,relative,,yes +README.md,docs/media/community-builds.jpg,relative,,yes +README.md,docs/media/community-library.png,relative,,yes +README.md,docs/media/daw-ableton.jpg,relative,,yes +README.md,docs/media/device-card.png,relative,,yes +README.md,docs/media/hero-loop.webp,relative,,yes +README.md,docs/media/pcbway-3d-print.jpg,relative,,yes +README.md,docs/media/pcbway-logo.png,relative,,yes +README.md,docs/media/web-build-map.png,relative,,yes +README.md,docs/media/web-live-editor.png,relative,,yes +README.md,docs/midi-ableton.md,relative,,yes +README.md,docs/midi-spec.md,relative,,yes +README.md,docs/mqtt-spec.md,relative,,yes +README.md,docs/osc-spec.md,relative,,yes +README.md,docs/panel-compatibility.md,relative,,yes +README.md,docs/rest-api.md,relative,,yes +README.md,docs/rest-api.md,relative,get-apidisplay,yes +README.md,docs/rest-api.md,relative,get-apiknobs-and-post-apiknobs,yes +README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +README.md,firmware/README.md,relative,,yes +README.md,firmware/README.md,relative,refresh-rate-anti-flicker-for-video,yes +README.md,firmware/toolchain/make_pack.py,relative,,yes +README.md,hardware/README.md,relative,,yes +README.md,hardware/case/remixes/README.md,relative,,yes +README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +README.md,integrations/README.md,relative,,yes +README.md,integrations/ableton,relative,,yes +README.md,tools/patternflow-audio-extension,relative,,yes +README.md,web/ARCHITECTURE.md,relative,,yes +README.md,web/public/journal/v1-30-days/first-pcb.jpg,relative,,yes +docs/AUDIO_GUIDE.md,/editions,site,,yes +docs/AUDIO_GUIDE.md,/guide/audio,site,,yes +docs/AUDIO_GUIDE.md,AGENTS.md,relative,hard-rules-do-not-violate,yes +docs/AUDIO_GUIDE.md,BUILD_GUIDE.md,relative,8-firmware,yes +docs/AUDIO_GUIDE.md,CONTRIBUTING.md,relative,,yes +docs/AUDIO_GUIDE.md,FEATURE_GUIDE.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/audio-ws-spec.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/director-midi.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/images/audio-guide/pdm-mic-wiring.jpg,relative,,yes +docs/AUDIO_GUIDE.md,docs/midi-ableton.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/midi-spec.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/osc-spec.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/rest-api.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/rest-api.md,relative,microphone-audio-edition,yes +docs/AUDIO_GUIDE.md,firmware/patternflow/console/README.md,relative,the-page-that-is-assembled-audio-in,yes +docs/AUDIO_GUIDE.md,firmware/patternflow/features/audio,relative,,yes +docs/AUDIO_GUIDE.md,firmware/patternflow/features/audio_in,relative,,yes +docs/AUDIO_GUIDE.md,firmware/patternflow/features/midi,relative,,yes +docs/AUDIO_GUIDE.md,integrations/ableton,relative,,yes +docs/AUDIO_GUIDE.md,tools/patternflow-audio-android,relative,,yes +docs/AUDIO_GUIDE.md,tools/patternflow-audio-android/README.md,relative,,yes +docs/AUDIO_GUIDE.md,tools/patternflow-audio-extension,relative,,yes +docs/AUDIO_GUIDE.md,tools/patternflow-audio-extension/README.md,relative,,yes +docs/EDITIONS.md,/editions,site,,yes +docs/EDITIONS.md,PATTERN_GUIDE.md,relative,,yes +docs/EDITIONS.md,docs/RELEASING.md,relative,,yes +docs/EDITIONS.md,docs/audio-ws-spec.md,relative,,yes +docs/EDITIONS.md,docs/investigations/2026-08-the-wifi-portal-a-samsung-never-drew.md,relative,,yes +docs/EDITIONS.md,docs/osc-spec.md,relative,,yes +docs/EDITIONS.md,docs/rest-api.md,relative,,yes +docs/EDITIONS.md,docs/rfc-core-and-variants.md,relative,,yes +docs/EDITIONS.md,docs/rfc-core-and-variants.md,relative,213-what-changed-and-why-step-5-is-withdrawn,yes +docs/EDITIONS.md,docs/rfc-core-and-variants.md,relative,214-the-formats-are-frozen,yes +docs/EDITIONS.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/EDITIONS.md,firmware/bundles/README.md,relative,,yes +docs/EDITIONS.md,firmware/patternflow/features/README.md,relative,,yes +docs/LICENSE-SUMMARY.md,/community,site,,yes +docs/LICENSE-SUMMARY.md,/terms,site,,yes +docs/LICENSE-SUMMARY.md,CONTRIBUTING.md,relative,,yes +docs/README.md,.github/SUPPORT.md,relative,,yes +docs/README.md,/guide,site,,yes +docs/README.md,/guide/audio,site,,yes +docs/README.md,/guide/build,site,,yes +docs/README.md,/guide/make,site,,yes +docs/README.md,/guide/play,site,,yes +docs/README.md,/guide/play,site,flash,yes +docs/README.md,BUILD_GUIDE.md,relative,,yes +docs/README.md,BUILD_GUIDE.md,relative,8-firmware,yes +docs/README.md,CONTRIBUTING.md,relative,,yes +docs/README.md,PATTERN_GUIDE.md,relative,,yes +docs/README.md,docs/AUDIO_GUIDE.md,relative,,yes +docs/README.md,docs/EDITIONS.md,relative,,yes +docs/README.md,docs/LICENSE-SUMMARY.md,relative,,yes +docs/README.md,docs/RELEASING.md,relative,,yes +docs/README.md,docs/REPOSITORY.md,relative,,yes +docs/README.md,docs/SERVICES.md,relative,,yes +docs/README.md,docs/assembly/README.md,relative,,yes +docs/README.md,docs/audio-ws-spec.md,relative,,yes +docs/README.md,docs/audio-ws-vectors,relative,,yes +docs/README.md,docs/build-guide/images,relative,,yes +docs/README.md,docs/contract-vectors.md,relative,,yes +docs/README.md,docs/director-midi.md,relative,,yes +docs/README.md,docs/images,relative,,yes +docs/README.md,docs/investigations,relative,,yes +docs/README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +docs/README.md,docs/manifesto.md,relative,,yes +docs/README.md,docs/media,relative,,yes +docs/README.md,docs/midi-ableton.md,relative,,yes +docs/README.md,docs/midi-spec.md,relative,,yes +docs/README.md,docs/midi-vectors,relative,,yes +docs/README.md,docs/mqtt-spec.md,relative,,yes +docs/README.md,docs/mqtt-vectors,relative,,yes +docs/README.md,docs/osc-spec.md,relative,,yes +docs/README.md,docs/osc-vectors,relative,,yes +docs/README.md,docs/panel-compatibility.md,relative,,yes +docs/README.md,docs/pfst-v2-spec.md,relative,,yes +docs/README.md,docs/pfst-v2-vectors,relative,,yes +docs/README.md,docs/releases,relative,,yes +docs/README.md,docs/rest-api-vectors,relative,,yes +docs/README.md,docs/rest-api.md,relative,,yes +docs/README.md,docs/rfc-any-resolution.md,relative,,yes +docs/README.md,docs/rfc-core-and-variants-progress.md,relative,,yes +docs/README.md,docs/rfc-core-and-variants.md,relative,,yes +docs/README.md,docs/rfc-linux-player.md,relative,,yes +docs/README.md,docs/voice-banned.txt,relative,,yes +docs/README.md,docs/voice.md,relative,,yes +docs/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/README.md,firmware/patternflow/console/README.md,relative,,yes +docs/README.md,hardware/README.md,relative,,yes +docs/README.md,hardware/case/remixes/README.md,relative,,yes +docs/README.md,tools/virtual-panel/README.md,relative,,yes +docs/REPOSITORY.md,/guide/audio,site,,yes +docs/REPOSITORY.md,/guide/build,site,,yes +docs/REPOSITORY.md,/guide/make,site,,yes +docs/REPOSITORY.md,/guide/play,site,,yes +docs/REPOSITORY.md,/guide/play,site,flash,yes +docs/REPOSITORY.md,AGENTS.md,relative,,yes +docs/REPOSITORY.md,BUILD_GUIDE.md,relative,,yes +docs/REPOSITORY.md,BUILD_GUIDE.md,relative,8-firmware,yes +docs/REPOSITORY.md,CONTRIBUTING.md,relative,,yes +docs/REPOSITORY.md,FEATURE_GUIDE.md,relative,,yes +docs/REPOSITORY.md,PATTERN_GUIDE.md,relative,,yes +docs/REPOSITORY.md,README.md,relative,,yes +docs/REPOSITORY.md,docs/AUDIO_GUIDE.md,relative,,yes +docs/REPOSITORY.md,docs/EDITIONS.md,relative,,yes +docs/REPOSITORY.md,docs/README.md,relative,,yes +docs/REPOSITORY.md,docs/RELEASING.md,relative,,yes +docs/REPOSITORY.md,docs/SERVICES.md,relative,,yes +docs/REPOSITORY.md,docs/assembly/README.md,relative,,yes +docs/REPOSITORY.md,docs/rest-api.md,relative,,yes +docs/REPOSITORY.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/REPOSITORY.md,firmware/README.md,relative,,yes +docs/REPOSITORY.md,firmware/bundles/README.md,relative,,yes +docs/REPOSITORY.md,firmware/patternflow/features/README.md,relative,,yes +docs/REPOSITORY.md,hardware/README.md,relative,,yes +docs/REPOSITORY.md,integrations/README.md,relative,,yes +docs/REPOSITORY.md,shared/constants.json,relative,,yes +docs/REPOSITORY.md,tools/README.md,relative,,yes +docs/REPOSITORY.md,tools/virtual-panel/README.md,relative,,yes +docs/REPOSITORY.md,web/ARCHITECTURE.md,relative,,yes +docs/REPOSITORY.md,web/README.md,relative,,yes +docs/assembly/README.md,/build/breadboard,site,,yes +docs/assembly/README.md,/guide/build,site,,yes +docs/assembly/README.md,/guide/play,site,flash,yes +docs/assembly/README.md,BUILD_GUIDE.md,relative,,yes +docs/assembly/README.md,BUILD_GUIDE.md,relative,8-firmware,yes +docs/assembly/README.md,PATTERN_GUIDE.md,relative,,yes +docs/assembly/README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +docs/assembly/README.md,docs/panel-compatibility.md,relative,,yes +docs/assembly/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/assembly/README.md,hardware/README.md,relative,,yes +docs/assembly/README.md,hardware/case/README.md,relative,,yes +docs/assembly/README.md,hardware/case/legacy_lasercut/README.md,relative,,yes +docs/assembly/README.md,hardware/case/remixes/README.md,relative,,yes +docs/assembly/README.md,hardware/case/remixes/besoiobiy-printed/README.md,relative,,yes +docs/assembly/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,relative,,yes +docs/assembly/README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +docs/assembly/README.md,hardware/pcb/README.md,relative,,yes +docs/audio-ws-spec.md,docs/osc-spec.md,relative,,yes +docs/audio-ws-spec.md,docs/rest-api.md,relative,,yes +docs/audio-ws-spec.md,docs/rest-api.md,relative,audio-react-audio-edition,yes +docs/audio-ws-vectors/README.md,docs/audio-ws-spec.md,relative,,yes +docs/audio-ws-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/audio-ws-vectors/README.md,docs/rest-api-vectors/README.md,relative,,yes +docs/audio-ws-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/contract-vectors.md,docs/pfst-v2-vectors,relative,,yes +docs/contract-vectors.md,docs/rest-api.md,relative,,yes +docs/contract-vectors.md,tools/virtual-panel/README.md,relative,,yes +docs/director-midi.md,docs/audio-ws-spec.md,relative,,yes +docs/director-midi.md,docs/osc-spec.md,relative,,yes +docs/director-midi.md,integrations/ableton/README.md,relative,,yes +docs/director-midi.md,integrations/ableton/docs/recording-sync.md,relative,,yes +docs/investigations/2026-09-firmware-runtime.md,docs/investigations/2026-09-runtime-improvements.json,relative,,yes +docs/investigations/2026-09-firmware-runtime.md,docs/investigations/2026-09-runtime-research/README.md,relative,,yes +docs/investigations/2026-09-runtime-research/README.md,docs/investigations/2026-09-firmware-runtime.md,relative,,yes +docs/investigations/2026-10-cleanup-baseline.md,docs/investigations/2026-10-doc-map.csv,relative,,yes +docs/investigations/2026-10-cleanup-baseline.md,docs/rfc-cleanup-progress.md,relative,,yes +docs/investigations/2026-10-cleanup-baseline.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-baseline.md,web/scripts/doc-map.ts,relative,,yes +docs/investigations/2026-10-cleanup-step1-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step2-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step3-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step4-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step4-plan.md,docs/voice.md,relative,,yes +docs/investigations/2026-10-cleanup-step5-plan.md,docs/panel-compatibility.md,relative,,yes +docs/investigations/2026-10-cleanup-step5-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step5-plan.md,docs/voice.md,relative,,yes +docs/investigations/2026-10-panel-geometry.md,docs/investigations/2026-10-cleanup-step5-plan.md,relative,,yes +docs/investigations/2026-10-panel-geometry.md,docs/rfc-cleanup.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,/,site,,yes +docs/legacy/BUILD_GUIDE_v2.md,/build/breadboard,site,,yes +docs/legacy/BUILD_GUIDE_v2.md,BUILD_GUIDE.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,BUILD_GUIDE.md,relative,6-case-assembly,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/assembly/README.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/all_parts.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/back_cover.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/case_bond.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/divider_bond.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_back_correct.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_front_wrong.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_nut.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/esp32_insert.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/first_boot.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/knobs.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_bump_cut1.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_bump_cut2.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_screw.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/parts_layout.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_assembly_setup.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_insert.webm,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_inserted.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_left_screw.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_slider.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/power_wire_measure.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/usb_wire_cut.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/web_flash.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/panel-compatibility.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,firmware/README.md,relative,required-board-package,yes +docs/legacy/BUILD_GUIDE_v2.md,firmware/patternflow/config.h,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,hardware/case/legacy_v2/encloser_v2.1.stl,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,hardware/pcb/gerber,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,v2.1.0:hardware/case,absolute-blob,,yes +docs/manifesto.md,/inside,site,,yes +docs/midi-ableton.md,docs/director-midi.md,relative,,yes +docs/midi-ableton.md,docs/images/midi/ableton-5-remote.png,relative,,yes +docs/midi-ableton.md,docs/images/midi/rtpmidi-1-new-session.png,relative,,yes +docs/midi-ableton.md,docs/images/midi/rtpmidi-2-enable.png,relative,,yes +docs/midi-ableton.md,docs/images/midi/rtpmidi-3-connect.png,relative,,yes +docs/midi-ableton.md,docs/images/midi/rtpmidi-4-connected.png,relative,,yes +docs/midi-ableton.md,docs/midi-spec.md,relative,,yes +docs/midi-spec.md,docs/EDITIONS.md,relative,,yes +docs/midi-spec.md,docs/director-midi.md,relative,,yes +docs/midi-spec.md,docs/osc-spec.md,relative,,yes +docs/midi-spec.md,docs/rest-api.md,relative,,yes +docs/midi-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/midi-vectors/README.md,docs/midi-spec.md,relative,,yes +docs/midi-vectors/README.md,docs/rest-api-vectors/README.md,relative,,yes +docs/midi-vectors/README.md,tools/rtpmidi-probe/rtpmidi_probe.py,relative,,yes +docs/midi-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/mqtt-spec.md,docs/EDITIONS.md,relative,,yes +docs/mqtt-spec.md,docs/midi-spec.md,relative,,yes +docs/mqtt-spec.md,docs/osc-spec.md,relative,,yes +docs/mqtt-spec.md,docs/rest-api.md,relative,,yes +docs/mqtt-spec.md,docs/rest-api.md,relative,choosing-a-transport,yes +docs/mqtt-spec.md,docs/rest-api.md,relative,knob-ordering,yes +docs/mqtt-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/mqtt-vectors/README.md,docs/mqtt-spec.md,relative,,yes +docs/mqtt-vectors/README.md,docs/rest-api-vectors/README.md,relative,,yes +docs/mqtt-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/osc-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/osc-vectors/README.md,docs/osc-spec.md,relative,,yes +docs/osc-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/panel-compatibility.md,docs/media/panels/dp5125d_sm5166p.jpg,relative,,yes +docs/panel-compatibility.md,docs/media/panels/fm6124ej.jpg,relative,,yes +docs/panel-compatibility.md,docs/media/panels/icn2037bp.jpg,relative,,yes +docs/panel-compatibility.md,firmware/README.md,relative,,yes +docs/panel-compatibility.md,firmware/patternflow/config.h,relative,,yes +docs/panel-compatibility.md,firmware/patternflow/src/core_display.h,relative,,yes +docs/panel-compatibility.md,hardware/bom/README.md,relative,,yes +docs/pfst-v2-spec.md,docs/pfst-v2-vectors,relative,,yes +docs/releases/v1.0.0.md,BUILD_GUIDE.md,relative,,yes +docs/releases/v3.0.0-launch.md,docs/RELEASING.md,relative,,yes +docs/releases/v3.0.0-notes.md,/,site,,yes +docs/releases/v3.0.0-notes.md,BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes +docs/releases/v3.0.0-notes.md,v3.0.0:BUILD_GUIDE.md,absolute-blob,,yes +docs/releases/v3.0.0-notes.md,v3.0.0:BUILD_GUIDE_v2.md,absolute-blob,,yes +docs/releases/v3.0.0-notes.md,v3.0.0:CHANGELOG.md,absolute-blob,,yes +docs/releases/v3.0.0-notes.md,v3.0.0:hardware/bom/bom_v3.0.csv,absolute-blob,,yes +docs/releases/v3.1.0-notes.md,/roadmap,site,,yes +docs/releases/v3.1.0-notes.md,v3.1.0:BUILD_GUIDE.md,absolute-blob,,yes +docs/releases/v3.1.0-notes.md,v3.1.0:BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes +docs/releases/v3.1.0-notes.md,v3.1.0:CHANGELOG.md,absolute-blob,,yes +docs/releases/v3.1.0.md,/roadmap,site,,yes +docs/releases/v3.10.0.md,docs/investigations/2026-09-firmware-runtime.md,relative,,yes +docs/releases/v3.10.5.md,/guide,site,,yes +docs/releases/v3.10.5.md,/guide/build,site,,yes +docs/releases/v3.10.5.md,/guide/make,site,,yes +docs/releases/v3.10.5.md,/guide/play,site,,yes +docs/releases/v3.3.0.md,/panel-tuner.html,site,,yes +docs/releases/v3.3.0.md,docs/panel-compatibility.md,relative,,yes +docs/releases/v3.4.0.md,/update,site,,yes +docs/releases/v3.5.1.md,docs/investigations/2026-08-core2-heap-and-the-5s-stall.md,relative,,yes +docs/releases/v3.6.3.md,docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md,relative,,yes +docs/releases/v3.6.3.md,docs/pfst-v2-spec.md,relative,,yes +docs/releases/v3.7.0.md,/variants,site,,yes +docs/releases/v3.8.0.md,/variants,site,,yes +docs/rest-api-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/rest-api-vectors/README.md,docs/rest-api.md,relative,,yes +docs/rest-api-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/rest-api.md,docs/EDITIONS.md,relative,,yes +docs/rest-api.md,docs/audio-ws-spec.md,relative,,yes +docs/rest-api.md,docs/audio-ws-spec.md,relative,the-switch-on-the-panel,yes +docs/rest-api.md,docs/midi-spec.md,relative,,yes +docs/rest-api.md,docs/mqtt-spec.md,relative,,yes +docs/rfc-any-resolution.md,docs/investigations/2026-08-core2-heap-and-the-5s-stall.md,relative,,yes +docs/rfc-any-resolution.md,docs/investigations/2026-10-panel-geometry.md,relative,,yes +docs/rfc-any-resolution.md,docs/panel-compatibility.md,relative,9-why-hub75e-guarantees-nothing-and-the-three-tiers,yes +docs/rfc-any-resolution.md,docs/rfc-cleanup.md,relative,212-order-and-what-done-means,yes +docs/rfc-any-resolution.md,docs/rfc-linux-player.md,relative,a-receiving-card,yes +docs/rfc-cleanup-progress.md,docs/contract-vectors.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-baseline.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step1-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step2-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step3-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step4-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step5-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-doc-map.csv,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-doc-parity.csv,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-panel-geometry.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/rfc-any-resolution.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/rfc-cleanup.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/rfc-linux-player.md,relative,,yes +docs/rfc-cleanup.md,docs/rfc-cleanup-progress.md,relative,,yes +docs/rfc-core-and-variants-progress.md,docs/EDITIONS.md,relative,,yes +docs/rfc-core-and-variants-progress.md,docs/pfst-v2-vectors,relative,,yes +docs/rfc-core-and-variants-progress.md,docs/rest-api.md,relative,,yes +docs/rfc-core-and-variants-progress.md,docs/rfc-core-and-variants.md,relative,,yes +docs/rfc-core-and-variants-progress.md,firmware/patternflow/features/README.md,relative,,yes +docs/rfc-core-and-variants.md,/variants,site,,yes +docs/rfc-core-and-variants.md,docs/EDITIONS.md,relative,,yes +docs/rfc-core-and-variants.md,docs/pfst-v2-spec.md,relative,,yes +docs/rfc-core-and-variants.md,docs/rfc-core-and-variants-progress.md,relative,,yes +docs/rfc-core-and-variants.md,docs/rfc-core-and-variants-progress.md,relative,what-the-slim-core-measures,yes +docs/rfc-linux-player.md,docs/EDITIONS.md,relative,the-three-words,yes +docs/rfc-linux-player.md,docs/audio-ws-spec.md,relative,,yes +docs/rfc-linux-player.md,docs/investigations/2026-10-cleanup-step4-plan.md,relative,,yes +docs/rfc-linux-player.md,docs/investigations/2026-10-panel-geometry.md,relative,the-site,yes +docs/rfc-linux-player.md,docs/midi-spec.md,relative,,yes +docs/rfc-linux-player.md,docs/mqtt-spec.md,relative,,yes +docs/rfc-linux-player.md,docs/osc-spec.md,relative,,yes +docs/rfc-linux-player.md,docs/panel-compatibility.md,relative,,yes +docs/rfc-linux-player.md,docs/rest-api.md,relative,,yes +docs/rfc-linux-player.md,docs/rfc-any-resolution.md,relative,,yes +docs/rfc-linux-player.md,docs/rfc-cleanup.md,relative,,yes +docs/voice.md,AGENTS.md,relative,,yes +docs/voice.md,BUILD_GUIDE.md,relative,,yes +docs/voice.md,README.md,relative,,yes +docs/voice.md,docs/EDITIONS.md,relative,the-three-words,yes +docs/voice.md,docs/rfc-cleanup.md,relative,210-writing-voice,yes +docs/voice.md,docs/voice-banned.txt,relative,,yes +firmware/CUSTOM_PATTERNS.md,/,site,,yes +firmware/CUSTOM_PATTERNS.md,/update,site,,yes +firmware/CUSTOM_PATTERNS.md,firmware/README.md,relative,loadable-pattern-modules-pfm,yes +firmware/CUSTOM_PATTERNS.md,firmware/README.md,relative,required-board-package,yes +firmware/README.md,/editions,site,,yes +firmware/README.md,/guide/play,site,flash,yes +firmware/README.md,/update,site,,yes +firmware/README.md,BUILD_GUIDE.md,relative,8-firmware,yes +firmware/README.md,PATTERN_GUIDE.md,relative,,yes +firmware/README.md,docs/AUDIO_GUIDE.md,relative,,yes +firmware/README.md,docs/EDITIONS.md,relative,,yes +firmware/README.md,docs/README.md,relative,,yes +firmware/README.md,docs/audio-ws-spec.md,relative,,yes +firmware/README.md,docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md,relative,,yes +firmware/README.md,docs/investigations/2026-09-firmware-runtime.md,relative,,yes +firmware/README.md,docs/midi-ableton.md,relative,,yes +firmware/README.md,docs/midi-spec.md,relative,,yes +firmware/README.md,docs/mqtt-spec.md,relative,,yes +firmware/README.md,docs/osc-spec.md,relative,,yes +firmware/README.md,docs/panel-compatibility.md,relative,,yes +firmware/README.md,docs/rest-api.md,relative,,yes +firmware/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +firmware/README.md,firmware/bundles/README.md,relative,,yes +firmware/README.md,firmware/patternflow/console,relative,,yes +firmware/README.md,firmware/patternflow/console/README.md,relative,,yes +firmware/README.md,firmware/patternflow/src/hub75/UPSTREAM.md,relative,,yes +firmware/README.md,firmware/patternflow/src/hub75/VENDORED.md,relative,,yes +firmware/README.md,integrations/ableton,relative,,yes +firmware/README.md,tools/README.md,relative,,yes +firmware/bundles/README.md,/editions,site,,yes +firmware/bundles/README.md,docs/EDITIONS.md,relative,,yes +firmware/bundles/README.md,firmware/patternflow/features/README.md,relative,,yes +firmware/modules/README.md,/pattern-lab,site,,yes +firmware/modules/README.md,PATTERN_GUIDE.md,relative,,yes +firmware/patternflow/README.md,CONTRIBUTING.md,relative,,yes +firmware/patternflow/README.md,PATTERN_GUIDE.md,relative,,yes +firmware/patternflow/README.md,docs/LICENSE-SUMMARY.md,relative,,yes +firmware/patternflow/README.md,docs/panel-compatibility.md,relative,,yes +firmware/patternflow/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +firmware/patternflow/README.md,firmware/README.md,relative,,yes +firmware/patternflow/README.md,firmware/patternflow/_TEMPLATE.h,relative,,yes +firmware/patternflow/README.md,firmware/patternflow/config.h,relative,,yes +firmware/patternflow/README.md,firmware/patternflow/pattern_registry.h,relative,,yes +firmware/patternflow/README.md,firmware/patternflow/presets,relative,,yes +firmware/patternflow/console/_audio_in_bar.html,/,console,,yes +firmware/patternflow/console/_audio_in_bar.html,/status,console,,yes +firmware/patternflow/console/_audio_in_bar.html,tools/patternflow-audio-extension,absolute-blob,readme,yes +firmware/patternflow/console/audio-in.html,/,console,,yes +firmware/patternflow/console/audio-in.html,/pf-console.js,console,,yes +firmware/patternflow/console/audio-in.html,/status,console,,yes +firmware/patternflow/console/audio-in.html,tools/patternflow-audio-extension,absolute-blob,readme,yes +firmware/patternflow/console/clock.html,/pf-console.js,console,,yes +firmware/patternflow/console/home.html,/,site,,yes +firmware/patternflow/console/home.html,/editions,site,,yes +firmware/patternflow/console/home.html,/flash/manifest.json,site,,yes +firmware/patternflow/console/home.html,/knobs,console,,yes +firmware/patternflow/console/home.html,/patterns,console,,yes +firmware/patternflow/console/home.html,/pf-console.js,console,,yes +firmware/patternflow/console/home.html,/status,console,,yes +firmware/patternflow/console/home.html,/update,console,,yes +firmware/patternflow/console/home.html,/update,site,,yes +firmware/patternflow/console/home.html,/wifi,console,,yes +firmware/patternflow/console/knobs.html,/,console,,yes +firmware/patternflow/console/knobs.html,/pf-console.js,console,,yes +firmware/patternflow/console/knobs.html,/status,console,,yes +firmware/patternflow/console/midi.html,/pf-console.js,console,,yes +firmware/patternflow/console/midi.html,docs/midi-ableton.md,absolute-blob,,yes +firmware/patternflow/console/midi.html,docs/midi-spec.md,absolute-blob,,yes +firmware/patternflow/console/mqtt.html,/pf-console.js,console,,yes +firmware/patternflow/console/patterns.html,/,console,,yes +firmware/patternflow/console/patterns.html,/,site,,yes +firmware/patternflow/console/patterns.html,/patterns/fflate.js,console,,yes +firmware/patternflow/console/patterns.html,/pf-console.js,console,,yes +firmware/patternflow/console/show.html,/patterns/fflate.js,console,,yes +firmware/patternflow/console/show.html,/pf-console.js,console,,yes +firmware/patternflow/console/show.html,/weather,console,,yes +firmware/patternflow/console/status.html,/,console,,yes +firmware/patternflow/console/status.html,/patterns,console,,yes +firmware/patternflow/console/status.html,/pf-console.js,console,,yes +firmware/patternflow/console/update.html,/,console,,yes +firmware/patternflow/console/update.html,/,site,,yes +firmware/patternflow/console/update.html,/editions,site,,yes +firmware/patternflow/console/update.html,/pf-console.js,console,,yes +firmware/patternflow/console/update.html,/update,site,,yes +firmware/patternflow/console/weather.html,/pf-console.js,console,,yes +firmware/patternflow/console/wifi.html,/,console,,yes +firmware/patternflow/console/wifi.html,/pf-console.js,console,,yes +firmware/patternflow/console/wifi.html,/status,console,,yes +firmware/patternflow/features/README.md,docs/EDITIONS.md,relative,,yes +firmware/patternflow/features/README.md,docs/rfc-core-and-variants.md,relative,,yes +firmware/patternflow/features/README.md,firmware/bundles/README.md,relative,,yes +firmware/patternflow/presets/README.md,/pattern-lab,site,,yes +firmware/patternflow/presets/README.md,docs/LICENSE-SUMMARY.md,relative,,yes +firmware/patternflow/presets/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +firmware/patternflow/presets/README.md,firmware/patternflow/README.md,relative,,yes +firmware/patternflow/presets/README.md,firmware/patternflow/pattern_registry.h,relative,,yes +firmware/patternflow/presets/preset_0628.h,/,site,,yes +firmware/patternflow/presets/preset_0629.h,/,site,,yes +firmware/patternflow/presets/preset_0629_2.h,/,site,,yes +firmware/patternflow/presets/preset_0701.h,/,site,,yes +firmware/patternflow/presets/preset_0707.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0710.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0712.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0712_2.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0713.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0715.h,/pattern,site,,yes +firmware/patternflow/presets/preset_0716.h,/pattern,site,,yes +firmware/patternflow/presets/preset_0718.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0719.h,/pattern-lab,site,,yes +firmware/toolchain/README.md,docs/EDITIONS.md,relative,,yes +firmware/toolchain/README.md,docs/RELEASING.md,relative,,yes +firmware/toolchain/README.md,firmware/patternflow/console/README.md,relative,,yes +hardware/README.md,/build,site,,yes +hardware/README.md,/guide/build,site,,yes +hardware/README.md,BUILD_GUIDE.md,relative,,yes +hardware/README.md,LICENSE-CC-BY-SA,relative,,yes +hardware/README.md,docs/assembly/README.md,relative,,yes +hardware/README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +hardware/README.md,hardware/bom/README.md,relative,,yes +hardware/README.md,hardware/bom/bom_v3.0.csv,relative,,yes +hardware/README.md,hardware/bom/bom_v3.9.csv,relative,,yes +hardware/README.md,hardware/case/README.md,relative,,yes +hardware/README.md,hardware/case/legacy_lasercut/README.md,relative,,yes +hardware/README.md,hardware/case/remixes/README.md,relative,,yes +hardware/README.md,hardware/pcb/README.md,relative,,yes +hardware/README.md,hardware/pcb/gerber,relative,,yes +hardware/README.md,v2.1.0:BUILD_GUIDE.md,absolute-blob,1-bill-of-materials-bom,yes +hardware/bom/README.md,docs/panel-compatibility.md,relative,,yes +hardware/bom/README.md,firmware/patternflow/config.h,relative,,yes +hardware/bom/README.md,v2.1.0:BUILD_GUIDE.md,absolute-blob,1-bill-of-materials-bom,yes +hardware/case/README.md,BUILD_GUIDE.md,relative,,yes +hardware/case/README.md,BUILD_GUIDE.md,relative,2-power-input--use-the-screw-terminal,yes +hardware/case/README.md,BUILD_GUIDE.md,relative,6-case-assembly,yes +hardware/case/README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +hardware/case/README.md,hardware/bom,relative,,yes +hardware/case/README.md,hardware/case/bed_256mm,relative,,yes +hardware/case/README.md,hardware/case/bed_256mm/patternflow_v3.3mf,relative,,yes +hardware/case/README.md,hardware/case/bed_330mm,relative,,yes +hardware/case/README.md,hardware/case/knobs,relative,,yes +hardware/case/README.md,hardware/case/legacy_lasercut/README.md,relative,,yes +hardware/case/README.md,hardware/case/legacy_v2,relative,,yes +hardware/case/README.md,hardware/case/remixes/README.md,relative,,yes +hardware/case/README.md,hardware/case/remixes/besoiobiy-printed/README.md,relative,,yes +hardware/case/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,relative,,yes +hardware/case/README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +hardware/case/README.md,v2.1.0:hardware/case,absolute-blob,,yes +hardware/case/legacy_lasercut/README.md,hardware/case/remixes/README.md,relative,,yes +hardware/case/legacy_lasercut/README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +hardware/case/remixes/README.md,/build,site,,yes +hardware/case/remixes/README.md,/inside,site,,yes +hardware/case/remixes/README.md,CONTRIBUTING.md,relative,your-build,yes +hardware/case/remixes/README.md,hardware/case/remixes/besoiobiy-printed/README.md,relative,,yes +hardware/case/remixes/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,relative,,yes +hardware/case/remixes/README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +hardware/case/remixes/README.md,tools/case-models/README.md,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,BUILD_GUIDE.md,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,BUILD_GUIDE.md,relative,2-power-input--use-the-screw-terminal,yes +hardware/case/remixes/besoiobiy-printed/README.md,BUILD_GUIDE.md,relative,6-case-assembly,yes +hardware/case/remixes/besoiobiy-printed/README.md,docs/panel-compatibility.md,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig1_corner_bulge.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig2_panel_gap.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig3_variants.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig4_grooves.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig5_glue_order.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig6_inserts_and_screws.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig7_panel_bolts.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig8_ribbon_connector.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/photo_built.jpg,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/render_exploded_back.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/render_exploded_front.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/render_parts.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/print_layout.3mf,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/source/patternbox.blend,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/source/patternbox.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_bottom.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_bottom_no_bridge.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_bottom_plain.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_top.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_top_no_bridge.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_top_plain.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/cover_box_bottom.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/cover_box_top.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/cover_frame_bottom.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/cover_frame_top.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/frame_bottom.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/frame_top.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/knob_cap.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,BUILD_GUIDE.md,relative,2-power-input--use-the-screw-terminal,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,LICENSE-CC-BY-SA,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front.jpg,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front_angle.jpg,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/print_layout.3mf,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/alignment_pin_2mm.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/control_section.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/joint_bar.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/knob.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_control_cover.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_display_cover.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/stand.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/usb_retainer.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_inner_frame.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_outer_frame.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,BUILD_GUIDE.md,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/01_frame_connections.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/02_joint_section.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/03_optional_pins.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/04_usb_installation.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/05_connections.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_rear_assembly.jpg,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_socket_detail.jpg,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md,hardware/bom/bom_v3.0.csv,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,relative,print-files,yes +hardware/case/remixes/simonepda-lasercut/README.md,BUILD_GUIDE.md,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/knobs,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/lasercut_layout.cdr,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/photos/acrylic_front.jpg,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/photos/board_in_box.jpg,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/photos/mdf_back.jpg,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/photos/mdf_front.jpg,relative,,yes +hardware/pcb/README.md,LICENSE-CC-BY-SA,relative,,yes +hardware/pcb/README.md,hardware/bom,relative,,yes +hardware/pcb/README.md,hardware/pcb/gerber,relative,,yes +hardware/pcb/README.md,hardware/pcb/images/board_render_bottom.png,relative,,yes +hardware/pcb/README.md,hardware/pcb/images/board_render_top.png,relative,,yes +hardware/pcb/README.md,hardware/pcb/images/schematic.svg,relative,,yes +hardware/pcb/README.md,hardware/pcb/schematic.pdf,relative,,yes +hardware/pcb/gerber/experiment/README.md,hardware/pcb/gerber,relative,,yes +integrations/README.md,/editions,site,,yes +integrations/README.md,docs/midi-spec.md,relative,,yes +integrations/README.md,docs/mqtt-spec.md,relative,,yes +integrations/README.md,docs/osc-spec.md,relative,,yes +integrations/README.md,docs/rest-api.md,relative,,yes +integrations/README.md,integrations/ableton/README.md,relative,,yes +integrations/README.md,tools/README.md,relative,,yes +integrations/ableton/README.md,/editions,site,,yes +integrations/ableton/README.md,docs/EDITIONS.md,relative,,yes +integrations/ableton/README.md,docs/director-midi.md,relative,,yes +integrations/ableton/README.md,docs/osc-spec.md,relative,,yes +integrations/ableton/README.md,integrations/ableton/docs/m4l-osc-guide.md,relative,,yes +integrations/ableton/README.md,integrations/ableton/docs/recording-sync.md,relative,,yes +integrations/ableton/docs/recording-sync.md,docs/osc-spec.md,relative,,yes +tools/README.md,LICENSE-MIT,relative,,yes +tools/README.md,docs/README.md,relative,contracts--other-software-is-built-against-these-not-against-the-firmware-source,yes +tools/README.md,docs/audio-ws-spec.md,relative,,yes +tools/README.md,docs/midi-spec.md,relative,,yes +tools/README.md,docs/mqtt-spec.md,relative,,yes +tools/README.md,docs/osc-spec.md,relative,,yes +tools/README.md,docs/rest-api.md,relative,,yes +tools/README.md,docs/rest-api.md,relative,microphone-audio-edition,yes +tools/README.md,integrations/README.md,relative,,yes +tools/README.md,tools/case-models/README.md,relative,,yes +tools/README.md,tools/patternflow-audio-android/README.md,relative,,yes +tools/README.md,tools/patternflow-audio-extension/README.md,relative,,yes +tools/README.md,tools/rtpmidi-probe/README.md,relative,,yes +tools/README.md,tools/virtual-panel/README.md,relative,,yes +tools/case-models/README.md,/build,site,,yes +tools/patternflow-audio-android/README.md,/editions,site,,yes +tools/patternflow-audio-extension/README.md,/editions,site,,yes +tools/patternflow-audio-extension/README.md,docs/AUDIO_GUIDE.md,relative,,yes +tools/patternflow-audio-extension/README.md,docs/audio-ws-spec.md,relative,,yes +tools/patternflow-audio-extension/README.md,firmware/patternflow/console/README.md,relative,the-page-that-is-assembled-audio-in,yes +tools/rtpmidi-probe/README.md,docs/midi-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/README.md,relative,,yes +tools/virtual-panel/README.md,docs/audio-ws-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/midi-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/mqtt-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/osc-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/rest-api.md,relative,,yes +tools/virtual-panel/README.md,docs/rfc-cleanup.md,relative,26-step-4--the-test-spine,yes +web/ARCHITECTURE.md,/,site,,yes +web/ARCHITECTURE.md,docs/SERVICES.md,relative,,yes +web/README.md,/,site,,yes +web/README.md,CONTRIBUTING.md,relative,,yes +web/README.md,LICENSE-MIT,relative,,yes +web/README.md,tools/case-models/README.md,relative,,yes +web/README.md,web/ARCHITECTURE.md,relative,,yes +web/README.md,web/ARCHITECTURE.md,relative,environment-variables,yes +web/content/inside.md,CONTRIBUTING.md,absolute-blob,,yes +web/content/journal/a-strange-dream.en.mdx,/journal/a-strange-dream/certificate.jpg,site,,yes +web/content/journal/a-strange-dream.mdx,/journal/a-strange-dream/certificate.jpg,site,,yes +web/content/journal/a-strange-feeling.en.mdx,/journal/a-strange-feeling/build-guide.jpg,site,,yes +web/content/journal/a-strange-feeling.mdx,/journal/a-strange-feeling/build-guide.jpg,site,,yes +web/content/journal/all-jumbled-up.en.mdx,/journal/all-jumbled-up/turntable.jpg,site,,yes +web/content/journal/all-jumbled-up.mdx,/journal/all-jumbled-up/turntable.jpg,site,,yes +web/content/journal/all-over-the-place.en.mdx,/journal/all-over-the-place/funding-spreadsheet.png,site,,yes +web/content/journal/all-over-the-place.mdx,/journal/all-over-the-place/funding-spreadsheet.png,site,,yes +web/content/journal/community.en.mdx,/roadmap,site,,yes +web/content/journal/community.mdx,/roadmap,site,,yes +web/content/journal/experiment-after-experiment.en.mdx,/pattern-lab,site,,yes +web/content/journal/experiment-after-experiment.mdx,/pattern-lab,site,,yes +web/content/journal/faster-faster.en.mdx,/community,site,,yes +web/content/journal/faster-faster.en.mdx,/journal/faster-faster/crowd-supply-subscribers.png,site,,yes +web/content/journal/faster-faster.mdx,/community,site,,yes +web/content/journal/faster-faster.mdx,/journal/faster-faster/crowd-supply-subscribers.png,site,,yes +web/content/journal/good-but.en.mdx,/journal/good-but/astra-patterns.png,site,,yes +web/content/journal/good-but.en.mdx,/journal/good-but/funding-success.png,site,,yes +web/content/journal/good-but.mdx,/journal/good-but/astra-patterns.png,site,,yes +web/content/journal/good-but.mdx,/journal/good-but/funding-success.png,site,,yes +web/content/journal/how-to-use-patternflow.en.mdx,/journal/how-to-use-patternflow/gwangju-biennale.jpg,site,,yes +web/content/journal/how-to-use-patternflow.mdx,/journal/how-to-use-patternflow/gwangju-biennale.jpg,site,,yes +web/content/journal/i-hope-it-works-out.en.mdx,/journal/i-hope-it-works-out/crowd-supply-subscribers.png,site,,yes +web/content/journal/i-hope-it-works-out.mdx,/journal/i-hope-it-works-out/crowd-supply-subscribers.png,site,,yes +web/content/journal/ill-just-do-it.en.mdx,/journal/ill-just-do-it/at-a-glance.png,site,,yes +web/content/journal/ill-just-do-it.en.mdx,/journal/ill-just-do-it/the-lab.png,site,,yes +web/content/journal/ill-just-do-it.mdx,/journal/ill-just-do-it/at-a-glance.png,site,,yes +web/content/journal/ill-just-do-it.mdx,/journal/ill-just-do-it/the-lab.png,site,,yes +web/content/journal/its-a-failure.en.mdx,/journal/its-a-failure/pdm-mic.jpg,site,,yes +web/content/journal/its-a-failure.en.mdx,/journal/its-a-failure/petg-enclosure.jpg,site,,yes +web/content/journal/its-a-failure.mdx,/journal/its-a-failure/pdm-mic.jpg,site,,yes +web/content/journal/its-a-failure.mdx,/journal/its-a-failure/petg-enclosure.jpg,site,,yes +web/content/journal/maybe-this-will-work.en.mdx,/journal/maybe-this-will-work/filming-setup.jpg,site,,yes +web/content/journal/maybe-this-will-work.mdx,/journal/maybe-this-will-work/filming-setup.jpg,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/instagram-grid.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/pattern-lab.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/posthog-session-replay.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/price-comment.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/robot-k456.jpg,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/tally-dashboard.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/wins-and-losses-next-step/en,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/instagram-grid.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/pattern-lab.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/posthog-session-replay.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/price-comment.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/robot-k456.jpg,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/tally-dashboard.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/wins-and-losses-next-step,site,,yes +web/content/journal/nam-june-paik-me-patternflow.en.mdx,/journal/nam-june-paik-me-patternflow/pattern-30.png,site,,yes +web/content/journal/nam-june-paik-me-patternflow.mdx,/journal/nam-june-paik-me-patternflow/pattern-30.png,site,,yes +web/content/journal/negative.en.mdx,/journal/negative/emc-test-scan.jpg,site,,yes +web/content/journal/negative.en.mdx,/journal/negative/funding-dashboard.png,site,,yes +web/content/journal/negative.mdx,/journal/negative/emc-test-scan.jpg,site,,yes +web/content/journal/negative.mdx,/journal/negative/funding-dashboard.png,site,,yes +web/content/journal/new-possibilities.en.mdx,/journal/new-possibilities/kkuji-tree.jpg,site,,yes +web/content/journal/new-possibilities.en.mdx,/journal/new-possibilities/pattern-map.png,site,,yes +web/content/journal/new-possibilities.mdx,/journal/new-possibilities/kkuji-tree.jpg,site,,yes +web/content/journal/new-possibilities.mdx,/journal/new-possibilities/pattern-map.png,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/blender-3d-modeling.png,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/exhibition-process.jpg,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/pcb-discord-feedback.png,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/pcbway-delivery-1.jpg,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/pcbway-delivery-2.jpg,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/blender-3d-modeling.png,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/exhibition-process.jpg,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/pcb-discord-feedback.png,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/pcbway-delivery-1.jpg,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/pcbway-delivery-2.jpg,site,,yes +web/content/journal/right-before-launch.en.mdx,/journal/right-before-launch/business-cards.png,site,,yes +web/content/journal/right-before-launch.mdx,/journal/right-before-launch/business-cards.png,site,,yes +web/content/journal/shaking-it-off.en.mdx,/journal/shaking-it-off/flooded-lawn.jpg,site,,yes +web/content/journal/shaking-it-off.mdx,/journal/shaking-it-off/flooded-lawn.jpg,site,,yes +web/content/journal/the-messy-desk.en.mdx,/journal/the-messy-desk/flowlocal.jpg,site,,yes +web/content/journal/the-messy-desk.en.mdx,/journal/the-messy-desk/messy-desk.jpg,site,,yes +web/content/journal/the-messy-desk.en.mdx,/journal/the-messy-desk/park-ice-cream.jpg,site,,yes +web/content/journal/the-messy-desk.mdx,/journal/the-messy-desk/flowlocal.jpg,site,,yes +web/content/journal/the-messy-desk.mdx,/journal/the-messy-desk/messy-desk.jpg,site,,yes +web/content/journal/the-messy-desk.mdx,/journal/the-messy-desk/park-ice-cream.jpg,site,,yes +web/content/journal/this-is-the-worst.en.mdx,/journal/this-is-the-worst/studio-product-shoot.jpg,site,,yes +web/content/journal/this-is-the-worst.mdx,/journal/this-is-the-worst/studio-product-shoot.jpg,site,,yes +web/content/journal/today-is-my-birthday.en.mdx,/journal/today-is-my-birthday/crowd-supply-contract-signed.png,site,,yes +web/content/journal/today-is-my-birthday.en.mdx,/journal/today-is-my-birthday/crowd-supply-contract-top.png,site,,yes +web/content/journal/today-is-my-birthday.en.mdx,/journal/today-is-my-birthday/diffuser-discord.png,site,,yes +web/content/journal/today-is-my-birthday.mdx,/journal/today-is-my-birthday/crowd-supply-contract-signed.png,site,,yes +web/content/journal/today-is-my-birthday.mdx,/journal/today-is-my-birthday/crowd-supply-contract-top.png,site,,yes +web/content/journal/today-is-my-birthday.mdx,/journal/today-is-my-birthday/diffuser-discord.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/assembled-pcb-back.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/debugging-wires-esp32-potentiometer.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/first-pcb.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/kicad-schematic-screenshot.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/led-panel-in-nature-1.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/led-panel-in-nature-2.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/participation-tv-experience.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/patternflow-origin-interaction.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/patternflow-v1-complete.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/reddit-comment-1.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/reddit-comment-2.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/robot-k456.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/tangled-wires-led-panel.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/warped-3d-print.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/assembled-pcb-back.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/debugging-wires-esp32-potentiometer.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/first-pcb.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/kicad-schematic-screenshot.png,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/led-panel-in-nature-1.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/led-panel-in-nature-2.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/participation-tv-experience.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/patternflow-origin-interaction.png,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/patternflow-v1-complete.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/reddit-comment-1.png,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/reddit-comment-2.png,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/robot-k456.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/tangled-wires-led-panel.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/warped-3d-print.jpg,site,,yes +web/content/journal/what-is-patternflow.en.mdx,/journal/what-is-patternflow/viral-x-post.png,site,,yes +web/content/journal/what-is-patternflow.mdx,/journal/what-is-patternflow/viral-x-post.png,site,,yes +web/content/journal/wins-and-losses-next-step.en.mdx,/journal/wins-and-losses-next-step/io0-pullup-resistor.jpg,site,,yes +web/content/journal/wins-and-losses-next-step.en.mdx,/journal/wins-and-losses-next-step/remote-rendering-failure.webm,site,,yes +web/content/journal/wins-and-losses-next-step.mdx,/journal/wins-and-losses-next-step/io0-pullup-resistor.jpg,site,,yes +web/content/journal/wins-and-losses-next-step.mdx,/journal/wins-and-losses-next-step/remote-rendering-failure.webm,site,,yes +web/content/journal/worn-out.en.mdx,/journal/worn-out/c-type-patternflow.jpg,site,,yes +web/content/journal/worn-out.en.mdx,/journal/worn-out/studying-sound.jpg,site,,yes +web/content/journal/worn-out.mdx,/journal/worn-out/c-type-patternflow.jpg,site,,yes +web/content/journal/worn-out.mdx,/journal/worn-out/studying-sound.jpg,site,,yes +web/next.config.ts,/editions,site,,yes +web/next.config.ts,/guide/play,site,flash,yes +web/next.config.ts,/journal/:slug/en,site,,yes +web/next.config.ts,/journal/en,site,,yes +web/scripts/deck-pack-smoke.ts,/community/p/p123,site,,yes +web/scripts/drift/editions.ts,/,site,,yes +web/scripts/license-smoke.ts,/community/p/abc123,site,,yes +web/scripts/license-smoke.ts,/pattern-lab,site,,yes +web/scripts/module-cache-smoke.ts,/community/p/p-author,site,,yes +web/src/app/build/breadboard/page.tsx,BUILD_GUIDE.md,absolute-blob,,yes +web/src/app/build/breadboard/page.tsx,BUILD_GUIDE.md,absolute-blob,4-3d-printing,yes +web/src/app/build/breadboard/page.tsx,BUILD_GUIDE.md,absolute-blob,82-arduino-ide-custom-builds,yes +web/src/app/build/breadboard/page.tsx,docs/panel-compatibility.md,absolute-blob,,yes +web/src/app/build/page.tsx,/build,site,,yes +web/src/app/compliance/page.tsx,/compliance,site,,yes +web/src/app/editions/editions-data.ts,/flash/bin/audio-v0.7.0/patternflow.ino.bin,site,,yes +web/src/app/editions/editions-data.ts,/flash/bin/core-v3.11.0/patternflow.ino.bin,site,,yes +web/src/app/editions/editions-data.ts,/flash/bin/performance-v0.4.0/patternflow.ino.bin,site,,yes +web/src/app/editions/editions-data.ts,firmware/bundles/audio,absolute-blob,,yes +web/src/app/editions/editions-data.ts,firmware/bundles/performance,absolute-blob,,yes +web/src/app/editions/page.tsx,docs/EDITIONS.md,absolute-blob,,yes +web/src/app/editions/page.tsx,docs/EDITIONS.md,absolute-blob,getting-an-edition-listed,yes +web/src/app/features/TryInstall.tsx,/,site,,yes +web/src/app/features/features-data.ts,PATTERN_GUIDE.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/AUDIO_GUIDE.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/audio-ws-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/director-midi.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/midi-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/mqtt-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/osc-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/pfst-v2-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/rest-api.md,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/bundles/midi,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/audio,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/audio_in,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/ble,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/clock,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/midi,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/mqtt,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/osc,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/show,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/weather,absolute-blob,,yes +web/src/app/features/features-data.ts,integrations/ableton,absolute-blob,,yes +web/src/app/features/features-data.ts,tools/patternflow-audio-android,absolute-blob,,yes +web/src/app/features/features-data.ts,tools/patternflow-audio-extension,absolute-blob,,yes +web/src/app/features/page.tsx,FEATURE_GUIDE.md,absolute-blob,,yes +web/src/app/features/page.tsx,docs/EDITIONS.md,absolute-blob,building-an-edition,yes +web/src/app/features/page.tsx,firmware/patternflow/features,absolute-blob,,yes +web/src/app/feed.xml/route.ts,/,site,,yes +web/src/app/feed.xml/route.ts,/feed.xml,site,,yes +web/src/app/feed.xml/route.ts,/journal,site,,yes +web/src/app/inside/[build]/page.tsx,/,site,,yes +web/src/app/inside/page.tsx,/inside,site,,yes +web/src/app/layout.tsx,/,site,,yes +web/src/app/layout.tsx,/,site,organization,yes +web/src/app/layout.tsx,/,site,person,yes +web/src/app/layout.tsx,/,site,website,yes +web/src/app/layout.tsx,/,site,work,yes +web/src/app/layout.tsx,/apple-touch-icon.png,site,,yes +web/src/app/layout.tsx,/og-image.png,site,,yes +web/src/app/pattern/page.tsx,/pattern,site,,yes +web/src/app/roadmap/roadmap-data.ts,/,site,,yes +web/src/app/roadmap/roadmap-data.ts,/build/breadboard,site,,yes +web/src/app/roadmap/roadmap-data.ts,/community,site,,yes +web/src/app/roadmap/roadmap-data.ts,/inside/nath-uk,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/community,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/faster-faster,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/i-hope-it-works-out,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/nam-june-paik-me-patternflow,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/refocus,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/right-before-launch,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/stopped,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/v1-30-days,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/v3-and-beyond,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/what-is-patternflow,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/wins-and-losses-next-step,site,,yes +web/src/app/roadmap/roadmap-data.ts,/panel-tuner.html,site,,yes +web/src/app/roadmap/roadmap-data.ts,/pattern,site,,yes +web/src/app/roadmap/roadmap-data.ts,/pattern-lab,site,,yes +web/src/app/roadmap/roadmap-data.ts,BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes +web/src/app/roadmap/roadmap-data.ts,CHANGELOG.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,docs/EDITIONS.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,docs/osc-spec.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,docs/panel-compatibility.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,docs/releases/v2.0.0.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,firmware/CUSTOM_PATTERNS.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,integrations/ableton,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v1.0.0:docs/BUILD.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v1.0.0:firmware/patternflow_v1,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v1.0.0:hardware/case,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v1.0.0:hardware/pcb,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.0.0:docs/BUILD.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.1.0:BUILD_GUIDE.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.1.0:firmware/patternflow,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.1.0:hardware/case/source,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.1.0:hardware/pcb,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v3.0.0:BUILD_GUIDE.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v3.0.0:hardware/bom/bom_v3.0.csv,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v3.0.0:hardware/case,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v3.0.0:hardware/pcb,absolute-blob,,yes +web/src/app/robots.ts,/,site,,yes +web/src/app/robots.ts,/sitemap.xml,site,,yes +web/src/app/sitemap.ts,/,site,,yes +web/src/app/sitemap.ts,/build,site,,yes +web/src/app/sitemap.ts,/compliance,site,,yes +web/src/app/sitemap.ts,/contact,site,,yes +web/src/app/sitemap.ts,/editions,site,,yes +web/src/app/sitemap.ts,/features,site,,yes +web/src/app/sitemap.ts,/guide,site,,yes +web/src/app/sitemap.ts,/guide/audio,site,,yes +web/src/app/sitemap.ts,/guide/audio/ko,site,,yes +web/src/app/sitemap.ts,/guide/build,site,,yes +web/src/app/sitemap.ts,/guide/build/ko,site,,yes +web/src/app/sitemap.ts,/guide/ko,site,,yes +web/src/app/sitemap.ts,/guide/make,site,,yes +web/src/app/sitemap.ts,/guide/make/ko,site,,yes +web/src/app/sitemap.ts,/guide/play,site,,yes +web/src/app/sitemap.ts,/guide/play/ko,site,,yes +web/src/app/sitemap.ts,/inside,site,,yes +web/src/app/sitemap.ts,/journal,site,,yes +web/src/app/sitemap.ts,/journal/en,site,,yes +web/src/app/sitemap.ts,/pattern,site,,yes +web/src/app/sitemap.ts,/roadmap,site,,yes +web/src/app/sitemap.ts,/terms,site,,yes +web/src/app/terms/page.tsx,docs/LICENSE-SUMMARY.md,absolute-blob,,yes +web/src/components/guide/build/build.test.ts,BUILD_GUIDE.md,absolute-blob,1-bill-of-materials-bom,yes +web/src/components/guide/build/build.test.ts,BUILD_GUIDE.md,absolute-blob,10-known-issues--design-notes,yes +web/src/components/guide/build/build.test.ts,docs/panel-compatibility.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,docs/AUDIO_GUIDE.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,docs/director-midi.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,docs/midi-spec.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,docs/osc-spec.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,tools/patternflow-audio-android/README.md,absolute-blob,,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,1-bill-of-materials-bom,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,10-known-issues--design-notes,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,4-3d-printing,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,6-case-assembly,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,what-you-also-need-not-in-bom,yes +web/src/components/guide/copy/build.ts,docs/panel-compatibility.md,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case/bed_256mm/encloser.stl,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case/bed_256mm/for_other_panels,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case/bed_256mm/patternflow_v3.3mf,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case/bed_330mm/encloser.stl,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/pcb/gerber/patternflow_v3.9_gerber.zip,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/pcb/kicad,absolute-blob,,yes +web/src/components/guide/desk/ai/headers.ts,/pattern-lab,site,,yes +web/src/components/sections/BuildPanel.test.tsx,docs/legacy/BUILD_GUIDE_v2.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,docs/panel-compatibility.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/README.md,absolute-blob,for_other_panels--using-a-different-led-panel,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/besoiobiy-printed,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/besoiobiy-printed/README.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/besoiobiy-printed/README.md,absolute-blob,check-your-panel-first,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/besoiobiy-printed/print_layout.3mf,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/mbchars-horizontal-desktop-printed,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/simonepda-lasercut,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/simonepda-lasercut/README.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,BUILD_GUIDE.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,docs/assembly/README.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,docs/legacy/BUILD_GUIDE_v2.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,docs/panel-compatibility.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,hardware/README.md,absolute-blob,,yes +web/src/components/sections/PatternPanel.tsx,BUILD_GUIDE.md,absolute-blob,8-firmware,yes +web/src/components/sections/PatternPanel.tsx,firmware/README.md,absolute-blob,,yes +web/src/lib/ai/gemini.ts,/pattern-lab,site,,yes +web/src/lib/pattern/packs.ts,/,site,,yes +web/src/lib/pattern/share.ts,/community,site,,yes +web/src/lib/pattern/share.ts,/pattern,site,,yes +web/src/lib/pattern/share.ts,/pattern-lab,site,,yes +web/src/lib/presets/README.md,/pattern-lab,site,,yes +web/src/lib/presets/README.md,firmware/modules/README.md,relative,,yes +web/src/lib/presets/README.md,firmware/patternflow/presets/README.md,relative,,yes +web/src/lib/presets/README.md,web/ARCHITECTURE.md,relative,pattern-system,yes +web/src/lib/presets/pattern-0624.ts,/,site,,yes +web/src/lib/presets/pattern-0624.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0628.ts,/,site,,yes +web/src/lib/presets/pattern-0628.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0629-2.ts,/,site,,yes +web/src/lib/presets/pattern-0629.ts,/,site,,yes +web/src/lib/presets/pattern-0701.ts,/,site,,yes +web/src/lib/presets/pattern-0707.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0710.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0712-2.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0712.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0713.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0715.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0716.ts,/pattern,site,,yes +web/src/lib/presets/pattern-0718.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0719.ts,/pattern-lab,site,,yes diff --git a/docs/investigations/2026-10-doc-parity.csv b/docs/investigations/2026-10-doc-parity.csv new file mode 100644 index 00000000..3765b95e --- /dev/null +++ b/docs/investigations/2026-10-doc-parity.csv @@ -0,0 +1,1367 @@ +kind,en,ko,en_chars,ko_chars,state +copy,web/src/components/community/AtlasClient.tsx#UI.en.addPattern,web/src/components/community/AtlasClient.tsx#UI.ko.addPattern,12,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.allChip,web/src/components/community/AtlasClient.tsx#UI.ko.allChip,3,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.axisChaos,web/src/components/community/AtlasClient.tsx#UI.ko.axisChaos,6,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.axisField,web/src/components/community/AtlasClient.tsx#UI.ko.axisField,6,7,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.axisOrder,web/src/components/community/AtlasClient.tsx#UI.ko.axisOrder,6,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.axisWire,web/src/components/community/AtlasClient.tsx#UI.ko.axisWire,5,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.cancel,web/src/components/community/AtlasClient.tsx#UI.ko.cancel,6,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.copiedLayout,web/src/components/community/AtlasClient.tsx#UI.ko.copiedLayout,12,9,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.copiedPrompt,web/src/components/community/AtlasClient.tsx#UI.ko.copiedPrompt,37,23,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.copyLayout,web/src/components/community/AtlasClient.tsx#UI.ko.copyLayout,10,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.copyPrompt,web/src/components/community/AtlasClient.tsx#UI.ko.copyPrompt,10,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.edit,web/src/components/community/AtlasClient.tsx#UI.ko.edit,10,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.editing,web/src/components/community/AtlasClient.tsx#UI.ko.editing,18,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.fileResearch,web/src/components/community/AtlasClient.tsx#UI.ko.fileResearch,27,15,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.introTitle,web/src/components/community/AtlasClient.tsx#UI.ko.introTitle,18,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.knobs,web/src/components/community/AtlasClient.tsx#UI.ko.knobs,5,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.legendPin,web/src/components/community/AtlasClient.tsx#UI.ko.legendPin,39,15,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.linkNone,web/src/components/community/AtlasClient.tsx#UI.ko.linkNone,6,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.madeFrom,web/src/components/community/AtlasClient.tsx#UI.ko.madeFrom,14,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.moveHint,web/src/components/community/AtlasClient.tsx#UI.ko.moveHint,32,17,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.newBadge,web/src/components/community/AtlasClient.tsx#UI.ko.newBadge,3,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.newChip,web/src/components/community/AtlasClient.tsx#UI.ko.newChip,4,5,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.openPattern,web/src/components/community/AtlasClient.tsx#UI.ko.openPattern,12,5,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.patternsFrom,web/src/components/community/AtlasClient.tsx#UI.ko.patternsFrom,22,11,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.pickerHead,web/src/components/community/AtlasClient.tsx#UI.ko.pickerHead,26,9,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.pickerSearch,web/src/components/community/AtlasClient.tsx#UI.ko.pickerSearch,14,7,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.pinChip,web/src/components/community/AtlasClient.tsx#UI.ko.pinChip,6,1,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.placeBanner,web/src/components/community/AtlasClient.tsx#UI.ko.placeBanner,35,15,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.placeBannerResearch,web/src/components/community/AtlasClient.tsx#UI.ko.placeBannerResearch,45,17,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.privateTag,web/src/components/community/AtlasClient.tsx#UI.ko.privateTag,16,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.promotePin,web/src/components/community/AtlasClient.tsx#UI.ko.promotePin,18,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.removePin,web/src/components/community/AtlasClient.tsx#UI.ko.removePin,16,7,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.researchBadge,web/src/components/community/AtlasClient.tsx#UI.ko.researchBadge,12,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.researchFrom,web/src/components/community/AtlasClient.tsx#UI.ko.researchFrom,17,5,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.researchHint,web/src/components/community/AtlasClient.tsx#UI.ko.researchHint,44,25,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secImpl,web/src/components/community/AtlasClient.tsx#UI.ko.secImpl,14,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secKnob,web/src/components/community/AtlasClient.tsx#UI.ko.secKnob,28,9,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secPrompt,web/src/components/community/AtlasClient.tsx#UI.ko.secPrompt,33,18,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secRisk,web/src/components/community/AtlasClient.tsx#UI.ko.secRisk,7,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secTexture,web/src/components/community/AtlasClient.tsx#UI.ko.secTexture,7,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secVert,web/src/components/community/AtlasClient.tsx#UI.ko.secVert,12,3,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.tagline,web/src/components/community/AtlasClient.tsx#UI.ko.tagline,42,18,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.zoomHint,web/src/components/community/AtlasClient.tsx#UI.ko.zoomHint,41,16,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.label.en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.label.ko,41,24,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[0].alt.ko,103,64,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[0].ko,41,29,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[1].alt.en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[1].alt.ko,129,76,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[1].en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[1].ko,40,24,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.label.en,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.label.ko,29,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.shots[0].alt.ko,112,64,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.shots[0].ko,44,29,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.label.en,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.label.ko,42,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.shots[0].alt.ko,171,121,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.shots[0].ko,71,42,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.label.en,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.label.ko,22,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.shots[0].alt.ko,134,87,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.shots[0].ko,69,43,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micPart.label.en,web/src/components/guide/AudioShots.tsx#SETS.micPart.label.ko,21,9,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micPart.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.micPart.shots[0].alt.ko,104,61,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micPart.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.micPart.shots[0].ko,66,49,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micSeated.label.en,web/src/components/guide/AudioShots.tsx#SETS.micSeated.label.ko,27,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micSeated.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.micSeated.shots[0].alt.ko,163,77,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micSeated.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.micSeated.shots[0].ko,118,64,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micWiring.label.en,web/src/components/guide/AudioShots.tsx#SETS.micWiring.label.ko,40,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micWiring.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.micWiring.shots[0].alt.ko,163,81,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micWiring.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.micWiring.shots[0].ko,88,50,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiPage.label.en,web/src/components/guide/AudioShots.tsx#SETS.midiPage.label.ko,31,20,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiPage.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.midiPage.shots[0].alt.ko,168,111,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiPage.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.midiPage.shots[0].ko,37,27,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiSession.label.en,web/src/components/guide/AudioShots.tsx#SETS.midiSession.label.ko,28,17,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiSession.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.midiSession.shots[0].alt.ko,171,105,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiSession.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.midiSession.shots[0].ko,79,50,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.label.en,web/src/components/guide/AudioShots.tsx#SETS.popup.label.ko,39,17,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[0].alt.ko,109,63,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[0].ko,38,26,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[1].alt.en,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[1].alt.ko,130,57,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[1].en,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[1].ko,41,27,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.label.en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.label.ko,42,19,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[0].alt.ko,125,66,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[0].ko,46,28,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[1].alt.en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[1].alt.ko,104,51,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[1].en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[1].ko,43,22,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.label.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.label.ko,22,11,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[0].alt.ko,83,59,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[0].ko,45,35,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[1].alt.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[1].alt.ko,116,60,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[1].en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[1].ko,41,24,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[2].alt.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[2].alt.ko,105,73,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[2].en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[2].ko,44,34,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[3].alt.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[3].alt.ko,71,48,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[3].en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[3].ko,44,28,ok +copy,web/src/components/guide/AudioShots.tsx#UI.en.from,web/src/components/guide/AudioShots.tsx#UI.ko.from,10,3,ok +copy,web/src/components/guide/AudioShots.tsx#UI.en.leads,web/src/components/guide/AudioShots.tsx#UI.ko.leads,49,20,ok +copy,web/src/components/guide/AudioShots.tsx#UI.en.step,web/src/components/guide/AudioShots.tsx#UI.ko.step,6,2,ok +copy,web/src/components/guide/CommunityShots.tsx#DECK.en,web/src/components/guide/CommunityShots.tsx#DECK.ko,11,4,ok +copy,web/src/components/guide/CommunityShots.tsx#DECK_BAR.en,web/src/components/guide/CommunityShots.tsx#DECK_BAR.ko,17,4,ok +copy,web/src/components/guide/CommunityShots.tsx#PATTERN.en,web/src/components/guide/CommunityShots.tsx#PATTERN.ko,14,5,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[0][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[0][0].ko,61,32,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[0][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[0][1].ko,57,40,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[0][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[0][2].ko,71,34,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[1][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[1][0].ko,65,28,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[1][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[1][1].ko,39,18,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[1][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[1][2].ko,81,50,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[2][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[2][0].ko,45,25,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[2][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[2][1].ko,81,36,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[2][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[2][2].ko,90,55,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][0].ko,41,22,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][1].ko,64,27,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][2].ko,58,27,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][3].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][3].ko,70,32,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][4].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][4].ko,80,41,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[4][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[4][0].ko,66,33,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[4][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[4][1].ko,75,41,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[4][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[4][2].ko,65,33,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[4][3].en,web/src/components/guide/CommunityShots.tsx#STEPS[4][3].ko,69,39,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[5][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[5][0].ko,38,21,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[5][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[5][1].ko,66,29,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[5][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[5][2].ko,53,22,ok +copy,web/src/components/guide/CommunityShots.tsx#UI.en.label,web/src/components/guide/CommunityShots.tsx#UI.ko.label,27,8,ok +copy,web/src/components/guide/CommunityShots.tsx#UI.en.step,web/src/components/guide/CommunityShots.tsx#UI.ko.step,6,2,ok +copy,web/src/components/guide/ConsoleWindow.tsx#UI.en.close,web/src/components/guide/ConsoleWindow.tsx#UI.ko.close,5,2,ok +copy,web/src/components/guide/ConsoleWindow.tsx#UI.en.label,web/src/components/guide/ConsoleWindow.tsx#UI.ko.label,16,4,ok +copy,web/src/components/guide/ConsoleWindow.tsx#UI.en.open,web/src/components/guide/ConsoleWindow.tsx#UI.ko.open,4,2,ok +copy,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.en.caption,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.ko.caption,79,56,en-short +copy,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.en.close,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.ko.close,5,2,ok +copy,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.en.label,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.ko.label,16,5,ok +copy,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.en.open,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.ko.open,4,2,ok +copy,web/src/components/guide/FlasherShots.tsx#CONNECTED.en,web/src/components/guide/FlasherShots.tsx#CONNECTED.ko,49,29,ok +copy,web/src/components/guide/FlasherShots.tsx#CONNECTING.en,web/src/components/guide/FlasherShots.tsx#CONNECTING.ko,37,18,ok +copy,web/src/components/guide/FlasherShots.tsx#SCANNING.en,web/src/components/guide/FlasherShots.tsx#SCANNING.ko,62,35,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.dashboard[0].en,web/src/components/guide/FlasherShots.tsx#SETS.dashboard[0].ko,104,68,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.dashboard[2].en,web/src/components/guide/FlasherShots.tsx#SETS.dashboard[2].ko,92,62,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[0].en,web/src/components/guide/FlasherShots.tsx#SETS.install[0].ko,64,36,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[1].en,web/src/components/guide/FlasherShots.tsx#SETS.install[1].ko,43,29,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[2].en,web/src/components/guide/FlasherShots.tsx#SETS.install[2].ko,41,25,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[3].en,web/src/components/guide/FlasherShots.tsx#SETS.install[3].ko,43,20,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[4].en,web/src/components/guide/FlasherShots.tsx#SETS.install[4].ko,90,48,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[5].en,web/src/components/guide/FlasherShots.tsx#SETS.install[5].ko,116,62,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.wifi[0].en,web/src/components/guide/FlasherShots.tsx#SETS.wifi[0].ko,105,48,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.wifi[2].en,web/src/components/guide/FlasherShots.tsx#SETS.wifi[2].ko,90,47,ok +copy,web/src/components/guide/FlasherShots.tsx#SET_LABEL.dashboard.en,web/src/components/guide/FlasherShots.tsx#SET_LABEL.dashboard.ko,16,8,ok +copy,web/src/components/guide/FlasherShots.tsx#SET_LABEL.install.en,web/src/components/guide/FlasherShots.tsx#SET_LABEL.install.ko,7,2,ok +copy,web/src/components/guide/FlasherShots.tsx#SET_LABEL.wifi.en,web/src/components/guide/FlasherShots.tsx#SET_LABEL.wifi.ko,19,9,ok +copy,web/src/components/guide/FlasherShots.tsx#UI.en.label,web/src/components/guide/FlasherShots.tsx#UI.ko.label,26,15,ok +copy,web/src/components/guide/FlasherShots.tsx#UI.en.sets,web/src/components/guide/FlasherShots.tsx#UI.ko.sets,19,8,ok +copy,web/src/components/guide/FlasherShots.tsx#UI.en.step,web/src/components/guide/FlasherShots.tsx#UI.ko.step,6,2,ok +copy,web/src/components/guide/LabShots.tsx#UI.en.label,web/src/components/guide/LabShots.tsx#UI.ko.label,25,7,ok +copy,web/src/components/guide/LabShots.tsx#UI.en.step,web/src/components/guide/LabShots.tsx#UI.ko.step,6,2,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.apply.en,web/src/components/guide/LabShots.tsx#WORDS.apply.ko,107,64,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.blend.en,web/src/components/guide/LabShots.tsx#WORDS.blend.ko,96,60,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.bw.en,web/src/components/guide/LabShots.tsx#WORDS.bw.ko,65,40,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.color.en,web/src/components/guide/LabShots.tsx#WORDS.color.ko,109,62,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.convert.en,web/src/components/guide/LabShots.tsx#WORDS.convert.ko,118,58,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.copyPrompt.en,web/src/components/guide/LabShots.tsx#WORDS.copyPrompt.ko,46,22,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.director.en,web/src/components/guide/LabShots.tsx#WORDS.director.ko,146,77,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.exportGo.en,web/src/components/guide/LabShots.tsx#WORDS.exportGo.ko,54,39,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.exportSize.en,web/src/components/guide/LabShots.tsx#WORDS.exportSize.ko,70,56,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.gallery.en,web/src/components/guide/LabShots.tsx#WORDS.gallery.ko,106,63,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.hwLayers.en,web/src/components/guide/LabShots.tsx#WORDS.hwLayers.ko,87,51,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.knobs.en,web/src/components/guide/LabShots.tsx#WORDS.knobs.ko,80,33,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.layers.en,web/src/components/guide/LabShots.tsx#WORDS.layers.ko,69,35,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.mask.en,web/src/components/guide/LabShots.tsx#WORDS.mask.ko,82,56,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.name.en,web/src/components/guide/LabShots.tsx#WORDS.name.ko,82,54,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.paste.en,web/src/components/guide/LabShots.tsx#WORDS.paste.ko,74,31,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.pasted.en,web/src/components/guide/LabShots.tsx#WORDS.pasted.ko,83,57,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.perf.en,web/src/components/guide/LabShots.tsx#WORDS.perf.ko,89,47,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.port.en,web/src/components/guide/LabShots.tsx#WORDS.port.ko,89,43,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.previewLayers.en,web/src/components/guide/LabShots.tsx#WORDS.previewLayers.ko,24,21,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.push.en,web/src/components/guide/LabShots.tsx#WORDS.push.ko,55,27,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.ramp.en,web/src/components/guide/LabShots.tsx#WORDS.ramp.ko,40,21,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.rampRandom.en,web/src/components/guide/LabShots.tsx#WORDS.rampRandom.ko,71,40,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.recent.en,web/src/components/guide/LabShots.tsx#WORDS.recent.ko,72,41,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.saved.en,web/src/components/guide/LabShots.tsx#WORDS.saved.ko,65,35,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.upload.en,web/src/components/guide/LabShots.tsx#WORDS.upload.ko,138,76,ok +copy,web/src/components/guide/LabWindow.tsx#LAB_WINDOW_WORDS.en.open,web/src/components/guide/LabWindow.tsx#LAB_WINDOW_WORDS.ko.open,4,2,ok +copy,web/src/components/guide/LabWindow.tsx#LAB_WINDOW_WORDS.en.phoneOpen,web/src/components/guide/LabWindow.tsx#LAB_WINDOW_WORDS.ko.phoneOpen,14,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.lede,web/src/components/guide/copy.ts#COPY.ko.console.lede,56,28,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[0].body[0],web/src/components/guide/copy.ts#COPY.ko.console.steps[0].body[0],127,75,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[0].body[1],web/src/components/guide/copy.ts#COPY.ko.console.steps[0].body[1],105,60,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[0].title,web/src/components/guide/copy.ts#COPY.ko.console.steps[0].title,17,9,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[1].body[0],web/src/components/guide/copy.ts#COPY.ko.console.steps[1].body[0],109,64,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[1].body[1],web/src/components/guide/copy.ts#COPY.ko.console.steps[1].body[1],37,21,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[1].kicker,web/src/components/guide/copy.ts#COPY.ko.console.steps[1].kicker,8,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[1].title,web/src/components/guide/copy.ts#COPY.ko.console.steps[1].title,18,9,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[2].body[0],web/src/components/guide/copy.ts#COPY.ko.console.steps[2].body[0],157,76,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[2].kicker,web/src/components/guide/copy.ts#COPY.ko.console.steps[2].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[2].title,web/src/components/guide/copy.ts#COPY.ko.console.steps[2].title,11,7,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.title,web/src/components/guide/copy.ts#COPY.ko.console.title,10,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.lede,web/src/components/guide/copy.ts#COPY.ko.flash.lede,84,43,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[0].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[0].body[0],137,63,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[0].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[0].kicker,8,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[0].note,web/src/components/guide/copy.ts#COPY.ko.flash.steps[0].note,58,29,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[0].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[0].title,30,16,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[1].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[1].body[0],101,44,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[1].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[1].kicker,14,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[1].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[1].title,25,15,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].body[0],93,43,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].body[1],87,39,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].kicker,11,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].title,19,7,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].warn,web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].warn,73,34,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[3].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[3].body[0],130,73,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[3].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[3].kicker,12,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[3].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[3].title,22,22,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[3].warn,web/src/components/guide/copy.ts#COPY.ko.flash.steps[3].warn,201,105,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[4].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[4].body[0],155,101,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[4].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[4].body[1],58,35,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[4].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[4].kicker,5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[4].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[4].title,17,23,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].body[0],119,56,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].body[1],225,119,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].kicker,5,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].title,22,14,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].warn,web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].warn,93,58,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].body[0],136,64,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].body[1],65,35,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].kicker,5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].note,web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].note,66,32,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].title,15,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].body[0],167,113,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].body[1],124,72,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].body[2],web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].body[2],142,68,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].kicker,13,10,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].title,25,13,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].warn,web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].warn,160,77,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.title,web/src/components/guide/copy.ts#COPY.ko.flash.title,12,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.lede,web/src/components/guide/copy.ts#COPY.ko.knobs.lede,41,20,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[0].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[0].body[0],70,35,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[0].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[0].kicker,10,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[0].note,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[0].note,82,52,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[0].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[0].title,23,11,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].body[0],105,53,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].body[1],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].body[1],45,21,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].body[2],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].body[2],156,77,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].kicker,4,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].title,27,11,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[2].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[2].body[0],126,66,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[2].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[2].kicker,5,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[2].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[2].title,18,9,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[3].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[3].body[0],120,58,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[3].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[3].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[3].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[3].title,11,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[4].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[4].body[0],109,48,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[4].body[1],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[4].body[1],48,28,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[4].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[4].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[4].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[4].title,11,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[5].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[5].body[0],73,41,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[5].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[5].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[5].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[5].title,17,10,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[6].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[6].body[0],100,55,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[6].body[1],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[6].body[1],29,20,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[6].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[6].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[6].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[6].title,9,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.title,web/src/components/guide/copy.ts#COPY.ko.knobs.title,9,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.langSwitch.label,web/src/components/guide/copy.ts#COPY.ko.langSwitch.label,3,7,ok +copy,web/src/components/guide/copy.ts#COPY.en.meta.description,web/src/components/guide/copy.ts#COPY.ko.meta.description,131,62,ok +copy,web/src/components/guide/copy.ts#COPY.en.meta.title,web/src/components/guide/copy.ts#COPY.ko.meta.title,20,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.name,web/src/components/guide/copy.ts#COPY.ko.name,4,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[0].items[0],web/src/components/guide/copy.ts#COPY.ko.next.groups[0].items[0],26,9,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[0].items[1],web/src/components/guide/copy.ts#COPY.ko.next.groups[0].items[1],40,19,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[0].label,web/src/components/guide/copy.ts#COPY.ko.next.groups[0].label,12,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[1].items[0],web/src/components/guide/copy.ts#COPY.ko.next.groups[1].items[0],38,20,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[1].label,web/src/components/guide/copy.ts#COPY.ko.next.groups[1].label,13,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[2].items[0],web/src/components/guide/copy.ts#COPY.ko.next.groups[2].items[0],8,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[2].items[2],web/src/components/guide/copy.ts#COPY.ko.next.groups[2].items[2],5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[2].items[3],web/src/components/guide/copy.ts#COPY.ko.next.groups[2].items[3],11,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[2].label,web/src/components/guide/copy.ts#COPY.ko.next.groups[2].label,9,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.lede,web/src/components/guide/copy.ts#COPY.ko.next.lede,90,45,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.report.where,web/src/components/guide/copy.ts#COPY.ko.next.report.where,31,14,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.title,web/src/components/guide/copy.ts#COPY.ko.next.title,17,10,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.until,web/src/components/guide/copy.ts#COPY.ko.next.until,31,14,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.back.label,web/src/components/guide/copy.ts#COPY.ko.opening.back.label,9,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.chapters[0],web/src/components/guide/copy.ts#COPY.ko.opening.chapters[0],5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.chapters[1],web/src/components/guide/copy.ts#COPY.ko.opening.chapters[1],5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.chapters[2],web/src/components/guide/copy.ts#COPY.ko.opening.chapters[2],8,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.chapters[3],web/src/components/guide/copy.ts#COPY.ko.opening.chapters[3],7,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.kicker,web/src/components/guide/copy.ts#COPY.ko.opening.kicker,13,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.lede,web/src/components/guide/copy.ts#COPY.ko.opening.lede,143,61,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.scroll,web/src/components/guide/copy.ts#COPY.ko.opening.scroll,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.title,web/src/components/guide/copy.ts#COPY.ko.opening.title,12,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.lede,web/src/components/guide/copy.ts#COPY.ko.patterns.lede,63,34,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[0].body[0],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[0].body[0],123,55,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[0].title,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[0].title,23,12,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[1].body[0],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[1].body[0],128,63,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[1].body[1],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[1].body[1],105,63,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[1].title,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[1].title,24,13,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].body[0],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].body[0],144,75,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].body[1],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].body[1],123,60,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].kicker,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].kicker,7,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].title,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].title,17,23,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].warn,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].warn,91,65,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[3].body[0],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[3].body[0],59,40,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[3].note,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[3].note,68,27,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[3].title,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[3].title,12,7,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.title,web/src/components/guide/copy.ts#COPY.ko.patterns.title,12,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.bootSteps[0],web/src/components/guide/copy.ts#COPY.ko.ui.bootSteps[0],8,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.bootSteps[1],web/src/components/guide/copy.ts#COPY.ko.ui.bootSteps[1],6,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.bootSteps[2],web/src/components/guide/copy.ts#COPY.ko.ui.bootSteps[2],11,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.flashUnsupported,web/src/components/guide/copy.ts#COPY.ko.ui.flashUnsupported,35,18,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.install.confirm,web/src/components/guide/copy.ts#COPY.ko.ui.install.confirm,86,86,missing-ko +copy,web/src/components/guide/copy.ts#COPY.en.ui.noteBy,web/src/components/guide/copy.ts#COPY.ko.ui.noteBy,12,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.openDecks,web/src/components/guide/copy.ts#COPY.ko.ui.openDecks,12,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.pages[0].body,web/src/components/guide/copy.ts#COPY.ko.ui.pages[0].body,64,33,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.pages[1].body,web/src/components/guide/copy.ts#COPY.ko.ui.pages[1].body,39,23,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.pages[2].body,web/src/components/guide/copy.ts#COPY.ko.ui.pages[2].body,42,15,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.pages[3].body,web/src/components/guide/copy.ts#COPY.ko.ui.pages[3].body,38,21,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.replay,web/src/components/guide/copy.ts#COPY.ko.ui.replay,6,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.report.general,web/src/components/guide/copy.ts#COPY.ko.ui.report.general,25,14,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.report.generalWhere,web/src/components/guide/copy.ts#COPY.ko.ui.report.generalWhere,27,12,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.report.hint,web/src/components/guide/copy.ts#COPY.ko.ui.report.hint,44,19,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.report.step,web/src/components/guide/copy.ts#COPY.ko.ui.report.step,16,13,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.tourLabel,web/src/components/guide/copy.ts#COPY.ko.ui.tourLabel,12,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.wip,web/src/components/guide/copy.ts#COPY.ko.ui.wip,62,24,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.lede,39,17,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.needs.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.needs.label,30,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].body[0],102,77,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].body[1],145,163,en-short +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].body[2],104,77,en-short +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].kicker,12,6,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].title,24,11,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].body[0],166,95,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].body[1],154,89,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].kicker,5,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].note,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].note,133,70,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].title,22,10,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].body[0],98,61,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].body[1],129,66,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].body[2],96,47,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].kicker,8,9,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].title,20,12,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].body[0],187,102,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].body[1],167,93,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].body[2],132,69,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].kicker,9,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].title,28,16,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.title,11,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.lede,60,33,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[0].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[0].body[0],175,86,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[0].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[0].body[1],117,57,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[0].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[0].kicker,9,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[0].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[0].title,26,13,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].body[0],161,112,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].body[1],74,44,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].body[2],104,58,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].kicker,7,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].title,17,23,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].warn,104,69,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].body[0],84,44,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].body[1],106,79,en-short +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].body[2],100,57,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].kicker,5,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].title,18,14,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.title,15,6,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.meta.description,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.meta.description,149,71,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.meta.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.meta.title,25,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.lede,69,38,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.needs.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.needs.label,30,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[0].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[0].body[0],137,101,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[0].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[0].kicker,7,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[0].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[0].title,26,14,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[0].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[0].warn,108,62,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[1].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[1].body[0],130,64,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[1].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[1].body[1],45,18,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[1].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[1].kicker,8,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[1].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[1].title,10,7,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].body[0],109,63,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].body[1],123,56,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].kicker,20,7,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].title,19,8,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].warn,77,40,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[3].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[3].body[0],144,64,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[3].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[3].kicker,6,4,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[3].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[3].title,30,17,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[3].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[3].warn,92,53,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[4].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[4].body[0],127,84,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[4].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[4].body[1],76,52,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[4].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[4].kicker,10,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[4].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[4].title,25,20,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].body[0],200,111,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].body[1],72,32,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].kicker,10,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].note,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].note,137,72,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].title,26,13,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].body[0],87,61,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].body[1],76,52,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].body[2],98,63,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].body[3],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].body[3],103,53,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].kicker,6,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].title,16,6,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.title,13,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.lede,97,51,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.needs.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.needs.label,30,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].body[0],124,73,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].body[1],102,85,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].body[2],92,63,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].kicker,7,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].title,24,16,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].warn,101,67,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].body[0],110,80,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].body[1],135,92,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].body[2],109,78,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].title,19,17,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].warn,59,47,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].body[0],79,37,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].body[1],113,74,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].body[2],117,84,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].body[3],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].body[3],166,105,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].kicker,6,1,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].note,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].note,144,92,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].title,30,14,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].body[0],186,141,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].body[1],137,94,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].body[2],57,47,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].kicker,8,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].title,21,11,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[4].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[4].body[0],181,100,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[4].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[4].body[1],136,85,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[4].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[4].kicker,13,4,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[4].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[4].title,16,22,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.title,12,8,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.name,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.name,5,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.lede,164,85,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.links[1].label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.links[1].label,11,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.links[2].label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.links[2].label,19,10,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.links[3].label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.links[3].label,10,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.links[6].label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.links[6].label,9,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.report.where,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.report.where,32,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.title,17,12,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.until,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.until,39,18,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.back.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.back.label,9,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.chapters[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.chapters[0],15,6,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.chapters[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.chapters[1],11,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.chapters[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.chapters[2],13,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.chapters[3],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.chapters[3],12,8,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.groups.first,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.groups.first,5,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.groups.rest,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.groups.rest,15,8,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.kicker,14,7,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.lede,180,91,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.scroll,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.scroll,6,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.title,19,12,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.ui.editions.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.ui.editions.label,29,27,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.guide.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.guide.label,26,17,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.labels.mpn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.labels.mpn,7,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.labels.name,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.labels.name,8,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.labels.spec,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.labels.spec,4,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.missing,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.missing,25,16,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.more.M4 screw,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.more.M4 screw,68,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.more.SW1-SW4,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.more.SW1-SW4,92,61,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.more.U1,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.more.U1,65,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.panel.listing.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.panel.listing.label,18,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.panel.note,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.panel.note,51,25,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.panel.other.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.panel.other.label,18,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.source,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.source,10,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.C11,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.C11,16,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.J1,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.J1,20,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.J3,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.J3,17,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.J4,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.J4,18,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.M4 screw,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.M4 screw,34,23,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.SW1-SW4,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.SW1-SW4,26,16,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.U1,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.U1,22,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.U1 (sockets),web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.U1 (sockets),21,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.USB cable (sacrificial),web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.USB cable (sacrificial),35,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.USB power bank,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.USB power bank,32,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.head[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.head[0],7,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.head[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.head[1],5,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.links[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.links[2].label,15,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[0].note,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[0].note,16,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[1].bed,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[1].bed,10,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[1].note,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[1].note,55,36,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[2].bed,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[2].bed,12,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[2].note,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[2].note,61,28,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settingsLabel,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settingsLabel,13,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[0],11,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[1],11,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[2],24,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[3],7,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[4],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[4],9,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.checks.checkKnobs[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.checks.checkKnobs[0],16,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.checks.checkKnobs[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.checks.checkKnobs[1],16,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.checks.checkKnobs[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.checks.checkKnobs[2],16,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.checks.checkKnobs[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.checks.checkKnobs[3],16,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[0].label,19,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[0].value,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[0].value,6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[1].label,10,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[1].value,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[1].value,3,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[2].label,22,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[2].value,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[2].value,2,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.handoff.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.handoff.label,14,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.handoff.line,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.handoff.line,124,52,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.knobMap.screens[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.knobMap.screens[0],10,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.knobMap.screens[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.knobMap.screens[1],28,22,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.knobMap.screens[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.knobMap.screens[2],16,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.knobMap.screens[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.knobMap.screens[3],13,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksBack[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksBack[0].label,39,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksGpio0[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksGpio0[0].label,27,18,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksGpio0[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksGpio0[1].label,25,19,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksPcb[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksPcb[0].label,19,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksPcb[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksPcb[1].label,14,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksPcb[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksPcb[2].label,11,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksPlay03[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksPlay03[0].label,19,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksWiring[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksWiring[0].label,34,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.order.skip,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.order.skip,72,56,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.order.steps[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.order.steps[0],7,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.order.steps[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.order.steps[3],8,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.order.video.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.order.video.label,26,9,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.black,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.black,5,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.j3,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.j3,13,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.j4,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.j4,7,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.red,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.red,3,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.seen,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.seen,62,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[0].label,31,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[1].label,38,17,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[2].label,34,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[3].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[3].label,62,21,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[4].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[4].label,47,20,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[5].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[5].label,46,21,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[6].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[6].label,33,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.kept,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.kept,27,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.lede,60,29,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[0].body[0],85,54,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[0].kicker,10,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[0].title,45,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[1].body[0],117,63,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[1].kicker,11,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[1].title,32,16,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[1].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[1].warn,118,68,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[2].body[0],25,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[2].kicker,10,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[2].title,12,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[3].body[0],104,55,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[3].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[3].kicker,13,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[3].title,22,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.title,14,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.lede,62,30,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[0].body[0],141,83,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[0].kicker,8,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[0].title,18,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[0].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[0].warn,97,65,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[1].body[0],135,77,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[1].kicker,7,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[1].title,30,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[2].body[0],120,84,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[2].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[2].body[1],54,25,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[2].kicker,5,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[2].title,27,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].body[0],150,68,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].body[1],83,47,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].body[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].body[2],62,35,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].kicker,9,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].title,23,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.title,26,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.lede,57,30,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[0].body[0],151,62,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[0].body[1],44,25,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[0].kicker,7,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[0].title,30,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[1].body[0],104,52,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[1].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[1].body[1],195,102,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[1].body[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[1].body[2],76,39,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[1].title,32,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[2].body[0],135,65,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[2].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[2].body[1],26,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[2].title,30,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[2].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[2].warn,65,35,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[3].body[0],157,85,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[3].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[3].body[1],30,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[3].title,33,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[3].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[3].warn,133,54,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.title,14,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.lede,65,29,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].body[0],126,70,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].body[1],100,51,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].kicker,8,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].title,28,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].warn,79,41,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[1].body[0],174,87,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[1].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[1].body[1],76,45,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[1].kicker,8,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[1].title,20,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].body[0],78,64,en-short +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].body[1],154,76,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].body[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].body[2],69,45,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].kicker,9,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].title,28,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].warn,58,40,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[3].body[0],159,71,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[3].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[3].kicker,8,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[3].title,22,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[4].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[4].body[0],135,47,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[4].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[4].body[1],34,26,en-short +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[4].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[4].kicker,4,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[4].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[4].title,19,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.title,11,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.lede,59,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[0].body[0],121,63,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[0].body[1],48,22,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[0].kicker,6,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[0].title,21,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[1].body[0],142,63,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[1].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[1].body[1],103,62,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[1].title,22,9,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[1].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[1].warn,61,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.title,6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.lede,44,27,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[0].body[0],179,109,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[0].body[1],207,138,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[0].kicker,5,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[0].title,22,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[1].body[0],142,65,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[1].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[1].body[1],78,40,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[1].kicker,6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[1].title,23,17,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].body[0],97,42,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].body[1],74,44,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].body[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].body[2],210,106,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].kicker,10,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].title,19,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.title,21,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.lede,70,41,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[0].body[0],109,65,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[0].body[1],39,24,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[0].kicker,12,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[0].title,19,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[1].body[0],75,40,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[1].kicker,4,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[1].title,23,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[2].body[0],129,69,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[2].kicker,10,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[2].title,13,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[3].body[0],106,46,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[3].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[3].body[1],54,21,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[3].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[3].kicker,12,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[3].title,27,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[4].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[4].body[0],78,40,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[4].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[4].body[1],25,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[4].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[4].kicker,4,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[4].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[4].title,18,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.title,13,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.meta.description,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.meta.description,162,71,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.meta.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.meta.title,17,9,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.name,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.name,5,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.groups[0].items[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.groups[0].items[0],21,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.groups[0].items[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.groups[0].items[1],25,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.groups[0].items[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.groups[0].items[2],31,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.groups[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.groups[0].label,12,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.lede,90,37,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.links[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.links[0].label,25,19,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.links[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.links[1].label,14,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.links[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.links[2].label,13,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.report.where,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.report.where,32,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.title,20,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.until,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.until,54,23,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.back.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.back.label,9,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[0],6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[1],11,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[2],6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[3],11,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[4],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[4],4,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[5],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[5],8,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[6],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[6],11,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.kicker,14,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.lede,167,66,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.scroll,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.scroll,6,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.title,20,11,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.lede,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.lede,64,34,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[0].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[0].body[0],161,97,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[0].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[0].body[1],81,40,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[0].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[0].kicker,7,1,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[0].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[0].title,21,10,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[1].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[1].body[0],127,63,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[1].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[1].body[1],58,42,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[1].note,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[1].note,138,72,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[1].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[1].title,20,14,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].body[0],159,98,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].body[1],41,22,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].kicker,10,4,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].title,19,10,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].warn,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].warn,62,39,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].body[0],145,77,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].body[1],119,69,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].kicker,8,2,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].title,19,8,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].warn,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].warn,135,88,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[4].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[4].body[0],172,115,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[4].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[4].body[1],61,42,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[4].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[4].kicker,10,3,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[4].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[4].title,20,12,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].body[0],123,69,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].body[1],126,79,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].kicker,11,4,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].note,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].note,105,49,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].title,15,7,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.title,12,4,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.guides.audio.situation,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.guides.audio.situation,27,19,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.guides.build.situation,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.guides.build.situation,32,15,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.guides.make.situation,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.guides.make.situation,23,17,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.guides.play.situation,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.guides.play.situation,23,13,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.kicker,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.kicker,8,3,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.meta.description,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.meta.description,142,66,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.meta.title,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.meta.title,19,8,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.report.label,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.report.label,12,10,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.report.where,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.report.where,27,12,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.title,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.title,17,12,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.lede,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.lede,70,40,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[0].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[0].body[0],180,107,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[0].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[0].body[1],161,73,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[0].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[0].title,11,6,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].body[0],137,99,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].body[1],147,83,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].kicker,12,4,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].note,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].note,108,52,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].title,11,4,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[1].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[1].body[0],114,65,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[1].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[1].body[1],70,41,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[1].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[1].kicker,7,2,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[1].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[1].title,12,8,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].body[0],117,68,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].body[1],162,80,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].kicker,7,4,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].title,20,11,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].warn,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].warn,101,58,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[3].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[3].body[0],119,74,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[3].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[3].body[1],126,66,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[3].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[3].title,21,7,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[4].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[4].body[0],90,48,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[4].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[4].body[1],160,75,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[4].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[4].kicker,5,2,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[4].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[4].title,19,10,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[5].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[5].body[0],130,88,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[5].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[5].body[1],39,25,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[5].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[5].title,26,15,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[6].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[6].body[0],105,89,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[6].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[6].body[1],175,117,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[6].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[6].title,14,8,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[6].warn,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[6].warn,120,71,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].body[0],135,88,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].body[1],84,55,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].kicker,6,3,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].note,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].note,156,79,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].title,10,4,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[8].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[8].body[0],135,92,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[8].note,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[8].note,110,56,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[8].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[8].title,17,6,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[9].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[9].body[0],120,65,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[9].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[9].body[1],80,47,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[9].note,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[9].note,180,89,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[9].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[9].title,21,8,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.title,14,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.meta.description,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.meta.description,194,78,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.meta.title,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.meta.title,30,11,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.name,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.name,4,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[0].items[0],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[0].items[0],38,20,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[0].label,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[0].label,13,6,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[1].items[0],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[1].items[0],8,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[1].items[2],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[1].items[2],5,2,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[1].items[3],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[1].items[3],11,4,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[1].label,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[1].label,16,10,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.lede,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.lede,105,51,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.report.where,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.report.where,31,15,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.title,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.title,12,8,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.until,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.until,31,14,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.back.label,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.back.label,9,5,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.chapters[0],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.chapters[0],9,4,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.chapters[1],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.chapters[1],10,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.kicker,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.kicker,13,7,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.lede,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.lede,132,61,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.scroll,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.scroll,6,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.title,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.title,12,7,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.conversion,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.conversion,172,76,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.copied,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.copied,7,4,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.copy,4,2,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.copyByHand,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.copyByHand,14,10,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.hello,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.hello,80,40,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.input,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.input,22,10,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.layer,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.layer,110,87,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.other,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.other,137,96,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.pasted,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.pasted,20,7,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.placeholder,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.placeholder,18,10,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.send,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.send,4,3,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.thinking,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.thinking,8,4,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.thread,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.thread,29,9,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.variation,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.variation,64,39,ok +copy,web/src/components/guide/desk/apps/ai.tsx#AI_APP.chrome.title.en,web/src/components/guide/desk/apps/ai.tsx#AI_APP.chrome.title.ko,21,13,ok +copy,web/src/components/guide/desk/apps/community.tsx#COMMUNITY_APP.chrome.badge.en,web/src/components/guide/desk/apps/community.tsx#COMMUNITY_APP.chrome.badge.ko,8,3,ok +copy,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.en.banner,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.ko.banner,94,55,ok +copy,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.en.close,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.ko.close,5,2,ok +copy,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.en.note,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.ko.note,1250,739,ok +copy,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.en.tag,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.ko.tag,8,2,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.again,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.again,11,4,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.bringForward,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.bringForward,15,5,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.deskLabel,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.deskLabel,12,3,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.done,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.done,4,4,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.labBadge,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.labBadge,7,2,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.otherTab,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.otherTab,86,45,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.toWindow,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.toWindow,13,5,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.watch,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.watch,16,9,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.yourTurn,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.yourTurn,8,7,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[15].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[15].text.ko,9,4,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[16].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[16].text.ko,7,5,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[17].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[17].text.ko,16,8,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[18].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[18].text.ko,16,8,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[19].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[19].text.ko,16,7,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[20].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[20].text.ko,8,4,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[21].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[21].text.ko,8,6,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[22].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[22].text.ko,9,5,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][1].say.ko,15,10,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][2].say.ko,18,11,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][3].say.ko,30,15,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][1].say.ko,23,13,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][2].say.ko,25,13,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][3].say.ko,17,16,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][1].say.ko,12,12,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][2].say.ko,25,24,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][3].say.ko,17,20,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][1].say.ko,17,11,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][2].say.ko,23,15,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][3].say.ko,20,15,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][4].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][4].say.ko,17,10,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][5].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][5].say.ko,21,9,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][6].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][6].say.ko,18,17,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][7].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][7].say.ko,17,20,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][0].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][0].say.ko,14,8,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][1].say.ko,36,28,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][2].say.ko,9,9,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][3].say.ko,21,20,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][4].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][4].say.ko,29,22,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[5][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[5][1].say.ko,14,9,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[5][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[5][2].say.ko,21,20,ok +copy,web/src/components/guide/tutorials/community.ts#toWall.say.en,web/src/components/guide/tutorials/community.ts#toWall.say.ko,17,11,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][0].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][0].say.ko,10,4,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][1].say.ko,7,2,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][2].say.ko,8,2,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][3].say.ko,12,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[10][0].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[10][0].say.ko,9,8,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[10][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[10][1].say.ko,16,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[1][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[1][1].say.ko,9,6,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[1][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[1][2].say.ko,18,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][1].en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][1].ko,14,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][2].say.ko,15,14,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][4].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][4].say.ko,10,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[3][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[3][1].say.ko,15,14,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[3][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[3][2].say.ko,23,12,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][1].say.ko,13,8,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][2].say.ko,21,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][3].say.ko,21,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[5][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[5][2].say.ko,15,7,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[5][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[5][3].say.ko,23,27,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][2].say.ko,14,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][3].say.ko,22,15,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][4].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][4].say.ko,10,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][5].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][5].say.ko,13,8,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][1].say.ko,10,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][2].say.ko,19,20,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][3].say.ko,23,14,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][1].en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][1].ko,17,17,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][2].say.ko,9,6,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][3].say.ko,12,11,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][1].en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][1].ko,15,12,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][2].say.ko,19,8,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][3].say.ko,18,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][4].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][4].say.ko,6,5,ok +copy,web/src/components/guide/tutorials/lab.ts#askAi[0].say.en,web/src/components/guide/tutorials/lab.ts#askAi[0].say.ko,11,8,ok +copy,web/src/components/guide/tutorials/lab.ts#askAi[1].say.en,web/src/components/guide/tutorials/lab.ts#askAi[1].say.ko,6,3,ok +copy,web/src/components/guide/tutorials/lab.ts#askAi[2].say.en,web/src/components/guide/tutorials/lab.ts#askAi[2].say.ko,13,8,ok +copy,web/src/components/guide/tutorials/lab.ts#askAi[3].say.en,web/src/components/guide/tutorials/lab.ts#askAi[3].say.ko,9,7,ok +copy,web/src/components/guide/tutorials/lab.ts#closeHw.say.en,web/src/components/guide/tutorials/lab.ts#closeHw.say.ko,20,7,ok +copy,web/src/components/guide/tutorials/lab.ts#openHw.say.en,web/src/components/guide/tutorials/lab.ts#openHw.say.ko,15,14,ok +copy,web/src/components/guide/tutorials/lab.ts#unstack[0].say.en,web/src/components/guide/tutorials/lab.ts#unstack[0].say.ko,37,24,en-short +field,web/src/app/roadmap/roadmap-data.ts#LANES[case].label,web/src/app/roadmap/roadmap-data.ts#LANES[case].labelKo,9,4,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[community].label,web/src/app/roadmap/roadmap-data.ts#LANES[community].labelKo,9,4,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[firmware].label,web/src/app/roadmap/roadmap-data.ts#LANES[firmware].labelKo,8,3,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[guides].label,web/src/app/roadmap/roadmap-data.ts#LANES[guides].labelKo,6,5,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[media].label,web/src/app/roadmap/roadmap-data.ts#LANES[media].labelKo,11,6,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[pcb].label,web/src/app/roadmap/roadmap-data.ts#LANES[pcb].labelKo,3,5,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[tools].label,web/src/app/roadmap/roadmap-data.ts#LANES[tools].labelKo,12,5,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-campaign-success].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-campaign-success].detailKo,142,67,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-campaign-success].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-campaign-success].titleKo,26,14,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs-150].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs-150].detailKo,309,144,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs-150].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs-150].titleKo,18,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs].detailKo,266,138,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs].titleKo,19,13,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-designright].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-designright].detailKo,201,108,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-designright].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-designright].titleKo,19,13,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-discord].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-discord].detailKo,275,138,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-discord].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-discord].titleKo,15,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-exhibition].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-exhibition].detailKo,281,135,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-exhibition].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-exhibition].titleKo,10,2,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-kams].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-kams].detailKo,212,126,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-kams].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-kams].titleKo,28,20,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-launch].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-launch].detailKo,208,107,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-launch].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-launch].titleKo,14,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-market].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-market].detailKo,324,169,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-market].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-market].titleKo,18,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-nath-build].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-nath-build].detailKo,159,77,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-nath-build].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-nath-build].titleKo,19,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-opensource].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-opensource].detailKo,240,133,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-opensource].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-opensource].titleKo,20,5,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-pcbway-order].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-pcbway-order].detailKo,309,146,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-pcbway-order].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-pcbway-order].titleKo,17,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-prelaunch].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-prelaunch].detailKo,241,127,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-prelaunch].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-prelaunch].titleKo,14,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-reddit].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-reddit].detailKo,341,171,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-reddit].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-reddit].titleKo,15,5,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sdsc].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sdsc].detailKo,241,120,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sdsc].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sdsc].titleKo,24,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sfac].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sfac].detailKo,236,94,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sfac].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sfac].titleKo,18,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-exp].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-exp].detailKo,353,180,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-exp].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-exp].titleKo,16,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-laser].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-laser].detailKo,404,218,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-laser].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-laser].titleKo,24,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-petg].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-petg].detailKo,164,87,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-petg].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-petg].titleKo,20,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-proto].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-proto].detailKo,342,179,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-proto].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-proto].titleKo,23,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-snapfit].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-snapfit].detailKo,198,116,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-snapfit].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-snapfit].titleKo,17,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-v1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-v1].detailKo,301,154,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-v1].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-v1].titleKo,14,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-v3].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-v3].detailKo,369,222,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-v3].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-v3].titleKo,19,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[community-discussions].detail,web/src/app/roadmap/roadmap-data.ts#NODES[community-discussions].detailKo,393,201,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[community-discussions].title,web/src/app/roadmap/roadmap-data.ts#NODES[community-discussions].titleKo,16,6,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[community-terms].detail,web/src/app/roadmap/roadmap-data.ts#NODES[community-terms].detailKo,280,127,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[community-terms].title,web/src/app/roadmap/roadmap-data.ts#NODES[community-terms].titleKo,22,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-ableton].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-ableton].detailKo,292,172,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-ableton].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-ableton].titleKo,13,16,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-browser-build].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-browser-build].detailKo,319,174,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-browser-build].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-browser-build].titleKo,21,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-color-calibration].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-color-calibration].detailKo,784,402,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-color-calibration].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-color-calibration].titleKo,21,5,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-dualcore-pipeline].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-dualcore-pipeline].detailKo,377,227,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-dualcore-pipeline].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-dualcore-pipeline].titleKo,34,20,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-editions].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-editions].detailKo,420,268,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-editions].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-editions].titleKo,28,20,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-foundation].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-foundation].detailKo,365,212,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-foundation].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-foundation].titleKo,18,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-improv].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-improv].detailKo,166,92,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-improv].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-improv].titleKo,11,13,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-knobs].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-knobs].detailKo,463,243,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-knobs].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-knobs].titleKo,17,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-modules].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-modules].detailKo,431,276,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-modules].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-modules].titleKo,22,13,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-osc].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-osc].detailKo,307,176,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-osc].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-osc].titleKo,13,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-presets].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-presets].detailKo,329,189,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-presets].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-presets].titleKo,18,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-resolution].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-resolution].detailKo,288,158,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-resolution].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-resolution].titleKo,20,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v11].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v11].detailKo,331,211,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v11].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v11].titleKo,19,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v1].detailKo,190,131,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v1].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v1].titleKo,12,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v2].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v2].detailKo,280,159,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v2].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v2].titleKo,13,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-breadboard].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-breadboard].detailKo,205,121,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-breadboard].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-breadboard].titleKo,15,17,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-panel].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-panel].detailKo,374,215,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-panel].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-panel].titleKo,23,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-pattern].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-pattern].detailKo,342,181,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-pattern].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-pattern].titleKo,21,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-photos].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-photos].detailKo,238,119,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-photos].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-photos].titleKo,12,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-rebuild].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-rebuild].detailKo,281,166,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-rebuild].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-rebuild].titleKo,16,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-tht].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-tht].detailKo,229,133,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-tht].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-tht].titleKo,15,17,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-v1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-v1].detailKo,282,129,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-v1].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-v1].titleKo,12,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-amazona].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-amazona].detailKo,201,153,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-amazona].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-amazona].titleKo,10,16,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-can].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-can].detailKo,265,179,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-can].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-can].titleKo,20,25,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-digikey-youtube].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-digikey-youtube].detailKo,118,80,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-digikey-youtube].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-digikey-youtube].titleKo,14,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-fabscene].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-fabscene].detailKo,256,147,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-fabscene].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-fabscene].titleKo,14,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-1].detailKo,199,129,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-1].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-1].titleKo,12,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-2].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-2].detailKo,160,116,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-2].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-2].titleKo,12,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-howtogeek].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-howtogeek].detailKo,203,122,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-howtogeek].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-howtogeek].titleKo,10,16,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-matrixsynth].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-matrixsynth].detailKo,191,149,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-matrixsynth].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-matrixsynth].titleKo,11,19,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-midifan].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-midifan].detailKo,180,140,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-midifan].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-midifan].titleKo,7,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-mixmag].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-mixmag].detailKo,253,156,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-mixmag].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-mixmag].titleKo,10,18,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-mmr].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-mmr].detailKo,233,143,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-mmr].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-mmr].titleKo,11,19,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-synthtopia].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-synthtopia].detailKo,206,160,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-synthtopia].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-synthtopia].titleKo,10,17,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-twitter-viral].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-twitter-viral].detailKo,282,152,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-twitter-viral].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-twitter-viral].titleKo,14,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-video].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-video].detailKo,277,139,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-video].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-video].titleKo,10,4,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-yanko].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-yanko].detailKo,139,102,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-yanko].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-yanko].titleKo,11,18,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-proto-v0].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-proto-v0].detailKo,376,212,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-proto-v0].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-proto-v0].titleKo,22,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-usbc-safety].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-usbc-safety].detailKo,327,178,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-usbc-safety].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-usbc-safety].titleKo,19,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v1].detailKo,285,175,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v1].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v1].titleKo,7,6,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v21].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v21].detailKo,313,177,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v21].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v21].titleKo,11,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v22].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v22].detailKo,358,176,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v22].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v22].titleKo,18,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v2].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v2].detailKo,326,200,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v2].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v2].titleKo,14,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3-test].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3-test].detailKo,247,151,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3-test].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3-test].titleKo,13,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3].detailKo,324,175,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3].titleKo,11,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-director-export].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-director-export].detailKo,263,129,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-director-export].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-director-export].titleKo,22,21,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-editor].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-editor].detailKo,244,129,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-editor].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-editor].titleKo,10,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-gemini].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-gemini].detailKo,191,104,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-gemini].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-gemini].titleKo,16,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-mobile].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-mobile].detailKo,282,149,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-mobile].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-mobile].titleKo,22,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-v2].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-v2].detailKo,441,262,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-v2].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-v2].titleKo,19,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab].detailKo,251,125,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab].titleKo,10,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-multiagent].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-multiagent].detailKo,200,107,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-multiagent].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-multiagent].titleKo,21,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-oklab].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-oklab].detailKo,701,354,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-oklab].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-oklab].titleKo,20,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin-studio].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin-studio].detailKo,194,92,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin-studio].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin-studio].titleKo,16,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin].detailKo,333,195,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin].titleKo,17,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-paik].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-paik].detailKo,363,204,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-paik].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-paik].titleKo,20,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-reflow].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-reflow].detailKo,277,137,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-reflow].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-reflow].titleKo,19,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-stack].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-stack].detailKo,308,160,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-stack].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-stack].titleKo,17,10,ok +field,web/src/lib/atlas/data.ts#ENTRIES[adhesion].knobEn,web/src/lib/atlas/data.ts#ENTRIES[adhesion].knob,158,65,ok +field,web/src/lib/atlas/data.ts#ENTRIES[adhesion].nmEn,web/src/lib/atlas/data.ts#ENTRIES[adhesion].nm,17,14,ok +field,web/src/lib/atlas/data.ts#ENTRIES[adhesion].texEn,web/src/lib/atlas/data.ts#ENTRIES[adhesion].tex,199,102,ok +field,web/src/lib/atlas/data.ts#ENTRIES[billiard].knobEn,web/src/lib/atlas/data.ts#ENTRIES[billiard].knob,113,47,ok +field,web/src/lib/atlas/data.ts#ENTRIES[billiard].nmEn,web/src/lib/atlas/data.ts#ENTRIES[billiard].nm,34,17,ok +field,web/src/lib/atlas/data.ts#ENTRIES[billiard].texEn,web/src/lib/atlas/data.ts#ENTRIES[billiard].tex,146,63,ok +field,web/src/lib/atlas/data.ts#ENTRIES[blight].implEn,web/src/lib/atlas/data.ts#ENTRIES[blight].impl,92,53,ok +field,web/src/lib/atlas/data.ts#ENTRIES[blight].knobEn,web/src/lib/atlas/data.ts#ENTRIES[blight].knob,93,33,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[blight].nmEn,web/src/lib/atlas/data.ts#ENTRIES[blight].nm,23,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[blight].texEn,web/src/lib/atlas/data.ts#ENTRIES[blight].tex,78,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].knobEn,web/src/lib/atlas/data.ts#ENTRIES[braid].knob,169,89,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].nmEn,web/src/lib/atlas/data.ts#ENTRIES[braid].nm,16,12,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].riskEn,web/src/lib/atlas/data.ts#ENTRIES[braid].risk,131,53,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].texEn,web/src/lib/atlas/data.ts#ENTRIES[braid].tex,178,82,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].vertEn,web/src/lib/atlas/data.ts#ENTRIES[braid].vert,88,42,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].knobEn,web/src/lib/atlas/data.ts#ENTRIES[branched].knob,206,78,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].nmEn,web/src/lib/atlas/data.ts#ENTRIES[branched].nm,12,15,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].riskEn,web/src/lib/atlas/data.ts#ENTRIES[branched].risk,136,55,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].texEn,web/src/lib/atlas/data.ts#ENTRIES[branched].tex,170,81,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].vertEn,web/src/lib/atlas/data.ts#ENTRIES[branched].vert,76,33,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cahn].knobEn,web/src/lib/atlas/data.ts#ENTRIES[cahn].knob,62,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cahn].nmEn,web/src/lib/atlas/data.ts#ENTRIES[cahn].nm,23,16,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cahn].texEn,web/src/lib/atlas/data.ts#ENTRIES[cahn].tex,92,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].knobEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].knob,86,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].nmEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].nm,23,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].riskEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].risk,97,40,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].texEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].tex,154,80,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].vertEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].vert,81,38,ok +field,web/src/lib/atlas/data.ts#ENTRIES[caustic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[caustic].knob,61,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[caustic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[caustic].nm,18,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[caustic].texEn,web/src/lib/atlas/data.ts#ENTRIES[caustic].tex,87,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].knobEn,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].knob,43,14,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].nmEn,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].nm,14,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].texEn,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].tex,61,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl].knobEn,web/src/lib/atlas/data.ts#ENTRIES[cgl].knob,59,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl].nmEn,web/src/lib/atlas/data.ts#ENTRIES[cgl].nm,19,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl].texEn,web/src/lib/atlas/data.ts#ENTRIES[cgl].tex,73,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chimera].knobEn,web/src/lib/atlas/data.ts#ENTRIES[chimera].knob,71,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chimera].nmEn,web/src/lib/atlas/data.ts#ENTRIES[chimera].nm,25,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chimera].texEn,web/src/lib/atlas/data.ts#ENTRIES[chimera].tex,163,63,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chirikov].knobEn,web/src/lib/atlas/data.ts#ENTRIES[chirikov].knob,67,26,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chirikov].nmEn,web/src/lib/atlas/data.ts#ENTRIES[chirikov].nm,22,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chirikov].texEn,web/src/lib/atlas/data.ts#ENTRIES[chirikov].tex,78,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chladni].knobEn,web/src/lib/atlas/data.ts#ENTRIES[chladni].knob,74,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chladni].nmEn,web/src/lib/atlas/data.ts#ENTRIES[chladni].nm,24,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chladni].riskEn,web/src/lib/atlas/data.ts#ENTRIES[chladni].risk,78,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chladni].texEn,web/src/lib/atlas/data.ts#ENTRIES[chladni].tex,142,47,ok +field,web/src/lib/atlas/data.ts#ENTRIES[choke].implEn,web/src/lib/atlas/data.ts#ENTRIES[choke].impl,136,59,ok +field,web/src/lib/atlas/data.ts#ENTRIES[choke].knobEn,web/src/lib/atlas/data.ts#ENTRIES[choke].knob,126,39,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[choke].nmEn,web/src/lib/atlas/data.ts#ENTRIES[choke].nm,25,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[choke].texEn,web/src/lib/atlas/data.ts#ENTRIES[choke].tex,144,65,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].knobEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].knob,88,37,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].nmEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].nm,18,17,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].riskEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].risk,81,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].texEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].tex,116,43,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].vertEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].vert,75,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[crease].knobEn,web/src/lib/atlas/data.ts#ENTRIES[crease].knob,56,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[crease].nmEn,web/src/lib/atlas/data.ts#ENTRIES[crease].nm,25,16,ok +field,web/src/lib/atlas/data.ts#ENTRIES[crease].texEn,web/src/lib/atlas/data.ts#ENTRIES[crease].tex,108,66,ok +field,web/src/lib/atlas/data.ts#ENTRIES[creep].knobEn,web/src/lib/atlas/data.ts#ENTRIES[creep].knob,97,31,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[creep].nmEn,web/src/lib/atlas/data.ts#ENTRIES[creep].nm,20,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[creep].texEn,web/src/lib/atlas/data.ts#ENTRIES[creep].tex,147,67,ok +field,web/src/lib/atlas/data.ts#ENTRIES[curtain].knobEn,web/src/lib/atlas/data.ts#ENTRIES[curtain].knob,75,44,ok +field,web/src/lib/atlas/data.ts#ENTRIES[curtain].nmEn,web/src/lib/atlas/data.ts#ENTRIES[curtain].nm,30,18,ok +field,web/src/lib/atlas/data.ts#ENTRIES[curtain].texEn,web/src/lib/atlas/data.ts#ENTRIES[curtain].tex,166,70,ok +field,web/src/lib/atlas/data.ts#ENTRIES[curtain].vertEn,web/src/lib/atlas/data.ts#ENTRIES[curtain].vert,65,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[dla].knobEn,web/src/lib/atlas/data.ts#ENTRIES[dla].knob,66,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[dla].nmEn,web/src/lib/atlas/data.ts#ENTRIES[dla].nm,19,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[dla].riskEn,web/src/lib/atlas/data.ts#ENTRIES[dla].risk,62,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[dla].texEn,web/src/lib/atlas/data.ts#ENTRIES[dla].tex,81,25,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fhn].knobEn,web/src/lib/atlas/data.ts#ENTRIES[fhn].knob,62,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fhn].riskEn,web/src/lib/atlas/data.ts#ENTRIES[fhn].risk,56,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fhn].texEn,web/src/lib/atlas/data.ts#ENTRIES[fhn].tex,51,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[flame].implEn,web/src/lib/atlas/data.ts#ENTRIES[flame].impl,83,61,ok +field,web/src/lib/atlas/data.ts#ENTRIES[flame].knobEn,web/src/lib/atlas/data.ts#ENTRIES[flame].knob,48,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[flame].nmEn,web/src/lib/atlas/data.ts#ENTRIES[flame].nm,12,6,ok +field,web/src/lib/atlas/data.ts#ENTRIES[flame].texEn,web/src/lib/atlas/data.ts#ENTRIES[flame].tex,72,36,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fluid].knobEn,web/src/lib/atlas/data.ts#ENTRIES[fluid].knob,59,36,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fluid].nmEn,web/src/lib/atlas/data.ts#ENTRIES[fluid].nm,15,14,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fluid].riskEn,web/src/lib/atlas/data.ts#ENTRIES[fluid].risk,104,50,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fluid].texEn,web/src/lib/atlas/data.ts#ENTRIES[fluid].tex,101,40,ok +field,web/src/lib/atlas/data.ts#ENTRIES[forestfire].knobEn,web/src/lib/atlas/data.ts#ENTRIES[forestfire].knob,91,46,ok +field,web/src/lib/atlas/data.ts#ENTRIES[forestfire].nmEn,web/src/lib/atlas/data.ts#ENTRIES[forestfire].nm,22,6,ok +field,web/src/lib/atlas/data.ts#ENTRIES[forestfire].texEn,web/src/lib/atlas/data.ts#ENTRIES[forestfire].tex,97,42,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fpu].knobEn,web/src/lib/atlas/data.ts#ENTRIES[fpu].knob,77,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fpu].nmEn,web/src/lib/atlas/data.ts#ENTRIES[fpu].nm,19,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fpu].riskEn,web/src/lib/atlas/data.ts#ENTRIES[fpu].risk,153,56,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fpu].texEn,web/src/lib/atlas/data.ts#ENTRIES[fpu].tex,170,61,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ftle].knobEn,web/src/lib/atlas/data.ts#ENTRIES[ftle].knob,112,43,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ftle].nmEn,web/src/lib/atlas/data.ts#ENTRIES[ftle].nm,22,10,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ftle].texEn,web/src/lib/atlas/data.ts#ENTRIES[ftle].tex,169,86,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gale].knobEn,web/src/lib/atlas/data.ts#ENTRIES[gale].knob,50,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gale].nmEn,web/src/lib/atlas/data.ts#ENTRIES[gale].nm,23,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gale].texEn,web/src/lib/atlas/data.ts#ENTRIES[gale].tex,61,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gmira].knobEn,web/src/lib/atlas/data.ts#ENTRIES[gmira].knob,60,20,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[gmira].nmEn,web/src/lib/atlas/data.ts#ENTRIES[gmira].nm,16,15,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gmira].texEn,web/src/lib/atlas/data.ts#ENTRIES[gmira].tex,52,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[harmono].knobEn,web/src/lib/atlas/data.ts#ENTRIES[harmono].knob,74,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[harmono].nmEn,web/src/lib/atlas/data.ts#ENTRIES[harmono].nm,12,6,ok +field,web/src/lib/atlas/data.ts#ENTRIES[harmono].riskEn,web/src/lib/atlas/data.ts#ENTRIES[harmono].risk,58,19,ok +field,web/src/lib/atlas/data.ts#ENTRIES[harmono].texEn,web/src/lib/atlas/data.ts#ENTRIES[harmono].tex,69,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[hybrid].knobEn,web/src/lib/atlas/data.ts#ENTRIES[hybrid].knob,64,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[hybrid].nmEn,web/src/lib/atlas/data.ts#ENTRIES[hybrid].nm,18,5,ok +field,web/src/lib/atlas/data.ts#ENTRIES[hybrid].riskEn,web/src/lib/atlas/data.ts#ENTRIES[hybrid].risk,92,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[hybrid].texEn,web/src/lib/atlas/data.ts#ENTRIES[hybrid].tex,102,39,ok +field,web/src/lib/atlas/data.ts#ENTRIES[interf].knobEn,web/src/lib/atlas/data.ts#ENTRIES[interf].knob,69,19,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[interf].nmEn,web/src/lib/atlas/data.ts#ENTRIES[interf].nm,28,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[interf].riskEn,web/src/lib/atlas/data.ts#ENTRIES[interf].risk,82,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[interf].texEn,web/src/lib/atlas/data.ts#ENTRIES[interf].tex,82,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[invasion].implEn,web/src/lib/atlas/data.ts#ENTRIES[invasion].impl,91,48,ok +field,web/src/lib/atlas/data.ts#ENTRIES[invasion].knobEn,web/src/lib/atlas/data.ts#ENTRIES[invasion].knob,76,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[invasion].nmEn,web/src/lib/atlas/data.ts#ENTRIES[invasion].nm,19,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[invasion].texEn,web/src/lib/atlas/data.ts#ENTRIES[invasion].tex,150,52,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ising].implEn,web/src/lib/atlas/data.ts#ENTRIES[ising].impl,100,58,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ising].knobEn,web/src/lib/atlas/data.ts#ENTRIES[ising].knob,59,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ising].nmEn,web/src/lib/atlas/data.ts#ENTRIES[ising].nm,14,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ising].texEn,web/src/lib/atlas/data.ts#ENTRIES[ising].tex,83,22,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[kdv].knobEn,web/src/lib/atlas/data.ts#ENTRIES[kdv].knob,52,14,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[kdv].nmEn,web/src/lib/atlas/data.ts#ENTRIES[kdv].nm,20,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kdv].riskEn,web/src/lib/atlas/data.ts#ENTRIES[kdv].risk,56,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kdv].texEn,web/src/lib/atlas/data.ts#ENTRIES[kdv].tex,60,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kneaded].knobEn,web/src/lib/atlas/data.ts#ENTRIES[kneaded].knob,90,40,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kneaded].nmEn,web/src/lib/atlas/data.ts#ENTRIES[kneaded].nm,28,15,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kneaded].texEn,web/src/lib/atlas/data.ts#ENTRIES[kneaded].tex,151,60,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kpz].knobEn,web/src/lib/atlas/data.ts#ENTRIES[kpz].knob,109,50,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kpz].nmEn,web/src/lib/atlas/data.ts#ENTRIES[kpz].nm,18,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kpz].texEn,web/src/lib/atlas/data.ts#ENTRIES[kpz].tex,100,37,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ks2d].knobEn,web/src/lib/atlas/data.ts#ENTRIES[ks2d].knob,67,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ks2d].nmEn,web/src/lib/atlas/data.ts#ENTRIES[ks2d].nm,27,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ks2d].texEn,web/src/lib/atlas/data.ts#ENTRIES[ks2d].tex,143,63,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lenia].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lenia].knob,78,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lenia].riskEn,web/src/lib/atlas/data.ts#ENTRIES[lenia].risk,66,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lenia].texEn,web/src/lib/atlas/data.ts#ENTRIES[lenia].tex,68,22,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lic].knob,72,26,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[lic].nm,19,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lic].riskEn,web/src/lib/atlas/data.ts#ENTRIES[lic].risk,50,22,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lic].texEn,web/src/lib/atlas/data.ts#ENTRIES[lic].tex,139,57,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lineage].implEn,web/src/lib/atlas/data.ts#ENTRIES[lineage].impl,93,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lineage].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lineage].knob,72,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lineage].nmEn,web/src/lib/atlas/data.ts#ENTRIES[lineage].nm,19,12,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lineage].texEn,web/src/lib/atlas/data.ts#ENTRIES[lineage].tex,73,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lle].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lle].knob,112,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lle].nmEn,web/src/lib/atlas/data.ts#ENTRIES[lle].nm,25,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lle].texEn,web/src/lib/atlas/data.ts#ENTRIES[lle].tex,131,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[logistic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[logistic].knob,65,21,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[logistic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[logistic].nm,23,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[logistic].riskEn,web/src/lib/atlas/data.ts#ENTRIES[logistic].risk,80,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[logistic].texEn,web/src/lib/atlas/data.ts#ENTRIES[logistic].tex,75,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lorenz].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lorenz].knob,73,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lorenz].nmEn,web/src/lib/atlas/data.ts#ENTRIES[lorenz].nm,24,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lorenz].texEn,web/src/lib/atlas/data.ts#ENTRIES[lorenz].tex,87,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mirror].implEn,web/src/lib/atlas/data.ts#ENTRIES[mirror].impl,110,74,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mirror].knobEn,web/src/lib/atlas/data.ts#ENTRIES[mirror].knob,62,25,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mirror].nmEn,web/src/lib/atlas/data.ts#ENTRIES[mirror].nm,30,10,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mirror].texEn,web/src/lib/atlas/data.ts#ENTRIES[mirror].tex,82,39,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mitosis].knobEn,web/src/lib/atlas/data.ts#ENTRIES[mitosis].knob,87,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mitosis].nmEn,web/src/lib/atlas/data.ts#ENTRIES[mitosis].nm,24,6,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mitosis].texEn,web/src/lib/atlas/data.ts#ENTRIES[mitosis].tex,128,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].knobEn,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].knob,57,17,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].nmEn,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].nm,16,4,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].texEn,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].tex,86,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nca].knobEn,web/src/lib/atlas/data.ts#ENTRIES[nca].knob,85,34,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nca].riskEn,web/src/lib/atlas/data.ts#ENTRIES[nca].risk,57,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nca].texEn,web/src/lib/atlas/data.ts#ENTRIES[nca].tex,96,38,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nematic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[nematic].knob,89,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nematic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[nematic].nm,29,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nematic].texEn,web/src/lib/atlas/data.ts#ENTRIES[nematic].tex,167,66,ok +field,web/src/lib/atlas/data.ts#ENTRIES[oslo].knobEn,web/src/lib/atlas/data.ts#ENTRIES[oslo].knob,115,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[oslo].nmEn,web/src/lib/atlas/data.ts#ENTRIES[oslo].nm,19,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[oslo].texEn,web/src/lib/atlas/data.ts#ENTRIES[oslo].tex,91,37,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].knobEn,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].knob,58,19,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].nmEn,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].nm,17,5,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].riskEn,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].risk,186,74,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].texEn,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].tex,89,33,ok +field,web/src/lib/atlas/data.ts#ENTRIES[physarum].knobEn,web/src/lib/atlas/data.ts#ENTRIES[physarum].knob,74,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[physarum].nmEn,web/src/lib/atlas/data.ts#ENTRIES[physarum].nm,13,10,ok +field,web/src/lib/atlas/data.ts#ENTRIES[physarum].texEn,web/src/lib/atlas/data.ts#ENTRIES[physarum].tex,65,25,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].knob,227,83,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].nm,20,15,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].riskEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].risk,100,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].texEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].tex,184,82,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].vertEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].vert,57,20,ok +field,web/src/lib/atlas/data.ts#ENTRIES[potts].knobEn,web/src/lib/atlas/data.ts#ENTRIES[potts].knob,89,40,ok +field,web/src/lib/atlas/data.ts#ENTRIES[potts].nmEn,web/src/lib/atlas/data.ts#ENTRIES[potts].nm,16,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[potts].texEn,web/src/lib/atlas/data.ts#ENTRIES[potts].tex,127,52,ok +field,web/src/lib/atlas/data.ts#ENTRIES[resonance].knobEn,web/src/lib/atlas/data.ts#ENTRIES[resonance].knob,85,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[resonance].nmEn,web/src/lib/atlas/data.ts#ENTRIES[resonance].nm,29,19,ok +field,web/src/lib/atlas/data.ts#ENTRIES[resonance].texEn,web/src/lib/atlas/data.ts#ENTRIES[resonance].tex,165,59,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rogue].knobEn,web/src/lib/atlas/data.ts#ENTRIES[rogue].knob,121,32,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[rogue].nmEn,web/src/lib/atlas/data.ts#ENTRIES[rogue].nm,29,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rogue].riskEn,web/src/lib/atlas/data.ts#ENTRIES[rogue].risk,107,42,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rogue].texEn,web/src/lib/atlas/data.ts#ENTRIES[rogue].tex,141,74,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rps].knobEn,web/src/lib/atlas/data.ts#ENTRIES[rps].knob,118,36,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[rps].nmEn,web/src/lib/atlas/data.ts#ENTRIES[rps].nm,26,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rps].texEn,web/src/lib/atlas/data.ts#ENTRIES[rps].tex,113,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rule54].knobEn,web/src/lib/atlas/data.ts#ENTRIES[rule54].knob,100,33,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rule54].nmEn,web/src/lib/atlas/data.ts#ENTRIES[rule54].nm,15,12,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rule54].riskEn,web/src/lib/atlas/data.ts#ENTRIES[rule54].risk,111,43,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rule54].texEn,web/src/lib/atlas/data.ts#ENTRIES[rule54].tex,144,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sandpile].implEn,web/src/lib/atlas/data.ts#ENTRIES[sandpile].impl,84,42,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sandpile].knobEn,web/src/lib/atlas/data.ts#ENTRIES[sandpile].knob,58,18,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[sandpile].nmEn,web/src/lib/atlas/data.ts#ENTRIES[sandpile].nm,17,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sandpile].texEn,web/src/lib/atlas/data.ts#ENTRIES[sandpile].tex,101,33,ok +field,web/src/lib/atlas/data.ts#ENTRIES[schrod].knobEn,web/src/lib/atlas/data.ts#ENTRIES[schrod].knob,48,14,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[schrod].nmEn,web/src/lib/atlas/data.ts#ENTRIES[schrod].nm,17,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[schrod].texEn,web/src/lib/atlas/data.ts#ENTRIES[schrod].tex,75,25,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sh].knobEn,web/src/lib/atlas/data.ts#ENTRIES[sh].knob,59,20,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sh].riskEn,web/src/lib/atlas/data.ts#ENTRIES[sh].risk,50,18,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sh].texEn,web/src/lib/atlas/data.ts#ENTRIES[sh].tex,54,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].knobEn,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].knob,160,59,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].nmEn,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].nm,26,12,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].texEn,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].tex,149,58,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].knobEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].knob,56,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].nmEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].nm,15,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].riskEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].risk,139,67,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].texEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].tex,71,26,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].vertEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].vert,49,20,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].knobEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].knob,203,84,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].nmEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].nm,30,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].riskEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].risk,164,69,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].texEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].tex,224,93,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].vertEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].vert,91,27,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[sprott].knobEn,web/src/lib/atlas/data.ts#ENTRIES[sprott].knob,57,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sprott].nmEn,web/src/lib/atlas/data.ts#ENTRIES[sprott].nm,29,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sprott].texEn,web/src/lib/atlas/data.ts#ENTRIES[sprott].tex,87,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].knobEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].knob,153,67,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].nmEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].nm,24,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].riskEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].risk,111,61,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].texEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].tex,176,94,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].vertEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].vert,65,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[tongues].knobEn,web/src/lib/atlas/data.ts#ENTRIES[tongues].knob,112,30,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[tongues].nmEn,web/src/lib/atlas/data.ts#ENTRIES[tongues].nm,30,19,ok +field,web/src/lib/atlas/data.ts#ENTRIES[tongues].texEn,web/src/lib/atlas/data.ts#ENTRIES[tongues].tex,166,81,ok +field,web/src/lib/atlas/data.ts#ENTRIES[traffic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[traffic].knob,85,31,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[traffic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[traffic].nm,20,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[traffic].riskEn,web/src/lib/atlas/data.ts#ENTRIES[traffic].risk,77,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[traffic].texEn,web/src/lib/atlas/data.ts#ENTRIES[traffic].tex,87,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].knobEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].knob,223,105,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].nmEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].nm,15,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].riskEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].risk,167,68,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].texEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].tex,214,108,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].vertEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].vert,70,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].knobEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].knob,165,71,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].nmEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].nm,29,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].riskEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].risk,110,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].texEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].tex,169,83,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].vertEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].vert,130,49,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].knobEn,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].knob,128,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].nmEn,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].nm,22,16,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].texEn,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].tex,188,75,ok +field,web/src/lib/atlas/data.ts#ENTRIES[voter].knobEn,web/src/lib/atlas/data.ts#ENTRIES[voter].knob,102,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[voter].nmEn,web/src/lib/atlas/data.ts#ENTRIES[voter].nm,19,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[voter].riskEn,web/src/lib/atlas/data.ts#ENTRIES[voter].risk,87,36,ok +field,web/src/lib/atlas/data.ts#ENTRIES[voter].texEn,web/src/lib/atlas/data.ts#ENTRIES[voter].tex,125,49,ok +field,web/src/lib/atlas/data.ts#ENTRIES[xy].knobEn,web/src/lib/atlas/data.ts#ENTRIES[xy].knob,112,47,ok +field,web/src/lib/atlas/data.ts#ENTRIES[xy].nmEn,web/src/lib/atlas/data.ts#ENTRIES[xy].nm,17,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[xy].texEn,web/src/lib/atlas/data.ts#ENTRIES[xy].tex,98,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[zrp].knobEn,web/src/lib/atlas/data.ts#ENTRIES[zrp].knob,82,36,ok +field,web/src/lib/atlas/data.ts#ENTRIES[zrp].nmEn,web/src/lib/atlas/data.ts#ENTRIES[zrp].nm,18,5,ok +field,web/src/lib/atlas/data.ts#ENTRIES[zrp].texEn,web/src/lib/atlas/data.ts#ENTRIES[zrp].tex,119,55,ok +field,web/src/lib/atlas/data.ts#FAMILIES.advect.labelEn,web/src/lib/atlas/data.ts#FAMILIES.advect.label,14,4,ok +field,web/src/lib/atlas/data.ts#FAMILIES.critical.labelEn,web/src/lib/atlas/data.ts#FAMILIES.critical.label,18,8,ok +field,web/src/lib/atlas/data.ts#FAMILIES.feedback.labelEn,web/src/lib/atlas/data.ts#FAMILIES.feedback.label,17,5,ok +field,web/src/lib/atlas/data.ts#FAMILIES.ink.labelEn,web/src/lib/atlas/data.ts#FAMILIES.ink.label,20,6,ok +field,web/src/lib/atlas/data.ts#FAMILIES.invent.labelEn,web/src/lib/atlas/data.ts#FAMILIES.invent.label,19,4,ok +field,web/src/lib/atlas/data.ts#FAMILIES.life.labelEn,web/src/lib/atlas/data.ts#FAMILIES.life.label,14,4,ok +field,web/src/lib/atlas/data.ts#FAMILIES.optics.labelEn,web/src/lib/atlas/data.ts#FAMILIES.optics.label,19,4,ok +field,web/src/lib/atlas/data.ts#FAMILIES.pde.labelEn,web/src/lib/atlas/data.ts#FAMILIES.pde.label,9,5,ok +field,web/src/lib/atlas/data.ts#FAMILIES.waterfall.labelEn,web/src/lib/atlas/data.ts#FAMILIES.waterfall.label,17,4,ok +field,web/src/lib/atlas/data.ts#STATUSES.active.labelEn,web/src/lib/atlas/data.ts#STATUSES.active.label,9,3,ok +field,web/src/lib/atlas/data.ts#STATUSES.active.legendEn,web/src/lib/atlas/data.ts#STATUSES.active.legend,58,18,ok +field,web/src/lib/atlas/data.ts#STATUSES.hold.labelEn,web/src/lib/atlas/data.ts#STATUSES.hold.label,6,2,ok +field,web/src/lib/atlas/data.ts#STATUSES.hold.legendEn,web/src/lib/atlas/data.ts#STATUSES.hold.legend,36,10,ok +field,web/src/lib/atlas/data.ts#STATUSES.invented.labelEn,web/src/lib/atlas/data.ts#STATUSES.invented.label,8,2,ok +field,web/src/lib/atlas/data.ts#STATUSES.invented.legendEn,web/src/lib/atlas/data.ts#STATUSES.invented.legend,55,23,ok +field,web/src/lib/atlas/data.ts#STATUSES.retired.labelEn,web/src/lib/atlas/data.ts#STATUSES.retired.label,9,3,ok +field,web/src/lib/atlas/data.ts#STATUSES.retired.legendEn,web/src/lib/atlas/data.ts#STATUSES.retired.legend,82,31,ok +field,web/src/lib/atlas/data.ts#STATUSES.unexplored.labelEn,web/src/lib/atlas/data.ts#STATUSES.unexplored.label,9,3,ok +field,web/src/lib/atlas/data.ts#STATUSES.unexplored.legendEn,web/src/lib/atlas/data.ts#STATUSES.unexplored.legend,49,18,ok +field,web/src/lib/atlas/data.ts#STATUSES.verified.labelEn,web/src/lib/atlas/data.ts#STATUSES.verified.label,8,3,ok +field,web/src/lib/atlas/data.ts#STATUSES.verified.legendEn,web/src/lib/atlas/data.ts#STATUSES.verified.legend,31,11,ok +file,web/content/journal/a-strange-dream.en.mdx,web/content/journal/a-strange-dream.mdx,3324,1343,ok +file,web/content/journal/a-strange-feeling.en.mdx,web/content/journal/a-strange-feeling.mdx,3788,1686,ok +file,web/content/journal/all-jumbled-up.en.mdx,web/content/journal/all-jumbled-up.mdx,4264,1816,ok +file,web/content/journal/all-over-the-place.en.mdx,web/content/journal/all-over-the-place.mdx,11515,5042,ok +file,web/content/journal/community.en.mdx,web/content/journal/community.mdx,6207,2668,ok +file,web/content/journal/experiment-after-experiment.en.mdx,web/content/journal/experiment-after-experiment.mdx,4227,1902,ok +file,web/content/journal/faster-faster.en.mdx,web/content/journal/faster-faster.mdx,5676,2619,ok +file,web/content/journal/good-but.en.mdx,web/content/journal/good-but.mdx,2989,1258,ok +file,web/content/journal/headache.en.mdx,web/content/journal/headache.mdx,7978,3503,ok +file,web/content/journal/how-to-use-part-two.en.mdx,web/content/journal/how-to-use-part-two.mdx,9248,4315,ok +file,web/content/journal/how-to-use-patternflow.en.mdx,web/content/journal/how-to-use-patternflow.mdx,9577,4446,ok +file,web/content/journal/i-hope-it-works-out.en.mdx,web/content/journal/i-hope-it-works-out.mdx,4566,1954,ok +file,web/content/journal/ill-just-do-it.en.mdx,web/content/journal/ill-just-do-it.mdx,4124,1826,ok +file,web/content/journal/its-a-failure.en.mdx,web/content/journal/its-a-failure.mdx,5757,2463,ok +file,web/content/journal/keep-going-without-falling-apart.en.mdx,web/content/journal/keep-going-without-falling-apart.mdx,5649,2433,ok +file,web/content/journal/make-it-easy-to-build.en.mdx,web/content/journal/make-it-easy-to-build.mdx,4107,1973,ok +file,web/content/journal/maybe-this-will-work.en.mdx,web/content/journal/maybe-this-will-work.mdx,3533,1521,ok +file,web/content/journal/me-and-patternflow.en.mdx,web/content/journal/me-and-patternflow.mdx,23807,10181,ok +file,web/content/journal/nam-june-paik-me-patternflow.en.mdx,web/content/journal/nam-june-paik-me-patternflow.mdx,13791,6014,ok +file,web/content/journal/negative.en.mdx,web/content/journal/negative.mdx,5380,2361,ok +file,web/content/journal/new-possibilities.en.mdx,web/content/journal/new-possibilities.mdx,4261,1828,ok +file,web/content/journal/refocus.en.mdx,web/content/journal/refocus.mdx,8601,3693,ok +file,web/content/journal/right-before-launch.en.mdx,web/content/journal/right-before-launch.mdx,1870,819,ok +file,web/content/journal/shaking-it-off.en.mdx,web/content/journal/shaking-it-off.mdx,3025,1305,ok +file,web/content/journal/stopped.en.mdx,web/content/journal/stopped.mdx,5399,2249,ok +file,web/content/journal/the-30th-entry.en.mdx,web/content/journal/the-30th-entry.mdx,1268,511,ok +file,web/content/journal/the-messy-desk.en.mdx,web/content/journal/the-messy-desk.mdx,9441,4208,ok +file,web/content/journal/the-next-step.en.mdx,web/content/journal/the-next-step.mdx,5231,2335,ok +file,web/content/journal/this-is-the-worst.en.mdx,web/content/journal/this-is-the-worst.mdx,4230,1837,ok +file,web/content/journal/today-is-my-birthday.en.mdx,web/content/journal/today-is-my-birthday.mdx,10198,4270,ok +file,web/content/journal/v1-30-days.en.mdx,web/content/journal/v1-30-days.mdx,16286,7501,ok +file,web/content/journal/v3-and-beyond.en.mdx,web/content/journal/v3-and-beyond.mdx,3066,1366,ok +file,web/content/journal/what-is-patternflow.en.mdx,web/content/journal/what-is-patternflow.mdx,8676,3895,ok +file,web/content/journal/who-we-are.en.mdx,web/content/journal/who-we-are.mdx,5948,2580,ok +file,web/content/journal/why-i-write-these.en.mdx,web/content/journal/why-i-write-these.mdx,4857,2184,ok +file,web/content/journal/wins-and-losses-next-step.en.mdx,web/content/journal/wins-and-losses-next-step.mdx,3053,1423,ok +file,web/content/journal/worn-out.en.mdx,web/content/journal/worn-out.mdx,7774,3412,ok +route,web/src/app/guide/audio/page.tsx,web/src/app/guide/audio/ko/page.tsx,,,ok +route,web/src/app/guide/build/page.tsx,web/src/app/guide/build/ko/page.tsx,,,ok +route,web/src/app/guide/make/page.tsx,web/src/app/guide/make/ko/page.tsx,,,ok +route,web/src/app/guide/page.tsx,web/src/app/guide/ko/page.tsx,,,ok +route,web/src/app/guide/play/page.tsx,web/src/app/guide/play/ko/page.tsx,,,ok +route,web/src/app/journal/[slug]/en/page.tsx,web/src/app/journal/[slug]/page.tsx,,,ok +route,web/src/app/journal/en/page.tsx,web/src/app/journal/page.tsx,,,ok diff --git a/docs/investigations/2026-10-panel-geometry.md b/docs/investigations/2026-10-panel-geometry.md new file mode 100644 index 00000000..63332526 --- /dev/null +++ b/docs/investigations/2026-10-panel-geometry.md @@ -0,0 +1,254 @@ +# Where the panel size lives + +*PR 1 of [the step 5 plan](2026-10-cleanup-step5-plan.md), for +[rfc-cleanup.md](../rfc-cleanup.md) §2.7. Counted on 15 October 2026 at +`e3d3e83` on `dev`. A record of the tree. It proposes nothing.* + +The stock panel is 128×64, and the tree says so in more places than one macro. +Every count here comes from a command run at the repository root at `e3d3e83`, +and the command is quoted with its number or its table. A different number on a +later tree means the tree moved there. + +## The macro + +`PANEL_RES_W` and `PANEL_RES_H` are set in `config.h`. §2.7's 25, four and +seven count occurrences, not lines, so `core_display.h:18`, which names both, +counts twice. Each row below is one `` in these three commands: + +```sh +git grep -o -E 'PANEL_RES_(W|H)' -- | wc -l # occurrences +git grep -n -E 'PANEL_RES_(W|H)' -- | wc -l # lines +git grep -l -E 'PANEL_RES_(W|H)' -- | wc -l # files +``` + +| path | occurrences | lines | files | in comments | +| --- | --- | --- | --- | --- | +| `firmware/patternflow/src` | 25 | 21 | 6 | 8 | +| `firmware/patternflow/features` | 4 | 4 | 2 | 0 | +| `firmware/patternflow/abi` | 7 | 7 | 2 | 2 | +| `firmware/patternflow/config.h` | 11 | 11 | 1 | 1 | +| `firmware/patternflow/presets` | 142 | 128 | 35 | 6 | + +`git grep -o -E 'PANEL_RES_(W|H)' -- . ':!docs' | wc -l` gives 245, so 56 sit +outside those five rows. `firmware/patternflow/README.md` has 5 and +`_TEMPLATE.h` 3. The four probe modules under `firmware/toolchain/tests/modules/` +have 16 between them. The site has 32. Of those, 28 are in C++ the site writes +or asks a model to write: `web/src/lib/lab/cppPrompt.ts` 14, +`web/src/components/guide/desk/ai/headers.ts` 12 and +`web/scripts/module-build-smoke.ts` 2. The other four are comments and prompt +text in `patternWaveSaw.ts` (2), `hExport.ts` and `matrix.ts`. + +### `src/`, 25 + +| file and line | uses | what the line does with the size | +| --- | --- | --- | +| `core_canvas.h:38-39` | 2 | `PFCanvas::W` and `H`, the canvas every preset draws into | +| `core_display.h:18` | 2, comment | warns never to give the driver the canvas size | +| `core_mem.h:17` | 2, comment | an example per-pixel buffer | +| `core_module_loader.h:601-602` | 2 | `panel_w` and `panel_h` in the host API each module receives | +| `core_module_loader.h:1145` | 2 | refuses a module stamped with another size | +| `core_status_http.h:282, 284` | 2 | `"panel": "128x64"` in `/api/status` | +| `core_tables.h:11, 24-26` | 4, comment | how `rT` is scaled, and an example loop | +| `core_tables.h:52, 57` | 3 | `PFTables::idx`, and the length of the two tables | +| `core_tables.h:70-76` | 6 | the table fill: centre, scale and both loops | + +### `features/`, 4 + +| file and line | uses | what the line does with the size | +| --- | --- | --- | +| `clock/core_clock_http.h:116, 118` | 2 | `w` and `h` in `/api/clock`, for the console page's preview | +| `weather/preset_weather.h:492-493` | 2 | the compact layout's frame (the extended one is typed, below) | + +### `abi/`, 7 + +| file and line | uses | what the line does with the size | +| --- | --- | --- | +| `pf_abi.h:116` | 1, comment | why the loader refuses a mismatch | +| `pf_module.h:47` | 1, comment | the same, from the module's side | +| `pf_module.h:71-72` | 2 | defines both macros from `-DPF_PANEL_W` and `-DPF_PANEL_H` | +| `pf_module.h:175-176` | 2 | the module's own `PFCanvas::W` and `H` | +| `pf_module.h:281` | 1 | the module's `PFTables::idx` | + +The ABI carries the size as `panel_w` and `panel_h` in both structs: +`PFHostAPI` at `pf_abi.h:82-83` and `PFPatternModule` at `pf_abi.h:117-118`. +`pf_module.h:61-66` stops a module build that lacks either flag, `:306` stamps +the descriptor and `:312` has the module refuse a host of another size. The ABI +is at version 1, frozen and append only. + +## Where it is set + +`config.h:81-108` offers three geometries, and `PANEL_GEOMETRY` picks one. + +| geometry | module | chain | canvas | how it is built | +| --- | --- | --- | --- | --- | +| `PANEL_GEOM_128x64_SINGLE` | 128×64 | 1 | 128×64 | the default (`config.h:87`), `env:firmware` | +| `PANEL_GEOM_64x64_SINGLE` | 64×64 | 1 | 64×64 | an edit to `config.h` | +| `PANEL_GEOM_64x64_CHAIN2` | 64×64 | 2 | 128×64 | `env:firmware64x2` (`platformio.ini:121-126`) | + +No workflow and no bundle builds the last two. Each branch sets `PANEL_PHYS_W`, +`PANEL_PHYS_H`, `PANEL_CHAIN` and the two canvas macros, and two `#error`s +(`config.h:110-115`) hold the canvas to module width × chain by module height. +`core_display.h:20` is the one line that hands the driver a geometry, and the +only code outside `config.h` that reads `PANEL_PHYS_*` or `PANEL_CHAIN`. The +driver IC is a separate line, `PANEL_PROFILE` (`config.h:168`), and it does not +touch the size. + +## The canvas and what reads it + +`core_canvas.h:38-42` gives `PFCanvas::W`, `H` and a global RGB888 array of +W × H × 3 bytes, 24,576 at 128×64, sized when the firmware links. `setFrame` +(`core_canvas.h:137-163`) compares a pattern's grid with W and H: equal draws +straight through, swapped rotates, anything else is centred and letterboxed. +`present()` passes W and H to the compose hook (`:227`) and the buffer to the +driver's `blitRGB888`. + +Outside the file, `core_thumbs.h:44-45` aliases W and H as `PFThumbs::W` and +`H`. A thumbnail is W × H in RGB565 behind an 8-byte header, 16,392 bytes at +128×64, kept in PSRAM (`core_thumbs.h:139`). `core_module_loader.h:603` hands +the buffer to modules. `PFTables::init` (`core_tables.h:57-59`) allocates two +float tables of W × H through `PFMem`, 32,768 bytes each at 128×64. + +## The menus + +The menus never read the macro. They ask the driver, and the driver answers for +the rotation the menu set, so a portrait screen gets 64 × 128. +`git grep -n -E 'dma_display->(width|height)\(\)' -- firmware/patternflow | wc -l` +gives 31: 20 in `patternflow.ino`, 6 in `src/core_ui_text.h` and 5 in +features (`show/core_show_schedule.h` 2, `show/core_show_select.h` 2, +`weather/feature_weather.h` 1). + +## Sizes typed by hand + +| file and line | what is typed | +| --- | --- | +| `firmware/patternflow/src/core_ui_text.h:16` | `UI_PORTRAIT_TILE = 64`, the square portrait menus lay out in | +| `firmware/patternflow/features/weather/preset_weather.h:491` | `EXT_W = 64, EXT_H = 128`, the extended layout's frame | +| `firmware/patternflow/presets/preset_0712_2.h:60-61` | `VW = 64`, `VH = 128`, its portrait grid | +| `firmware/patternflow/console/clock.html:414` | 128 and 64 for the preview until `/api/clock` answers | +| `firmware/patternflow/console/weather.html:62` | "Compact (128×64)", a menu label | +| `firmware/toolchain/build_module.py:51-52` | `PANEL_W = 128`, `PANEL_H = 64` for every module build | +| `firmware/toolchain/make_pack.py:199` | `"panel": {"w": 128, "h": 64}` in a pack's manifest | +| `firmware/toolchain/console_serve.py:332, 1614` | the console preview server's `/api/status` and `/api/clock` | +| `firmware/toolchain/console_demo.py:516` | the demo console's status | +| `firmware/toolchain/tests/thumbs_test.cpp:36` | the host test's `PFCanvas::W = 128, H = 64` | + +## The presets + +`preset_0629_2.h` sizes four arrays by the macro, two globals at lines 53-54 and +two on the stack at 101 and 106. `preset_0713.h` sizes two on the stack through +its local `H` (186-187). `preset_calib.h:119` allocates `4 × W × 3` bytes and +`preset_0719.h:100` W × H floats. `preset_0712.h`, `preset_0712_2.h` and +`preset_0713.h` turn a portrait grid onto the panel by hand (`v = W - 1 - x`) +instead of calling `setFrame`, so they take the panel to be wider than it is +tall. + +| file | uses | lines | | file | uses | lines | +| --- | --- | --- | --- | --- | --- | --- | +| `preset_0510.h` | 2 | 2 | | `preset_0628.h` | 2 | 2 | +| `preset_0511.h` | 2 | 2 | | `preset_0629.h` | 2 | 2 | +| `preset_0512.h` | 4 | 4 | | `preset_0629_2.h` | 12 | 12 | +| `preset_0513.h` | 2 | 1 | | `preset_0701.h` | 9 | 8 | +| `preset_0514.h` | 2 | 2 | | `preset_0707.h` | 2 | 2 | +| `preset_0515.h` | 2 | 2 | | `preset_0710.h` | 10 | 8 | +| `preset_0515_3.h` | 2 | 2 | | `preset_0712.h` | 2 | 2 | +| `preset_0515_4.h` | 2 | 2 | | `preset_0712_2.h` | 2 | 2 | +| `preset_0518.h` | 2 | 1 | | `preset_0713.h` | 2 | 2 | +| `preset_0519_1.h` | 2 | 2 | | `preset_0715.h` | 4 | 4 | +| `preset_0520.h` | 2 | 2 | | `preset_0716.h` | 2 | 2 | +| `preset_0521.h` | 2 | 2 | | `preset_0718.h` | 4 | 4 | +| `preset_0522.h` | 2 | 2 | | `preset_0719.h` | 16 | 12 | +| `preset_0527.h` | 2 | 2 | | `preset_a_big_hit.h` | 2 | 2 | +| `preset_0528.h` | 2 | 2 | | `preset_calib.h` | 21 | 20 | +| `preset_0531.h` | 2 | 2 | | `preset_origin.h` | 4 | 4 | +| `preset_0601.h` | 2 | 2 | | `preset_wave_saw.h` | 8 | 5 | +| `preset_0602.h` | 2 | 1 | | | | | + +25 of the 35 use it exactly twice, as `w` and `h` or as the two pixel loops. +The six in comments are in `preset_0710.h` (84, 88) and `preset_wave_saw.h` (77). + +## Modules and the cache key + +A `.pfm` is built for one size. `build_module.py:258-259` passes its typed 128 +and 64 as `-DPF_PANEL_W` and `-DPF_PANEL_H`, and `:323-324` writes the same two +into the sidecar as `panel_w` and `panel_h`. The device never reads the +sidecar's pair. It checks the descriptor (`core_module_loader.h:1145`). + +The community's compiled-module cache is keyed `(source_sha, builder_rev)` +(`web/src/lib/community/server/schema.ts:725`). `builder_rev` is a digest of the +toolchain (`web/src/lib/firmware/builderRev.ts`), and `build_module.py` is one +of its inputs (`:45`). The size reaches the key only because it is typed in that +file. + +## HTTP and the specs + +Of the five wire contracts, only `docs/rest-api.md` states a size: `"panel": +"128x64"` in status (line 48, described at 77), the thumbnail as a 128×64 frame +of 16,392 bytes (274), the sidecar's `panel_w` and `panel_h` (284), and +`/api/clock`'s `w` and `h` (499). `osc-spec.md`, `mqtt-spec.md` and +`audio-ws-spec.md` have no 128 and no 64. `midi-spec.md` has 128 twice, both +queue lengths (96, 130). + +## The driver's memory + +The driver is vendored in `firmware/patternflow/src/hub75/`, and `.h` and `.cpp` +below are its `ESP32-HUB75-MatrixPanel-I2S-DMA` pair. It allocates its row +buffers once, in `begin()` (`.cpp:111-133`), one per row pair per buffer. A row +is module width × chain pixels, and there are module height / 2 of them +(`.h:750-751`, with `MATRIX_ROWS_IN_PARALLEL` 2 at `.h:67`). Each takes width × +colour depth × 2 bytes (`.h:165-168`, words of `uint16_t` at `.h:87`), from +`MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA` (`.h:195`). The PSRAM branch at `.h:186` +needs `SPIRAM_DMA_BUFFER`, and nothing in the tree defines it. The depth is the +default 8 (`.h:80`, at most 12 at `.h:83`), and nothing sets another. +`core_display.h:22` turns on double buffering. When a row does not fit, +`begin()` logs "Not enough memory" (`.cpp:123`) and returns false. + +The tree states no total, though `begin()` logs one at boot (`.cpp:134`). +Worked from that formula, as arithmetic, with two buffers at depth 8 and the DMA +descriptors (`.cpp:236`) left out: + +| geometry | row | rows | both buffers | +| --- | --- | --- | --- | +| 128×64, one module | 128 × 8 × 2 = 2,048 B | 32 | 131,072 B | +| 64×64, one module | 64 × 8 × 2 = 1,024 B | 32 | 65,536 B | +| 64×64, chain of 2 | 128 × 8 × 2 = 2,048 B | 32 | 131,072 B | + +The vendored blit adds limits of its own. Its fast passes need depth 8 and an +even width (`.cpp:979`), and an odd width or any other depth goes through the +older loop. Above depth 10 it refuses to write the frame (`.cpp:538`, `:958`). +The host test runs it at nine widths from 2 to 256, 127 among them +(`firmware/toolchain/tests/blit_test.cpp:73`). `src/hub75/VENDORED.md:147-188` +works out why the blit never tears for 12 buffers of 128 words at 16 MHz: 96 µs +a row pair against the blit's 109. The line it draws is buffers per row × words +per row / pixel clock (`VENDORED.md:172`), so a wider row moves it. + +## The site + +`web/src/lib/pattern/harness.ts:1-2` sets `PATTERN_MATRIX_WIDTH = 128` and +`PATTERN_MATRIX_HEIGHT = 64`, and both `PatternRuntime` (`:486`) and +`renderPatternStill` (`:652-653`) default to them. `matrix.ts:30` keeps its own +`DEFAULT_MATRIX` of 128×64. `// @matrix` takes 8 to 512 on a side +(`matrix.ts:46-47`), and `matrix.ts:127` says its fit must match `core_canvas.h`. + +`git grep -n -E 'new PatternRuntime\(' -- web/src | wc -l` gives 7. Four take +the pattern's frame (`sandbox/sandbox.ts:51`, `lib/lab/engine.ts:109`, +`GalleryPanel.tsx:106`, `desk/community/live.ts:46`), and `harness.ts:665` its +caller's. Two are fixed: `lib/guide/deviceSim.ts:167` through +`panelScreens.ts:42-43`, and `components/guide/Extras.tsx:185`, typed. The +sandbox canvas starts at 128×64 (`pattern-sandbox.template.html:18`) and resizes +to each frame (`sandbox.ts:104-107`). + +| what | where the size comes from | +| --- | --- | +| `DeviceSim` and the guide's stage | `PANEL_W`, `PANEL_H` in `lib/guide/panelScreens.ts:42-43`, read by `deviceSim.ts` and five stage files (`Device.tsx`, `Fx.tsx`, `look/ledPanel.ts`, `look/panelGlow.ts`, `world/hubMake.ts`) | +| the 3D panel's texture | typed, `components/3d/LedMatrixTexture.ts:20-21, 29` | +| the 3D shaders | `vec2(128.0, 64.0)`, typed 5 times (`git grep -n -F 'vec2(128.0, 64.0)' -- web/src`): `patterns/common.ts:55`, `HeroScene.tsx:63, 66`, `patternFlowOriginal.ts:23`, `patternWaveSaw.ts:45` | +| the guide's preview | typed, `Extras.tsx:185` and the `ImageData` at `:248` | +| the AI prompts | typed defaults, `lib/ai/gemini.ts:226, 387` | +| Pattern Lab's C++ export | `PATTERN_MATRIX_*`, `lib/lab/cppPrompt.ts:68` and `hExport.ts:165` | +| a pack's manifest | the `panel` field typed in `make_pack.py`, read as `lib/pattern/packs.ts:54` | + +`git grep -l -E '\b(display|d)\.(width|height)\b' -- 'web/src/lib/presets/pattern-*.ts' | wc -l` +gives 48, every JS preset, so each reads the size from the display +(`pattern-0716.ts` through `d`). `pattern-0719.ts` also types 128×64 for its +density map (33, 70, 132-134, 146) and its centre (40, 75). `pattern-0712-2.ts` +types its portrait grid as 64×128 (43, 53). No JS preset carries `// @matrix`. diff --git a/BUILD_GUIDE_v2.md b/docs/legacy/BUILD_GUIDE_v2.md similarity index 89% rename from BUILD_GUIDE_v2.md rename to docs/legacy/BUILD_GUIDE_v2.md index ae0b0dbe..ff70dcc5 100644 --- a/BUILD_GUIDE_v2.md +++ b/docs/legacy/BUILD_GUIDE_v2.md @@ -2,7 +2,7 @@ This guide walks you through building a Patternflow v2.0.0 from scratch. It assumes basic familiarity with through-hole soldering and 3D printing. -This is the current detailed path for a hand-soldered official PCB plus a PLA 3D printed enclosure. For the broader assembly map, see [docs/assembly/README.md](docs/assembly/README.md); the community cases it lists, laser-cut and printed, are for the v3 board. +This is the current detailed path for a hand-soldered official PCB plus a PLA 3D printed enclosure. For the broader assembly map, see [docs/assembly/README.md](../assembly/README.md); the community cases it lists, laser-cut and printed, are for the v3 board. > **No PCB? Try the breadboard build.** If you don't want to order or solder a custom PCB, there's a complete **PCB-free path** that wires the same parts together with a snapped-off breadboard power rail and jumper wires — no soldering iron required. See the **[Breadboard Build Guide](https://patternflow.work/build/breadboard)**. You can always move up to this PCB build later. @@ -10,13 +10,13 @@ This is the current detailed path for a hand-soldered official PCB plus a PLA 3D **Skill level:** Beginner-to-intermediate. If you've assembled a mechanical keyboard or built a basic Arduino project, you're ready — this is an all through-hole build. -> 🚀 **This is the legacy v2.x guide.** The current build is the **v3.9** board — see the main **[Build Guide](BUILD_GUIDE.md)**. Keep following this page only if you already have v2.x parts (v2 and v3 boards/cases are not interchangeable). +> 🚀 **This is the legacy v2.x guide.** The current build is the **v3.9** board — see the main **[Build Guide](../../BUILD_GUIDE.md)**. Keep following this page only if you already have v2.x parts (v2 and v3 boards/cases are not interchangeable). > > Photos in this guide were shot on an early build; where a photo looks slightly off, follow the written steps. > **What changed in v2.0.0.** PCB now includes a 10k pullup on GPIO0 (resolves the v1 cold-boot issue), silkscreen cleaned up to clearly mark R vs. C designators and the correct encoder solder side, firmware ships with a built-in custom-pattern template usable with any AI coding assistant, and the case files now include a 15mm encoder knob variant. Most of the case geometry is unchanged from v1; see Section 10 for the issues still open and the deliberate design notes worth knowing. -![All parts laid out before assembly](docs/build-guide/images/all_parts.jpg) +![All parts laid out before assembly](../build-guide/images/all_parts.jpg) --- @@ -63,14 +63,14 @@ This is the current detailed path for a hand-soldered official PCB plus a PLA 3D > > Patternflow drives the panel **directly from the ESP32-S3** — there is no sending/receiving card. So the panel's **driver IC** decides whether it works, and "HUB75E" on the listing is a connector, not a compatibility promise. A spec-matching panel with the wrong chips stays **completely dark** even when wiring, power and firmware are all correct. > -> - ✅ **Works:** classic shift-register drivers — **74HC595**, **FM6124**, **FM6126A**, **ICN2037**, **ICN2038S**, **DP5125D**, **DP3246**, **MBI5124**, **SM162xx**. Stock firmware drives most of these as-is; only if the panel comes up completely dark do you set `PANEL_PROFILE` in [`firmware/patternflow/config.h`](firmware/patternflow/config.h) to match your chip. +> - ✅ **Works:** classic shift-register drivers — **74HC595**, **FM6124**, **FM6126A**, **ICN2037**, **ICN2038S**, **DP5125D**, **DP3246**, **MBI5124**, **SM162xx**. Stock firmware drives most of these as-is; only if the panel comes up completely dark do you set `PANEL_PROFILE` in [`firmware/patternflow/config.h`](../../firmware/patternflow/config.h) to match your chip. > - ❌ **Does NOT work:** S-PWM / GCLK "video wall" drivers — **ICN2053**, **FM6353**, **FM6363C/FM6373C**, **DP3264/DP3265**, **ICND2055**, **MBI505x**. Usually sold on a **very high refresh rate (1920/3840Hz)** and/or that they **require a Nova/Linsn/Colorlight/Huidu receiving card**. > -> 📖 **Read [LED Panel Compatibility](docs/panel-compatibility.md) before ordering** — full chip lists, how to read the markings on a panel you already own, a symptom→cause table, and a copy-paste question to send the seller. The linked panel above is a known-good one. +> 📖 **Read [LED Panel Compatibility](../panel-compatibility.md) before ordering** — full chip lists, how to read the markings on a panel you already own, a symptom→cause table, and a copy-paste question to send the seller. The linked panel above is a known-good one. -PCB: order from your preferred fab using the **`patternflow_v2.1_gerber.zip`** Gerbers in [`hardware/pcb/gerber/`](hardware/pcb/gerber/) (or the KiCad source in `hardware/pcb/kicad/`). I used PCBway (sponsored). +PCB: order from your preferred fab using the **`patternflow_v2.1_gerber.zip`** Gerbers in [`hardware/pcb/gerber/`](../../hardware/pcb/gerber/) (or the KiCad source in `hardware/pcb/kicad/`). I used PCBway (sponsored). -> ⚠️ **For this (v2) guide, order v2.1 only.** This guide and its case files are built around the v2.1 board. The newer **v3 board** (`patternflow_v3.9_gerber.zip`; the v3 generation was verified in [#114](https://github.com/engmung/Patternflow/issues/114)) is a different size — it does **not** fit the v2 cases described here, and v2.1 boards don't fit the v3 cases. The v3 board has its own guide — the main [BUILD_GUIDE.md](BUILD_GUIDE.md). +> ⚠️ **For this (v2) guide, order v2.1 only.** This guide and its case files are built around the v2.1 board. The newer **v3 board** (`patternflow_v3.9_gerber.zip`; the v3 generation was verified in [#114](https://github.com/engmung/Patternflow/issues/114)) is a different size — it does **not** fit the v2 cases described here, and v2.1 boards don't fit the v3 cases. The v3 board has its own guide — the main [BUILD_GUIDE.md](../../BUILD_GUIDE.md). > ⚠️ **Don't order through the PCBWay shared project for this guide.** The [Patternflow PCBWay project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) now carries the **v3.0 board**, which does not fit the v2 cases described here. For a v2 build, download `patternflow_v2.1_gerber.zip` and upload it to your fab yourself. @@ -103,7 +103,7 @@ PCB: order from your preferred fab using the **`patternflow_v2.1_gerber.zip`** G **Print the main body, dividers, and one knob file.** Print the knob STL that matches your encoder shaft length — `knobs_15mm.stl` or `knobs_20mm.stl`. Each file is one print job; each knob STL contains all four knobs. -> 💡 **Easier option — skip the gluing (and the nippers).** [`hardware/case/legacy_v2/encloser_v2.1.stl`](hardware/case/legacy_v2/encloser_v2.1.stl) packs the newer **snap-fit** design, cut for the v2.1 board, into one STL — body, LED-panel mount, and knobs all inside. Print & assembly verified. Open it in your slicer, split the objects, print the knobs in black and the rest in white — then skip Section 3 entirely (no bonding; assembly follows the [v3 guide's Section 6](BUILD_GUIDE.md#6-case-assembly) sequence) **and skip Section 5.1 too**: the design recesses the LED matrix's alignment bumps, so no trimming. Bonus: a snap-fit back panel and two wall-mount holes. The plates below remain the classic, fully photo-documented path. +> 💡 **Easier option — skip the gluing (and the nippers).** [`hardware/case/legacy_v2/encloser_v2.1.stl`](../../hardware/case/legacy_v2/encloser_v2.1.stl) packs the newer **snap-fit** design, cut for the v2.1 board, into one STL — body, LED-panel mount, and knobs all inside. Print & assembly verified. Open it in your slicer, split the objects, print the knobs in black and the rest in white — then skip Section 3 entirely (no bonding; assembly follows the [v3 guide's Section 6](../../BUILD_GUIDE.md#6-case-assembly) sequence) **and skip Section 5.1 too**: the design recesses the LED matrix's alignment bumps, so no trimming. Bonus: a snap-fit back panel and two wall-mount holes. The plates below remain the classic, fully photo-documented path. > **Note:** an `easyfit` main-body variant (alignment tabs along the bond seam) existed briefly but was retired over a fit defect ([Issue #154](https://github.com/engmung/Patternflow/issues/154)). Use the standard `plate_main.stl`; the old variant remains at the [v2.1.0 tag](https://github.com/engmung/Patternflow/tree/v2.1.0/hardware/case) if you're curious. @@ -139,7 +139,7 @@ Apply super glue along the seam between the upper and lower halves of the main b Same procedure — bond the upper and lower halves of the back panel together. - + ### 3.3 Bond the internal divider @@ -147,7 +147,7 @@ Inside the case, there's an internal divider that separates the LED matrix volum **Insert the divider from the front side (the power bank / lower side), sliding it up into position.** Apply super glue along the divider edges to bond it to the case interior. Do **not** insert it from the back. - + Allow ~5 minutes after every bond step for the glue to fully cure before handling. @@ -159,7 +159,7 @@ This is an all through-hole build. Work small-to-tall — shortest parts first, > **No SMD required.** The PCB has footprints for optional 0805 passives (encoder pull-ups, filter caps, ESP32 decoupling caps) and an R13 GPIO0 pull-up, but they are **not populated** — a unit runs fine without any of them. See the design note in Section 10 for the rationale and the on-module cold-boot fix if you ever need it. - + ### Through-Hole Pass @@ -178,7 +178,7 @@ Solder, in order (small/short to tall): Wrong side (front) vs. correct side (back): - + The photos in this guide show the earlier 20mm-shaft encoders. The 15mm EC11 encoders install the same way; only the matching knob STL changes. @@ -267,7 +267,7 @@ The LED matrix has two small alignment bumps on its back, diagonally opposite ea > This step only applies to the glued-plate body. If you printed `encloser_v2.1.stl` (the 💡 option in Section 2), skip it — that design has recesses for the bumps. - + ### 5.2 Screw the matrix into the case @@ -277,7 +277,7 @@ The LED matrix has two small alignment bumps on its back, diagonally opposite ea > The screws thread directly into the LED matrix's mounting holes. Don't over-tighten. - + --- @@ -285,13 +285,13 @@ The LED matrix has two small alignment bumps on its back, diagonally opposite ea At this point the matrix is in the case but the PCB is **not** yet installed. You'll do all the wire-side work first — connecting the USB power input, the matrix power, and the HUB75 ribbon to the PCB while it's still loose and easy to handle. Then in Section 7 the whole PCB-with-cables-attached assembly drops into the case. - + ### 6.1 Wire the USB power input to J2 Cut the sacrificial USB cable short — trim it to a length that routes from the power bank compartment, through the divider hole, to the PCB position without excessive slack. Strip the +5V (red) and GND (black) wires. - + Pass the cable through the divider hole. Connect to **J2** (with the PCB-side facing you): @@ -300,13 +300,13 @@ Pass the cable through the divider hole. Connect to **J2** (with the PCB-side fa Tighten with a small flathead. - + ### 6.2 Wire the LED matrix power to J3 The LED matrix ships with a power cable (red/black) that has two red (+) and two black (−) wires. Hold it up to estimate reach to **J3** before cutting — give it just enough length to route cleanly without strain. Then cut, strip, and bundle each pair (the two reds together, the two blacks together) before inserting. - + Polarity matches J2: **inner = +5V (red pair)**, **outer = GND (black pair)**. @@ -333,31 +333,31 @@ The PCB sits in the dedicated PCB slot, with the rotary encoders facing through 3. While tilting the PCB toward flat, guide the upper encoders into their slots simultaneously. 4. Push the PCB flat against the case interior. - + ### 7.2 Secure the encoders from the front From the **front** of the case, attach each rotary encoder's nut and tighten with a wrench or pliers. This both secures the encoder shafts to the front face and locks the PCB in place. - + ### 7.3 Attach the back cover Slide the back cover panel into place along the rear of the case. - + ### 7.4 Close the PCB compartment slider Slide the PCB compartment cover panel into its slot to close off the electronics section. - + ### 7.5 Attach the knobs Press-fit the four black knobs onto the encoder shafts. Use the knob set that matches your encoder shaft length: `knobs_15mm.stl` for 15mm encoders, or `knobs_20mm.stl` for 20mm encoders. - + At this point the **Patternflow body is mechanically complete.** The only thing left is the brain. @@ -385,7 +385,7 @@ No installation required. Works on any desktop with Chrome or Edge. 3. Scroll to the **Patterns** section and click **"Flash Patternflow"**. 4. Select the correct serial port when prompted and follow the on-screen steps. Wi-Fi can be provisioned right there too (Improv-Serial). - + 5. Once flashing is complete, disconnect the USB-C cable. @@ -393,7 +393,7 @@ No installation required. Works on any desktop with Chrome or Edge. ### 8.2 Arduino IDE (Manual / Custom Builds) -Use this method if you want to modify the firmware source, add custom patterns, or if the browser flasher doesn't work for your setup. Custom patterns are not added to the release flasher automatically; you compile and upload your own firmware build. See [firmware/CUSTOM_PATTERNS.md](firmware/CUSTOM_PATTERNS.md). +Use this method if you want to modify the firmware source, add custom patterns, or if the browser flasher doesn't work for your setup. Custom patterns are not added to the release flasher automatically; you compile and upload your own firmware build. See [firmware/CUSTOM_PATTERNS.md](../../firmware/CUSTOM_PATTERNS.md). #### Prerequisites @@ -416,7 +416,7 @@ Use this method if you want to modify the firmware source, add custom patterns, > cd firmware/patternflow && pio run -t upload > ``` > -> Full detail in [firmware/README.md](firmware/README.md#required-board-package). +> Full detail in [firmware/README.md](../../firmware/README.md#required-board-package). #### Board Settings @@ -462,7 +462,7 @@ OTA updates work via Arduino IDE's network port option once the device has been With flashing complete and the USB cable disconnected, plug the ESP32-S3 module into the female pin sockets on the Patternflow PCB. - + --- @@ -474,7 +474,7 @@ With flashing complete and the USB cable disconnected, plug the ESP32-S3 module 4. The LED matrix should illuminate with the default pattern within a second or two. 5. Turn the four knobs to confirm they all respond. - + > If your unit does not boot reliably, press RESET on the ESP32-S3 module once. On v2.0 boards this should not be necessary -- if it consistently is, [open an issue](https://github.com/engmung/Patternflow/issues) with your module source (AliExpress / Espressif / other) and a photo of the GPIO0 area on your PCB. diff --git a/docs/midi-spec.md b/docs/midi-spec.md index 68e0124d..f31ca293 100644 --- a/docs/midi-spec.md +++ b/docs/midi-spec.md @@ -29,15 +29,16 @@ The mapping below is transport-independent. What ships today: | **USB** (a USB-MIDI class device — the MIDI edition) | The DevKit's USB port, the one the panel is flashed over, plugged into the computer. No driver and no session: the panel enumerates as a MIDI device named `Patternflow` (a virtual serial port comes with it — that is the console's log and Improv, leave it be). MIDI edition only: the port has to be the S3's USB-OTG controller for a MIDI device to exist, and that is a build-time choice ([`EDITIONS.md`](EDITIONS.md)). **Data only** — the panel is powered from `J4` as always, never from that cable. Both transports share the map below and can be up at once. | Channel: **1** by default (`PF_MIDI_CHANNEL`; `0` listens on every channel -and sends on 1). MIDI Thru is off — the panel never echoes a host's messages -back to it. +and sends on 1). MIDI Thru is off: the panel never echoes a host's CC or note +back to it. A Program Change from the host comes back as the pattern change it +made, as every pattern change does. ## Host → device | Message | Meaning | |---|---| | **CC 20 · 21 · 22 · 23** | Knob 1–4, **absolute**. `0..127` maps to the device's `0..1000` bus (rounded; 127 = 1000). The value **holds** until a hand turns that knob, exactly like `POST /api/params` and MQTT `param/N`. | -| **CC 24 · 25 · 26 · 27** | Knob 1–4, **relative**. `64` = no motion, `65` = +1 detent, `63` = −1, and so on (two's-complement offset). Merged with physical motion at 1× per step — the same lane as OSC's `/patternflow/knob/N/delta`. Does not release an absolute hold. | +| **CC 24 · 25 · 26 · 27** | Knob 1–4, **relative**. `64` = no motion, `65` = +1 detent, `63` = −1, and so on (binary offset). Merged with physical motion at 1× per step — the same lane as OSC's `/patternflow/knob/N/delta`. Does not release an absolute hold. | | **Note 60 · 61 · 62 · 63** (C4–D♯4) | Button 1–4. Note-on = press (edge), the button reads as held for as long as the note is held; note-off (or velocity 0) releases. | | **Program Change `n`** | Select pattern index `n` (0-based, the order on `/patterns`). Treated as a person's choice: it is remembered across reboots, like a knob pick. Ignored while a show claims the panel. | @@ -48,7 +49,7 @@ the channel is `0`. The device's rules, not MIDI's: **a hand on an encoder beats everything.** An absolute CC pins a knob until that encoder moves; relative CCs and notes are -just more input, and the audio lanes yield to both. +just more input, and the audio lanes yield to the hand and to an absolute CC. ## Device → host @@ -116,7 +117,7 @@ most. | `POST /api/midi?outMode=abs\|rel` | knobs out as a virtual position (default) or as relative steps. Persisted. | | `POST /api/midi?outMul=N` | outbound sensitivity the other way: steps per detent, `1..8`. Setting one of `outMul`/`outDiv` above 1 resets the other to 1; the console page shows both as one scale from ×8 through 1:1 to 1/16. | | `...&knob=1..4` | Apply `outMul`/`outDiv` to one knob; without it, to all four. Each knob keeps its own ratio; `GET` reports `outMul` and `outDiv` as four-element arrays. | -| `POST /api/midi?outDiv=N` | outbound sensitivity: detents per outbound step, `1..16`, persisted on the panel. The encoders have 20 detents a turn, so `1` is 20 steps per turn and `4` is 5. Default `1` (`PF_MIDI_OUT_DIVISOR`). | +| `POST /api/midi?outDiv=N` | outbound sensitivity: detents per outbound step, `1..16`, persisted on the panel. The encoders have 24 detents a turn, so `1` is 24 steps per turn and `4` is 6. Default `1` (`PF_MIDI_OUT_DIVISOR`). | | `POST /api/midi?host=` | A host to **invite**: the panel sends the session invitation itself on boot and again every 20 s while no session is up, so a panel that reboots comes back into the DAW without anyone reopening rtpMIDI or Audio MIDI Setup. The host must accept invitations (rtpMIDI: *Who may connect to me: Anyone*; macOS: the same setting in the Network MIDI window). Empty string clears it. Persisted. | ## Why these numbers @@ -135,6 +136,10 @@ most. - **1.2** (unreleased) — additive receive diagnostics, transport service independent of display frames, and suppression of outbound CCs generated from absolute-bus compatibility clicks. No new transport or CC mapping. + Where this file said two things, the section that defines the message now + stands: CC 24–27 are binary offset, as their examples always were; a host's + Program Change comes back as the pattern change it made; the audio lanes + yield to a hand and an absolute CC. - **1.1** (2026-09-06) — outbound CC 24–27 paced: one message per knob per 50 ms at most (`PF_MIDI_OUT_MIN_INTERVAL_MS`), the final value always sent; `rel` carries the steps summed since the previous message. On the Audio diff --git a/docs/midi-vectors/README.md b/docs/midi-vectors/README.md new file mode 100644 index 00000000..271f5751 --- /dev/null +++ b/docs/midi-vectors/README.md @@ -0,0 +1,15 @@ +# MIDI vectors + +Each file here is one sentence of [midi-spec.md](../midi-spec.md) and the messages that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:midi-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `midi` feature, and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A file's name says which table of the spec it reads. `in-` is a row of *Host → device*, or the channel rule under it, and shows what the panel does with that message. `out-` is a row of *Device → host*, something the panel sends when a hand moves it. `/api/midi` is REST, so its vectors are in [rest-api-vectors](../rest-api-vectors/README.md) and start with `midi-`. + +A message is its bytes, the status and then its data. `[176, 24, 65]` is CC 24 at 65 on channel 1, `[144, 60, 127]` is note 60 on and `[192, 3]` is Program Change 3. Each one the vector sends goes in an RTP-MIDI packet of its own. What the panel sends is read back into the same form, one message to an entry, with running status filled in. + +The suite brings the session. `web/scripts/drift/_rtpmidi.ts` is an initiator shaped like [`rtpmidi_probe.py`](../../tools/rtpmidi-probe/rtpmidi_probe.py). Before the first step it invites the panel on its control port and then on the data port after it, and syncs clocks once. After the last step it says `BY`. + +The panel applies what has arrived at the end of a frame, and the pattern is given it on the next. So a vector gives a message 20 ms, a frame at 60 fps and a little more, before it looks. A hand does not take back a channel a CC wrote less than 250 ms before (`ABSOLUTE_RELEASE_GRACE_MS` in `core_bus.h`). That rule is the firmware's, not the spec's, and the REST vectors wait it out the same way: `in-cc-hold.json` holds the channel a second before the turn. + +One vector keeps the spec where the firmware does something else. `in-ignored.json` sends CC 120, 121 and 123 while a note is held, and the firmware lets the note go on any of them (`core_midi.h`). + +Some of the spec has no vectors. MIDI over USB needs the S3's OTG controller, and a Node process has no USB device side. The receive map both transports share is checked natively in CI by `midi_test.cpp`. A vector opens one session, so the limit of two participants is not replayed, and the virtual panel does not advertise itself over Bonjour. It does not reboot or run shows, so a Program Change remembered across a reboot and one a show ignores are not replayed either. Channel `0` is a build setting. CC 24 to 27 are sent only at 63, 64 and 65, where the spec's two readings of a relative value agree. diff --git a/docs/midi-vectors/in-cc-absolute.json b/docs/midi-vectors/in-cc-absolute.json new file mode 100644 index 00000000..1cd64c29 --- /dev/null +++ b/docs/midi-vectors/in-cc-absolute.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "`0..127` maps to the device's `0..1000` bus (rounded; 127 = 1000).", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 20, 0] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [0, "", "", ""], "paramActive": [true, false, false, false] } } }, + { "send": { "midi": [176, 20, 1] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [8, "", "", ""] } } }, + { "send": { "midi": [176, 20, 64] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [504, "", "", ""] } } }, + { "send": { "midi": [176, 20, 127] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [1000, "", "", ""] } } }, + { "send": { "midi": [176, 21, 127] } }, + { "send": { "midi": [176, 22, 64] } }, + { "send": { "midi": [176, 23, 1] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [1000, 1000, 504, 8], "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/midi-vectors/in-cc-hold.json b/docs/midi-vectors/in-cc-hold.json new file mode 100644 index 00000000..56eede14 --- /dev/null +++ b/docs/midi-vectors/in-cc-hold.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "The value **holds** until a hand turns that knob, exactly like `POST /api/params` and MQTT `param/N`.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 21, 100] } }, + { "send": { "wait": 1000 }, "expect": { "status": { "params": ["", 787, "", ""], "paramActive": [false, true, false, false] } } }, + { "send": { "hand": { "knob": 1, "turn": 1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, true, false, false] } } }, + { "send": { "hand": { "knob": 2, "turn": -1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } } + ] +} diff --git a/docs/midi-vectors/in-cc-keeps-hold.json b/docs/midi-vectors/in-cc-keeps-hold.json new file mode 100644 index 00000000..48caf6ab --- /dev/null +++ b/docs/midi-vectors/in-cc-keeps-hold.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Does not release an absolute hold.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 20, 127] } }, + { "send": { "wait": 1000 }, "expect": { "status": { "paramActive": [true, false, false, false] } } }, + { "send": { "midi": [176, 24, 63] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [1000, "", "", ""], "paramActive": [true, false, false, false] } } } + ] +} diff --git a/docs/midi-vectors/in-cc-merged.json b/docs/midi-vectors/in-cc-merged.json new file mode 100644 index 00000000..15aec9fc --- /dev/null +++ b/docs/midi-vectors/in-cc-merged.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Merged with physical motion at 1× per step — the same lane as OSC's `/patternflow/knob/N/delta`.", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 3, "turn": 2 } } }, + { "send": { "midi": [176, 26, 65] } }, + { "send": { "midi": [176, 26, 65] } }, + { "send": { "midi": [176, 26, 65] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 0, 5, 0] } } } } + ] +} diff --git a/docs/midi-vectors/in-cc-relative.json b/docs/midi-vectors/in-cc-relative.json new file mode 100644 index 00000000..b13a0fea --- /dev/null +++ b/docs/midi-vectors/in-cc-relative.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "`64` = no motion, `65` = +1 detent, `63` = −1", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 24, 65] } }, + { "send": { "midi": [176, 25, 63] } }, + { "send": { "midi": [176, 26, 64] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [1, -1, 0, 0] } } } }, + { "send": { "midi": [176, 27, 63] } }, + { "send": { "midi": [176, 27, 63] } }, + { "send": { "midi": [176, 24, 65] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [2, -1, 0, -2] } } } } + ] +} diff --git a/docs/midi-vectors/in-channel.json b/docs/midi-vectors/in-channel.json new file mode 100644 index 00000000..707c0a6a --- /dev/null +++ b/docs/midi-vectors/in-channel.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Messages on another channel are ignored unless the channel is `0`.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [177, 20, 127] } }, + { "send": { "midi": [178, 25, 65] } }, + { "send": { "midi": [159, 60, 127] } }, + { "send": { "midi": [193, 3] } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] }, "input": { "given": { "deltas": [0, 0, 0, 0], "presses": [0, 0, 0, 0] }, "pattern": { "index": 0 } } } }, + { "send": { "midi": [176, 25, 65] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 1, 0, 0] } } } } + ] +} diff --git a/docs/midi-vectors/in-ignored.json b/docs/midi-vectors/in-ignored.json new file mode 100644 index 00000000..b3532ce6 --- /dev/null +++ b/docs/midi-vectors/in-ignored.json @@ -0,0 +1,20 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Everything else is ignored.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [144, 60, 127] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "held": [true, false, false, false] } } } }, + { "send": { "midi": [176, 123, 0] } }, + { "send": { "midi": [176, 120, 0] } }, + { "send": { "midi": [176, 121, 0] } }, + { "send": { "midi": [176, 19, 127] } }, + { "send": { "midi": [176, 28, 65] } }, + { "send": { "midi": [144, 64, 127] } }, + { "send": { "midi": [144, 59, 127] } }, + { "send": { "midi": [224, 0, 127] } }, + { "send": { "midi": [208, 100] } }, + { "send": { "midi": [160, 61, 100] } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] }, "input": { "given": { "deltas": [0, 0, 0, 0], "presses": [1, 0, 0, 0], "held": [true, false, false, false] }, "pattern": { "index": 0 } } } } + ] +} diff --git a/docs/midi-vectors/in-notes.json b/docs/midi-vectors/in-notes.json new file mode 100644 index 00000000..74173ff9 --- /dev/null +++ b/docs/midi-vectors/in-notes.json @@ -0,0 +1,18 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Note-on = press (edge), the button reads as held for as long as the note is held; note-off (or velocity 0) releases.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [144, 60, 127] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "presses": [1, 0, 0, 0], "held": [true, false, false, false] } } } }, + { "send": { "wait": 1000 }, "expect": { "input": { "given": { "presses": [1, 0, 0, 0], "held": [true, false, false, false] } } } }, + { "send": { "midi": [144, 61, 127] } }, + { "send": { "midi": [144, 62, 1] } }, + { "send": { "midi": [144, 63, 64] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "presses": [1, 1, 1, 1], "held": [true, true, true, true] } } } }, + { "send": { "midi": [128, 60, 0] } }, + { "send": { "midi": [144, 61, 0] } }, + { "send": { "midi": [128, 62, 64] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "presses": [1, 1, 1, 1], "held": [false, false, false, true] } } } } + ] +} diff --git a/docs/midi-vectors/in-program-change.json b/docs/midi-vectors/in-program-change.json new file mode 100644 index 00000000..71b403f6 --- /dev/null +++ b/docs/midi-vectors/in-program-change.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Select pattern index `n` (0-based, the order on `/patterns`).", + "features": ["midi"], + "steps": [ + { "send": { "midi": [192, 3] } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 3 } } } }, + { "send": { "midi": [192, 0] } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 0 } } } } + ] +} diff --git a/docs/midi-vectors/out-abs-same.json b/docs/midi-vectors/out-abs-same.json new file mode 100644 index 00000000..8b49e6bd --- /dev/null +++ b/docs/midi-vectors/out-abs-same.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "in `abs` only a position that differs from the last one sent goes out", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 25, 65], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": -1 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }] } }, + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 25, 66], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-buttons.json b/docs/midi-vectors/out-buttons.json new file mode 100644 index 00000000..937eca17 --- /dev/null +++ b/docs/midi-vectors/out-buttons.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "Encoder button 1–4 pressed", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 1, "down": true } }, "expect": { "out": [{ "midi": [144, 60, 127], "within": 20 }] } }, + { "send": { "wait": 500 }, "expect": { "out": [{ "midi": "" }] } }, + { "send": { "hand": { "knob": 1, "down": false } }, "expect": { "out": [{ "midi": [128, 60, ""], "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "down": true } }, "expect": { "out": [{ "midi": [144, 63, 127], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 4, "down": false } }, "expect": { "out": [{ "midi": [128, 63, ""], "within": 20 }, { "midi": "" }] } } + ] +} diff --git a/docs/midi-vectors/out-cc-abs.json b/docs/midi-vectors/out-cc-abs.json new file mode 100644 index 00000000..6e43938b --- /dev/null +++ b/docs/midi-vectors/out-cc-abs.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "the value is a virtual position `0..127` the panel keeps per knob — starts at 64, moves by that knob's `outMul` steps per `outDiv` detents, clamps at the ends", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 1, "turn": 3 } }, "expect": { "out": [{ "midi": [176, 24, 67], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": -2 } }, "expect": { "out": [{ "midi": [176, 25, 62], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 1, "turn": 62 } }, "expect": { "out": [{ "midi": [176, 24, 127], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": -63 } }, "expect": { "out": [{ "midi": [176, 25, 0], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 1, "turn": -1 } }, "expect": { "out": [{ "midi": [176, 24, 126], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 25, 1], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-cc-div.json b/docs/midi-vectors/out-cc-div.json new file mode 100644 index 00000000..f315fa36 --- /dev/null +++ b/docs/midi-vectors/out-cc-div.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "The encoders have 24 detents a turn, so `1` is 24 steps per turn and `4` is 6.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outDiv=4&knob=1" } }, + { "send": { "hand": { "knob": 1, "turn": 24 } }, "expect": { "out": [{ "midi": [176, 24, 70], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": 24 } }, "expect": { "out": [{ "midi": [176, 25, 88], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 1, "turn": 3 } }, "expect": { "out": [{ "midi": "", "within": 100 }] } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 24, 71], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-cc-mul.json b/docs/midi-vectors/out-cc-mul.json new file mode 100644 index 00000000..b38e2538 --- /dev/null +++ b/docs/midi-vectors/out-cc-mul.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "outbound sensitivity the other way: steps per detent, `1..8`.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outMul=8&knob=4" } }, + { "send": { "hand": { "knob": 4, "turn": 2 } }, "expect": { "out": [{ "midi": [176, 27, 80], "within": 20 }] } }, + { "send": { "hand": { "knob": 3, "turn": 2 } }, "expect": { "out": [{ "midi": [176, 26, 66], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 4, "turn": -1 } }, "expect": { "out": [{ "midi": [176, 27, 72], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-cc-rel.json b/docs/midi-vectors/out-cc-rel.json new file mode 100644 index 00000000..4112487d --- /dev/null +++ b/docs/midi-vectors/out-cc-rel.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "`64 ± steps` since the previous message (binary offset)", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outMode=rel" } }, + { "send": { "hand": { "knob": 3, "turn": -2 } }, "expect": { "out": [{ "midi": [176, 26, 62], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 3, "turn": 5 } }, "expect": { "out": [{ "midi": [176, 26, 69], "within": 20 }] } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 24, 65], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-hand-only.json b/docs/midi-vectors/out-hand-only.json new file mode 100644 index 00000000..b4aeed98 --- /dev/null +++ b/docs/midi-vectors/out-hand-only.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "Encoder 1–4 turned by a hand.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/params?p1=900" } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }], "status": { "params": [900, "", "", ""] } } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 24, 65], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-no-echo.json b/docs/midi-vectors/out-no-echo.json new file mode 100644 index 00000000..dfa213a2 --- /dev/null +++ b/docs/midi-vectors/out-no-echo.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "a CC or note the host itself sent is never reflected back", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 20, 100] } }, + { "send": { "midi": [176, 25, 65] } }, + { "send": { "midi": [144, 62, 127] } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }], "status": { "paramActive": [true, false, false, false] }, "input": { "given": { "deltas": [0, 1, 0, 0], "presses": [0, 0, 1, 0] } } } }, + { "send": { "midi": [128, 62, 0] } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }] } } + ] +} diff --git a/docs/midi-vectors/out-pacing.json b/docs/midi-vectors/out-pacing.json new file mode 100644 index 00000000..3566af9c --- /dev/null +++ b/docs/midi-vectors/out-pacing.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "at most one message per knob every 50 ms (≤ 20/s per knob, `PF_MIDI_OUT_MIN_INTERVAL_MS`), trailing-edge so the final value always arrives", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 24, 65], "within": 20 }] } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": "", "within": 10 }] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "midi": [176, 24, 67] }, { "midi": "" }] } } + ] +} diff --git a/docs/midi-vectors/out-program-change.json b/docs/midi-vectors/out-program-change.json new file mode 100644 index 00000000..37a1cc42 --- /dev/null +++ b/docs/midi-vectors/out-program-change.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "The pattern changed (by anyone: knob, console, OSC, MIDI, a show), for `n ≤ 127`", + "features": ["midi"], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=2" }, "expect": { "out": [{ "midi": [192, 2], "within": 20 }] } }, + { "send": { "midi": [192, 5] }, "expect": { "out": [{ "midi": [192, 5], "within": 50 }] } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }] } } + ] +} diff --git a/docs/midi-vectors/out-rel-sum.json b/docs/midi-vectors/out-rel-sum.json new file mode 100644 index 00000000..dc90b33e --- /dev/null +++ b/docs/midi-vectors/out-rel-sum.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "in `rel` the steps sum between messages and a sum leaving ±63 is sent at once", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outMode=rel" } }, + { "send": { "hand": { "knob": 4, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 27, 65], "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "turn": 2 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "turn": 3 } }, "expect": { "out": [{ "midi": "", "within": 10 }] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "midi": [176, 27, 69] }, { "midi": "" }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 4, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 27, 65], "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "turn": 40 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "turn": 40 } }, "expect": { "out": [{ "midi": [176, 27, 127], "within": 20 }] } }, + { "send": { "wait": 60 }, "expect": { "out": [{ "midi": [176, 27, 81] }, { "midi": "" }] } } + ] +} diff --git a/docs/mqtt-vectors/README.md b/docs/mqtt-vectors/README.md new file mode 100644 index 00000000..a8d9d00c --- /dev/null +++ b/docs/mqtt-vectors/README.md @@ -0,0 +1,13 @@ +# MQTT vectors + +Each file here is one sentence of [mqtt-spec.md](../mqtt-spec.md) and the steps that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:mqtt-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `mqtt` feature, and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A Patternflow is a broker's client, so the suite brings the broker. `web/scripts/drift/_broker.ts` starts one for each vector on a free port. It speaks MQTT 3.1.1 at QoS 0 and keeps retained messages, and a topic matches only itself. Before the first step the suite points the panel at it with `POST /api/mqtt`, host and port only, which leaves the role off. A vector picks the role in its first step, `POST /api/mqtt?role=subscriber` for one, and the panel dials on its next frame, so a `wait` comes next. A message the vector sends is `{ "topic": "patternflow/sleep", "payload": "1" }`, with `"retain": true` to keep it on the broker. It reaches the panel only on a topic the panel subscribed to, as on any broker. `out` is each message the panel published, with its `retain` flag beside the topic and payload. + +A file's name starts with the part of the spec it reads. `topics-` is the topic table and the two roles, and `channels-` is the channels and the snapshot. `/api/mqtt` is REST, so its vectors are in [rest-api-vectors](../rest-api-vectors/README.md) and start with `mqtt-`. + +The banner a `message` puts up is read with `GET /vp/mqtt`. That route is the virtual panel's own. The firmware has none, and `/frame.png` does not draw the banner. + +Two vectors keep the spec where the firmware does something else. `topics-param.json` empties `param/3` 20 ms after writing it, and the firmware keeps a channel written in the last 250 ms (`ABSOLUTE_RELEASE_GRACE_MS` in `core_bus.h`). `topics-knob.json` sends a count 27 past the one before, and the firmware cuts that to 16 (`KNOB_PENDING_FLUSH` in `core_mqtt.h`). + +Some of the spec has no vectors. The Director's topics (`query`, `select`, `select/ack`) are for one tool, and every other client ignores them. How soon a panel dials again after losing its broker is the firmware's to decide, not the spec's. The broker takes any user and password, so a refused login is not replayed. diff --git a/docs/mqtt-vectors/channels-broadcast.json b/docs/mqtt-vectors/channels-broadcast.json new file mode 100644 index 00000000..c4316d54 --- /dev/null +++ b/docs/mqtt-vectors/channels-broadcast.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "**Broadcast has no snapshot subscription.**", + "features": ["mqtt"], + "steps": [ + { "send": { "mqtt": { "topic": "patternflow/snapshot", "payload": "{\"param\":[100,200,300,400]}", "retain": true } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow&role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true, "paramActive": [false, false, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/param/2", "payload": "800" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/snapshot", "payload": "{\"param\":[100,200,300,400]}", "retain": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } } + ] +} diff --git a/docs/mqtt-vectors/channels-forced.json b/docs/mqtt-vectors/channels-forced.json new file mode 100644 index 00000000..d4f28435 --- /dev/null +++ b/docs/mqtt-vectors/channels-forced.json @@ -0,0 +1,20 @@ +{ + "contract": "docs/mqtt-spec.md#transport", + "says": "Channels 1–4 force the Subscriber role.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow5&role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow1" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow2" } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow3" } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow4" } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "subscriber", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow4/pattern", "payload": "Wave Saw" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Wave Saw" } } } + ] +} diff --git a/docs/mqtt-vectors/channels-heartbeat.json b/docs/mqtt-vectors/channels-heartbeat.json new file mode 100644 index 00000000..2ed1ecbd --- /dev/null +++ b/docs/mqtt-vectors/channels-heartbeat.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "A Publisher on the channel re-sends one every 8 seconds, and the retained copy is redelivered on every reconnect.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow5&role=publisher" } }, + { "send": { "wait": 20 }, "expect": { + "status": { "mqttRole": "publisher", "mqttConnected": true, "paramActive": [false, false, false, false] }, + "out": [{ "mqtt": { "topic": "patternflow5/snapshot", "payload": "", "retain": true } }] } }, + { "send": { "wait": 7900 }, "expect": { "out": [{ "mqtt": "" }] } }, + { "send": { "wait": 200 }, "expect": { "out": [{ "mqtt": { "topic": "patternflow5/snapshot", "payload": "", "retain": true } }] } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "subscriber", "mqttConnected": true, "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/mqtt-vectors/channels-overwrite.json b/docs/mqtt-vectors/channels-overwrite.json new file mode 100644 index 00000000..4ba87021 --- /dev/null +++ b/docs/mqtt-vectors/channels-overwrite.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "So on those five channels an external controller is not the only writer: its value is overwritten a moment later by whatever the snapshot last said, with every write succeeding and nothing reporting an error.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow2&role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow2/param/1", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [900, "", "", ""], "paramActive": [true, false, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow2/snapshot", "payload": "{\"param\":[250,500,500,500]}", "retain": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [250, 500, 500, 500], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow5" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "subscriber", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow5/param/3", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [250, 500, 900, 500], "paramActive": [true, true, true, true] } } }, + { "send": { "mqtt": { "topic": "patternflow5/snapshot", "payload": "{\"param\":[250,500,640,500]}", "retain": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [250, 500, 640, 500], "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/mqtt-vectors/channels-prefix.json b/docs/mqtt-vectors/channels-prefix.json new file mode 100644 index 00000000..31a0bfac --- /dev/null +++ b/docs/mqtt-vectors/channels-prefix.json @@ -0,0 +1,20 @@ +{ + "contract": "docs/mqtt-spec.md#transport", + "says": "Prefix `patternflow` is **Broadcast**; `patternflow1` … `patternflow4` are **channels 1–4**; `patternflow5` is **Live**; anything else is **Custom**.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow3&role=subscriber" }, "expect": { "reply": { "body": { "channel": "ch3", "prefix": "patternflow3" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/param/1", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow3/param/1", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [900, "", "", ""], "paramActive": [true, false, false, false] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow" }, "expect": { "reply": { "body": { "channel": "broadcast" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow1" }, "expect": { "reply": { "body": { "channel": "ch1" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow2" }, "expect": { "reply": { "body": { "channel": "ch2" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow4" }, "expect": { "reply": { "body": { "channel": "ch4" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow5" }, "expect": { "reply": { "body": { "channel": "live" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow6" }, "expect": { "reply": { "body": { "channel": "custom" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=studio" }, "expect": { "reply": { "body": { "channel": "custom" } } } } + ] +} diff --git a/docs/mqtt-vectors/channels-snapshot.json b/docs/mqtt-vectors/channels-snapshot.json new file mode 100644 index 00000000..3171c2dd --- /dev/null +++ b/docs/mqtt-vectors/channels-snapshot.json @@ -0,0 +1,22 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "Channels 1–4 and Live subscribe to the retained `/snapshot`, whose `param:[…]` the firmware applies straight onto the knobs, exactly as if it had arrived on `param/`.", + "features": ["mqtt"], + "steps": [ + { "send": { "mqtt": { "topic": "patternflow1/snapshot", "payload": "{\"param\":[110,120,130,140]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow2/snapshot", "payload": "{\"param\":[210,220,230,240]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow3/snapshot", "payload": "{\"param\":[310,320,330,340]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow4/snapshot", "payload": "{\"param\":[410,420,430,440]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow5/snapshot", "payload": "{\"param\":[510,520,530,540]}", "retain": true } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow1&role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [110, 120, 130, 140], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow2" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [210, 220, 230, 240], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow3" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [310, 320, 330, 340], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow4" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [410, 420, 430, 440], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow5" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [510, 520, 530, 540], "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/mqtt-vectors/topics-either-role.json b/docs/mqtt-vectors/topics-either-role.json new file mode 100644 index 00000000..301c4bbd --- /dev/null +++ b/docs/mqtt-vectors/topics-either-role.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "`sleep` and `message` are the two topics a panel obeys in **either** role", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/message", "payload": "Doors at nine" } } }, + { "send": { "http": "GET /vp/mqtt" }, "expect": { "reply": { "body": { "banner": "Doors at nine" } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/message", "payload": "Last call" } } }, + { "send": { "http": "GET /vp/mqtt" }, "expect": { "reply": { "body": { "banner": "Last call" } } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "1" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } } + ] +} diff --git a/docs/mqtt-vectors/topics-knob.json b/docs/mqtt-vectors/topics-knob.json new file mode 100644 index 00000000..687c1b07 --- /dev/null +++ b/docs/mqtt-vectors/topics-knob.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/mqtt-spec.md#which-topic-to-write", + "says": "The panel diffs it against the last value it received and injects the difference as a detent delta.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "0" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "5" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 5, 0, 0] } } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "5" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 5, 0, 0] } } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "-2" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, -2, 0, 0] } } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "25" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 25, 0, 0] } } } } + ] +} diff --git a/docs/mqtt-vectors/topics-param.json b/docs/mqtt-vectors/topics-param.json new file mode 100644 index 00000000..6b232676 --- /dev/null +++ b/docs/mqtt-vectors/topics-param.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "absolute `0`–`1000`; empty payload releases the hold", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/param/3", "payload": "750" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", "", 750, ""], "paramActive": [false, false, true, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/param/3", "payload": "1000" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", "", 1000, ""], "paramActive": [false, false, true, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/param/3", "payload": "0" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", "", 0, ""], "paramActive": [false, false, true, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/param/3", "payload": "" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } } + ] +} diff --git a/docs/mqtt-vectors/topics-pattern.json b/docs/mqtt-vectors/topics-pattern.json new file mode 100644 index 00000000..30ec819c --- /dev/null +++ b/docs/mqtt-vectors/topics-pattern.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "the pattern's display name or slug", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/pattern", "payload": "Wave Saw" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Wave Saw" } } }, + { "send": { "mqtt": { "topic": "patternflow/pattern", "payload": "Origin" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Origin" } } }, + { "send": { "mqtt": { "topic": "patternflow/pattern", "payload": "wave_saw" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Wave Saw" } } } + ] +} diff --git a/docs/mqtt-vectors/topics-publisher-ignores.json b/docs/mqtt-vectors/topics-publisher-ignores.json new file mode 100644 index 00000000..8c4522f1 --- /dev/null +++ b/docs/mqtt-vectors/topics-publisher-ignores.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/mqtt-spec.md#which-topic-to-write", + "says": "`knob`, `param` and `pattern` are obeyed **only in Subscriber role**; a Publisher ignores them silently.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/1", "payload": "5" } } }, + { "send": { "mqtt": { "topic": "patternflow/param/2", "payload": "800" } } }, + { "send": { "mqtt": { "topic": "patternflow/pattern", "payload": "Wave Saw" } } }, + { "send": { "wait": 20 }, "expect": { + "out": [{ "mqtt": "" }], + "input": { "given": { "deltas": [0, 0, 0, 0] } }, + "status": { "active": "Origin", "paramActive": [false, false, false, false] } } } + ] +} diff --git a/docs/mqtt-vectors/topics-publisher.json b/docs/mqtt-vectors/topics-publisher.json new file mode 100644 index 00000000..670286e7 --- /dev/null +++ b/docs/mqtt-vectors/topics-publisher.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/mqtt-spec.md#transport", + "says": "`publisher` (sends knobs, pattern and, on a show channel, a snapshot heartbeat)", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true } } }, + { "send": { "hand": { "knob": 2, "turn": -3 } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/knob/2", "payload": "-3", "retain": false } }] } }, + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/knob/2", "payload": "-2", "retain": false } }] } }, + { "send": { "http": "GET /api/patterns/select?index=1" } }, + { "send": { "wait": 20 }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/pattern", "payload": "", "retain": false } }] } } + ] +} diff --git a/docs/mqtt-vectors/topics-sleep-state.json b/docs/mqtt-vectors/topics-sleep-state.json new file mode 100644 index 00000000..d5de0b48 --- /dev/null +++ b/docs/mqtt-vectors/topics-sleep-state.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "`0` or `1`, on every change and once per connection", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0", "retain": false } }, { "mqtt": "" }] } }, + { "send": { "http": "POST /api/sleep?on=1" } }, + { "send": { "wait": 20 }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1", "retain": false } }] } }, + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true }, "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1", "retain": false } }] } } + ] +} diff --git a/docs/mqtt-vectors/topics-sleep-words.json b/docs/mqtt-vectors/topics-sleep-words.json new file mode 100644 index 00000000..6ceaa4be --- /dev/null +++ b/docs/mqtt-vectors/topics-sleep-words.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "`1` / `on` / `true` / `sleep` · `0` / `off` / `false` / `wake` · `toggle`", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "1" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "0" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "on" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "off" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "true" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "false" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "sleep" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "wake" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "toggle" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "toggle" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } } + ] +} diff --git a/docs/mqtt-vectors/topics-sleep.json b/docs/mqtt-vectors/topics-sleep.json new file mode 100644 index 00000000..09f29507 --- /dev/null +++ b/docs/mqtt-vectors/topics-sleep.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "Publishing `1` to `/sleep` puts the panel to sleep, `0` wakes it, and `sleep/state` mirrors the result", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true, "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "1" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "0" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } } + ] +} diff --git a/docs/osc-vectors/README.md b/docs/osc-vectors/README.md new file mode 100644 index 00000000..e227f91c --- /dev/null +++ b/docs/osc-vectors/README.md @@ -0,0 +1,11 @@ +# OSC vectors + +Each file here is one sentence of [osc-spec.md](../osc-spec.md) and the messages that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:osc-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `osc` feature, and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A file's name says which table of the spec it reads. `in-` is a row of *Host → device*, or the transport rules every message obeys, and shows what the panel does with that message. `out-` is a row of *Device → host*, something the panel sends without being asked. + +A message is its address and then its arguments. A whole number goes on the wire as an `int32`, any other number as a `float32` and text as a string, so `["/patternflow/knob/1/delta", 2.6]` sends a float. `["#bundle", ["/patternflow/ping"]]` is a bundle with a ping in it, its time tag set to "immediately". What the panel sends is read back into the same form. The suite sends from one UDP socket. A device answers on port 9000, and the virtual panel, started with `oscReply` at 0, answers to the port the message came from. + +The panel reads what has arrived at the end of a frame and answers in that frame. A knob delta reaches the pattern a frame after it is read. So a vector gives each message 50 ms, three frames at 60 fps, before it looks. + +Some of the spec has no vectors. The virtual panel has no radio, so the announce on Wi-Fi connect and `patternflow.local` are not replayed. A vector talks from one address, so neither is a host that changes. A knob a lane is moving is not reported, and a panel built with `osc` alone has no lane to move one. diff --git a/docs/osc-vectors/in-bundle.json b/docs/osc-vectors/in-bundle.json new file mode 100644 index 00000000..e8393700 --- /dev/null +++ b/docs/osc-vectors/in-bundle.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/osc-spec.md#transport", + "says": "Plain OSC 1.0 messages. No `#bundle` support — send bare messages.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["#bundle", ["/patternflow/ping"], ["/patternflow/knob/1/delta", 2]] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": "" }], "input": { "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "osc": ["/patternflow/knob/1/delta", 2] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"] }], "input": { "given": { "deltas": [2, 0, 0, 0] } } } } + ] +} diff --git a/docs/osc-vectors/in-content-toggle.json b/docs/osc-vectors/in-content-toggle.json new file mode 100644 index 00000000..38215085 --- /dev/null +++ b/docs/osc-vectors/in-content-toggle.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "No-op. Accepted and discarded — the Pattern/Video content-mode split was removed.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"] }] } }, + { "send": { "osc": ["/patternflow/content/toggle"] } }, + { + "send": { "wait": 50 }, + "expect": { + "out": [{ "osc": "" }], + "input": { "mode": "run", "pattern": { "index": 0 }, "given": { "deltas": [0, 0, 0, 0], "presses": [0, 0, 0, 0] }, "banner": null } + } + } + ] +} diff --git a/docs/osc-vectors/in-float.json b/docs/osc-vectors/in-float.json new file mode 100644 index 00000000..625f4c4c --- /dev/null +++ b/docs/osc-vectors/in-float.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/osc-spec.md#transport", + "says": "Numeric arguments may be sent as OSC `int32` (`i`) or `float32` (`f`); the device accepts both and rounds floats.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/knob/1/delta", 2.6] } }, + { "send": { "osc": ["/patternflow/knob/2/delta", -2.6] } }, + { "send": { "osc": ["/patternflow/pattern/index", 1.6] } }, + { "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [3, -3, 0, 0] } } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 2 } } } } + ] +} diff --git a/docs/osc-vectors/in-knob-delta.json b/docs/osc-vectors/in-knob-delta.json new file mode 100644 index 00000000..f61329f6 --- /dev/null +++ b/docs/osc-vectors/in-knob-delta.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "Virtual rotation on knob N (1–4), applied on top of physical motion at 1× per detent", + "features": ["osc"], + "steps": [ + { "send": { "hand": { "knob": 2, "turn": 1 } } }, + { "send": { "osc": ["/patternflow/knob/2/delta", 3] } }, + { "send": { "osc": ["/patternflow/knob/3/delta", -2] } }, + { "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [0, 4, -2, 0] } } } } + ] +} diff --git a/docs/osc-vectors/in-pattern-index.json b/docs/osc-vectors/in-pattern-index.json new file mode 100644 index 00000000..99f722cd --- /dev/null +++ b/docs/osc-vectors/in-pattern-index.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "Switch to the pattern at this registry index", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/pattern/index", 3] } }, + { "send": { "wait": 50 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 3 } } } }, + { "send": { "osc": ["/patternflow/pattern/index", 0] } }, + { "send": { "wait": 50 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 0 } } } } + ] +} diff --git a/docs/osc-vectors/in-ping.json b/docs/osc-vectors/in-ping.json new file mode 100644 index 00000000..614ea266 --- /dev/null +++ b/docs/osc-vectors/in-ping.json @@ -0,0 +1,22 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "Learn/refresh sender as the remote host and reply with a full announce (hello, version, ip, pattern index/name, modes).", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { + "send": { "wait": 50 }, + "expect": { + "out": [ + { "osc": ["/patternflow/hello", "Patternflow"] }, + { "osc": ["/patternflow/version", ""] }, + { "osc": ["/patternflow/ip", ""] }, + { "osc": ["/patternflow/pattern/index", ""] }, + { "osc": ["/patternflow/pattern/name", ""] }, + { "osc": ["/patternflow/content/mode", 0] }, + { "osc": ["/patternflow/app/mode", ""] } + ] + } + } + ] +} diff --git a/docs/osc-vectors/in-unknown.json b/docs/osc-vectors/in-unknown.json new file mode 100644 index 00000000..e704da4e --- /dev/null +++ b/docs/osc-vectors/in-unknown.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "Unknown addresses are ignored silently.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/knob/5/delta", 1] } }, + { "send": { "osc": ["/patternflow/pattern/next"] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": "" }], "input": { "pattern": { "index": 0 }, "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"] }] } } + ] +} diff --git a/docs/osc-vectors/out-button.json b/docs/osc-vectors/out-button.json new file mode 100644 index 00000000..1e3d4f99 --- /dev/null +++ b/docs/osc-vectors/out-button.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/osc-spec.md#device--host", + "says": "Encoder button N pressed", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 } }, + { "send": { "hand": { "knob": 3, "down": true } } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/button/3/press", 1] }, { "osc": ["/patternflow/button/3/held", 1] }] } }, + { "send": { "hand": { "knob": 3, "down": false } } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/button/3/held", 0] }, { "osc": "" }] } } + ] +} diff --git a/docs/osc-vectors/out-heartbeat.json b/docs/osc-vectors/out-heartbeat.json new file mode 100644 index 00000000..a3eb111b --- /dev/null +++ b/docs/osc-vectors/out-heartbeat.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/osc-spec.md#device--host", + "says": "Every 1 s while connected", + "features": ["osc"], + "steps": [ + { "send": { "wait": 1500 }, "expect": { "out": [{ "osc": "" }] } }, + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 1000 }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"] }, { "osc": ["/patternflow/heartbeat", ""] }, { "osc": "" }] } }, + { "send": { "wait": 1000 }, "expect": { "out": [{ "osc": ["/patternflow/heartbeat", ""] }, { "osc": "" }] } } + ] +} diff --git a/docs/osc-vectors/out-knob.json b/docs/osc-vectors/out-knob.json new file mode 100644 index 00000000..1dff2f2c --- /dev/null +++ b/docs/osc-vectors/out-knob.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/osc-spec.md#device--host", + "says": "Encoder N (1–4) turned by a hand; signed detent delta for this frame.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 } }, + { "send": { "hand": { "knob": 1, "turn": 2 } } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/knob/1/delta", 2] }, { "osc": ["/patternflow/knob/1/clicks", 2] }, { "osc": "" }] } }, + { "send": { "hand": { "knob": 1, "turn": -3 } } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/knob/1/delta", -3] }, { "osc": ["/patternflow/knob/1/clicks", -1] }, { "osc": "" }] } }, + { "send": { "osc": ["/patternflow/knob/1/delta", 5] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": "" }], "input": { "given": { "deltas": [4, 0, 0, 0] } } } } + ] +} diff --git a/docs/osc-vectors/out-pattern.json b/docs/osc-vectors/out-pattern.json new file mode 100644 index 00000000..21fdd2d2 --- /dev/null +++ b/docs/osc-vectors/out-pattern.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/osc-spec.md#device--host", + "says": "Pattern changed (or announce)", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 } }, + { "send": { "http": "GET /api/patterns/select?index=2" } }, + { + "send": { "wait": 50 }, + "expect": { "out": [{ "osc": ["/patternflow/pattern/index", 2] }, { "osc": ["/patternflow/pattern/name", ""] }, { "osc": "" }] } + } + ] +} diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 00000000..ca2aa603 --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,39 @@ +## [v1.0.0] - 2026-04 (initial public release) + +The first publicly buildable version of Patternflow. + +### Hardware +- 128x64 px HUB75 LED matrix (P2.5, 320x160 mm) +- ESP32-S3-WROOM-1 (N16R8 -- 16MB Flash, 8MB PSRAM) +- 4x EC11 rotary encoders with push-switches +- Custom PCB (KiCad) -- fabricated via PCBway sponsorship +- 3D-printed PLA case (white body, black knobs) +- Powered by user-supplied USB power bank, with internal mounting compartment + +### Firmware +- Arduino-based firmware for ESP32-S3 +- HUB75 DMA driver +- Default pattern set + +### Documentation +- `docs/BUILD.md` -- full build guide with BOM and assembly walkthrough +- AliExpress affiliate sourcing links for all electronic components +- KiCad project files for PCB +- STL files for case (3 prints total) + +### License +- Firmware & web: MIT +- Hardware & designs: CC-BY-SA 4.0 +- "Patternflow" is a trademark of SeungHun Lee + +### Known Issues +See [BUILD_GUIDE.md](../../BUILD_GUIDE.md) -- Known Issues section for full details. +- Reset button must be pressed once after power-up +- Rotary encoder direction reversed in PCB (compensated in firmware) +- Silkscreen ambiguity between 0805 caps and resistors +- LED matrix alignment bumps require trimming during assembly + +### Acknowledgments +- **PCBway** -- for sponsoring v1.0 PCB fabrication +- **r/arduino community** -- 1.6k upvotes on the prototype thread that pushed this from "just a personal project" toward open source +- **Doyoon** -- for the suggestion that started the LED matrix direction diff --git a/docs/releases/v1.1.0.md b/docs/releases/v1.1.0.md new file mode 100644 index 00000000..bf36b63c --- /dev/null +++ b/docs/releases/v1.1.0.md @@ -0,0 +1,16 @@ +## [v1.1.0] - 2026-04 (Multi-pattern Update) + +This update consolidates multiple patterns into a single firmware and introduces a runtime pattern selection mode. + +### Firmware +- **Refactored Modular Architecture**: Patterns are now modularized (`pattern_*.h`) and registered in a central registry. +- **Unified Input Handling**: Introduced `InputFrame` to share normalized encoder and button states across patterns. +- **Pattern Selection Mode**: Long-press Encoder 4 (1 second) to enter/exit the pattern selection UI. +- **New Pattern**: Added `Wave1_Saw` (rotated sawtooth waves with fractal noise distortion). +- **Improved Performance**: Replaced macros with namespace-scoped constants and optimized LUT usage. + +### Web +- **Consolidated Flasher**: The web flasher now provides a single "PatternFlow OS v1.1.0" image containing all patterns. +- **Simplified UI**: Removed individual pattern buttons in favor of the all-in-one OS build. + +--- diff --git a/docs/releases/v2.0.0.md b/docs/releases/v2.0.0.md new file mode 100644 index 00000000..7099e329 --- /dev/null +++ b/docs/releases/v2.0.0.md @@ -0,0 +1,24 @@ +## [2.0.0] - 2026-05 + +### Added +- **Custom pattern workflow.** New `firmware/CUSTOM_PATTERNS.md` with a prompt template and step-by-step guide for creating patterns using AI coding assistants. Drop in a new `pattern_*.h` file, register it, flash. +- **Web platform.** patternflow.work is now substantially complete: browser-based firmware flasher (Chrome/Edge), Live Editor, journal, and build map. +- **PCB R13** -- 10k pullup on GPIO0. Resolves the v1 cold-boot issue. +- **Silkscreen revisions** -- clear R/C designators, explicit encoder back-side marking. + +### Changed +- **Unified versioning.** Project, firmware, PCB, and case are tracked as a single Patternflow version. v2.0.0 covers all of them. +- **Pattern naming canonicalized.** "Origin" and "Wave Saw" are the display names across firmware, web, and docs. Firmware filename `pattern_wave1.h` renamed to `pattern_wave_saw.h`. +- **Build guide reorganized.** Known Issues section split into "Fixed in v2.0," "Still open," and "Design notes." + +### Fixed +- **Cold-boot unreliability** after extended power-off. Root cause: GPIO0 strapping pin floating. Full story: [Issue #16](https://github.com/engmung/Patternflow/issues/16). Credit to @idranoutof1d and u/Infrated on r/AskElectronics. + +### Still open +- **Issue #19** -- LED matrix alignment bumps require manual trimming. Will be addressed when the LED diffuser variant ships. + +### Deliberate non-changes +- **C11 (1000uF bulk cap)** retained despite USB inrush concerns. Patternflow is power-bank-powered, not desktop-USB-powered, and the cap improves boot transient stability. +- **Encoder direction** handled in firmware (sign inversion) rather than via PCB footprint re-spin. Transparent to users. + +--- diff --git a/docs/releases/v2.1.0.md b/docs/releases/v2.1.0.md new file mode 100644 index 00000000..a0752f2d --- /dev/null +++ b/docs/releases/v2.1.0.md @@ -0,0 +1,61 @@ +## [2.1.0] - 2026-07 + +The final consolidation release of the v2.x line before v3.0. Everything a v2.x builder needs — the v2.1 board, the case options, the current firmware and web tools — is pinned here. + +### Hardware — PCB + +- **v2.1 Gerbers are the recommended board** (`hardware/pcb/gerber/patternflow_v2.1_gerber.zip`). Over v2.0: reworked ESP32↔J1 (HUB75) routing and silkscreen fixes (clearer C15/R10 reference positions). The build guide pins PCB orders to v2.1. +- **v3.0 test board** lives in `hardware/pcb/gerber/experiment/` — hybrid power input (USB-C plus a 2-pin screw terminal bypass for beginners), all-through-hole, DRC clean. **Unverified — do not order** ([#114](https://github.com/engmung/Patternflow/issues/114)). This absorbs the earlier v2.2 USB-C experiment. +- Schematic component placement tidied to mirror the physical board layout. + +### Hardware — Case + +- **One-piece snap-fit enclosure** (`print-ready/variants/oneshot_v2_1/2.stl`) promoted to a supported print option ([#113](https://github.com/engmung/Patternflow/issues/113)): single-piece body plus snap-fit closing part, no gluing. Needs a ~330 mm bed (H2S-class). Includes a wall-mount hanger hole; stable print confirmed. +- **`easyfit` main plate variant** with alignment tabs along the bond seam — ⚠️ current STL is missing the LED matrix divider slot; do not print as-is ([#154](https://github.com/engmung/Patternflow/issues/154)). +- **Divided snap-fit experiments** (`print-ready/experiment/`): the one-piece design split to fit 256 mm (P1S-class) beds, including the latest `v2.1_divided.stl`. Modeled but **not print-tested yet**. +- `oneshot_v3-wip/` quarantined — for the upcoming v3.0 board only, not compatible with v2.x. +- USB-C adapter clearance fixed after the encoder position change. +- STLs are stored as regular git files (not LFS) so GitHub's *Download ZIP* works; the Blender source stays in LFS and is attached to releases. +- v1 Blender/SVG source for the original laser-cut acrylic case added for reference. + +### Firmware + +- **Foundation refactor.** Patterns render through `PFCanvas` instead of touching the HUB75 driver directly; shared `core_math` / `core_color` / `core_noise` libraries; foundation modules moved into `src/`. +- **Color and refresh quality**: gamma LUT applied in `PFCanvas::present()`, per-channel white balance and saturation boost, panel refresh raised to ~240 Hz to eliminate phone-camera flicker bands. +- **Controls**: encoder acceleration; K1 long-press global brightness mode; K2 long-press OSC info screen and runtime toggle (persisted in NVS); K3 knob-map screen; K1/K2 logical mapping fixed; encoder direction corrected for Bourns PEC11R parts. +- **Two-way OSC as a sidechannel** (no longer a content mode): accepts knob, pattern, and content commands; `/patternflow/ping` full-announce for late-starting hosts; `/patternflow/version` sent with `hello`; remote host auto-learned from the last valid sender (`PF_OSC_REMOTE_HOST` now optional); up to 8 datagrams drained per frame; numeric args accepted as int or float. +- **Wireless workflows**: ArduinoOTA flashing (with Arduino IDE 2.x workaround documented) and Improv-Serial Wi-Fi provisioning from the browser flasher. +- **Live pattern preview** behind the SELECT screen. +- **Audio-react foundation**: WebSocket server routing browser audio analysis through virtual knobs (opt-in override). +- **Pattern system**: preset library plus reusable custom slots with a custom-first registry (Origin stays pattern 1); memory-for-math optimization toolkit (`fastPow`, LUTs, typed angle constants replacing Arduino macros). +- Video Baker `PFV1` playback support and a standalone rotary encoder test sketch. + +### Web (patternflow.work) + +- **Pattern Lab**: development harness with calibrated knobs and encoder buttons; in-app Gemini pattern generation (bring-your-own-key); color ramp + v-field mode with a gradient editor; Experiment tab — a layer-stack patch editor that compiles to pattern code, with knob bindings. +- **Video Baker** tool for baking patterns to `PFV1` video. +- **Live Editor**: hardened C++ conversion prompt (exact helper signatures, expensive-math decision table, pre-baked ramp LUTs, knob ranges and `@knobs` annotations carried through), collapsible preset library with shuffle, pattern sharing modal and links, source-aware Discord share. +- **Build globe**: community builds shown on an interactive globe inside the Inside viewer, with multi-link support per build. +- **Interactive project map** replacing the status page, including the project's origins (PCBWay sponsorship, Nam June Paik Art Center). +- **SEO / AI discoverability**: robots.txt, sitemap, JSON-LD, `llms.txt`. +- **Crowd Supply pre-launch funnel**: hero and mobile floating CTAs link to the campaign page. +- CI via GitHub Actions: web build, lint as a hard gate, Discord notifications. + +### Integrations + +- **Ableton Live integration** (`integrations/ableton/`). A Max for Live bridge device maps the four hardware knobs to any Live parameters over OSC (relative encoder deltas, per-slot sweep sensitivity, mappings saved with the Live set), plus guides for M4L/OSC pitfalls and a filming-with-synced-sound workflow. +- **OSC spec** (`docs/osc-spec.md`): the wire protocol as a versioned contract for third-party integrations. + +### Docs + +- **Build guide moved to the repo root** as `BUILD_GUIDE.md`, with an all-through-hole BOM and PCB orders pinned to the v2.1 Gerbers. +- **Breadboard-only build guide** — the no-PCB path is now documented and surfaced as available. +- HUB75 driver-IC selection and panel compatibility guidance. +- Pattern licensing stated: CC-BY-SA-4.0, inbound=outbound. +- Issue form templates, a "Share your build" discussion form, and a development workflow section in CONTRIBUTING. + +### Known issues + +- `easyfit` main plate STL missing the divider slot ([#154](https://github.com/engmung/Patternflow/issues/154)). +- Divided (256 mm bed) snap-fit enclosure not yet print-tested. +- LED matrix alignment bumps still require manual trimming ([#19](https://github.com/engmung/Patternflow/issues/19)). diff --git a/docs/releases/v3.0.0-notes.md b/docs/releases/v3.0.0-notes.md new file mode 100644 index 00000000..599220fb --- /dev/null +++ b/docs/releases/v3.0.0-notes.md @@ -0,0 +1,51 @@ +# Patternflow v3.0.0 — the hybrid-power generation + +> **Historical (2026-07).** Long-form notes for one release, kept as written. Release notes since 3.2 live on [GitHub Releases](https://github.com/engmung/Patternflow/releases); `CHANGELOG.md` at the root is the complete record. + +The biggest hardware revision since launch. The v3.0 board and enclosure were rebuilt around one goal: **anyone should be able to solder and assemble this.** + +**Building one? Start here → [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE.md)** — the PCB soldering is covered by a [full video walkthrough](https://youtu.be/NZCjMBCsDAc). + +## Highlights + +### The board: two power inputs, zero SMD +- **Screw-terminal power** — strip a USB cable, clamp two wires into the 2-pin terminal on the back of the board. No soldering iron needed for the power input. + > ⏸️ **Update (2026-07):** the board also carries a USB-C footprint (`USB1`), but **that input is on hold** — a USB-C-powered board ran fine for 20–30+ minutes and then smoked at a connector pin, destroying the receptacle and power path ([#221](https://github.com/engmung/Patternflow/issues/221)). Leave `USB1`/`R1`/`R2` unpopulated and power the board through `J4` for now. See [BUILD_GUIDE §2](https://github.com/engmung/Patternflow/blob/main/BUILD_GUIDE.md#2-power-input--use-the-screw-terminal). +- **No SMD passives at all** — every part you solder is through-hole. The whole board is beginner-solderable. +- Fabricated, assembled, and verified on the screw-terminal input. + +### The enclosure: snap-fit, printable on a 256 mm bed +- Folders named by **printer bed size**: `bed_256mm/encloser.stl` (one STL, ~10 h on a P1S) or `bed_330mm/` one-piece for large-format printers. Knobs print separately in black — white body, black knobs. +- **Bambu printers: one click** — the case is on [MakerWorld](https://makerworld.com/ko/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015) with tuned print profiles; the same 4-plate project is attached below as `patternflow_v3.3mf`. +- **Snap-fit back panel**, **two wall-mount holes**, and recesses for the LED panel's alignment bumps — **the nipper-trimming step that's been with us since v1.0 is gone** ([#19](https://github.com/engmung/Patternflow/issues/19)). +- Using a different LED panel than the recommended one? `bed_256mm/for_other_panels/` has an adjustable-mount variant ([#169](https://github.com/engmung/Patternflow/issues/169)). + +### The BOM: every part by part number +- [`hardware/bom/bom_v3.0.csv`](https://github.com/engmung/Patternflow/blob/v3.0.0/hardware/bom/bom_v3.0.csv) — machine-readable, every part specified by MPN, sourcing notes included. Order from Mouser/DigiKey/LCSC or find the same parts anywhere. + +### The guide: watch, don't decipher +- [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE.md) rewritten for v3.0 — **two full video walkthroughs** ([PCB soldering](https://youtu.be/NZCjMBCsDAc) and [assembly to first power-on](https://youtu.be/J9C9bZgkNKs)), photo-documented printing/bonding steps, a collapsible netlist-derived pin reference, and a dedicated OSC/Ableton build path. +- PCB ordering routed through the [PCBWay shared project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) (no Gerber upload needed) — any fab works with the attached zip too. + +### Firmware +- **One image for every board generation** — v2.x and v3.0 share an identical pin map, so the [browser flasher](https://patternflow.work) just works on both. No board selection to get wrong. +- **Bigger preset library, sturdier engine** — thirteen new presets from the June–July daily batch, three fresh custom-slot patterns, and a new PSRAM-first allocator (`PFMem`) so a fully loaded pattern image can't starve the Wi-Fi/DMA DRAM budget. + +## Breaking changes + +- **v2.x boards and v3.0 cases are not interchangeable** (and vice versa) — different board size, different port positions. +- Already own a v2.x build? Everything you need stays bundled at the [v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0), and the v2 guide lives on as [`BUILD_GUIDE_v2.md`](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE_v2.md). Bonus for v2 owners: `hardware/case/legacy_v2/encloser_v2.1.stl` is a newly verified snap-fit case for the v2.1 board — no gluing, no trimming. + +## Assets + +- `patternflow_v3_case_stl_pack.zip` — all v3 case STLs (256 mm kit, 330 mm one-piece, adjustable-mount variant, knob plates) +- `patternflow_v3.3mf` — Bambu Studio project (4 pre-arranged plates with print settings; same as the [MakerWorld listing](https://makerworld.com/ko/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015)) +- `patternflow_v3.0_gerber.zip` — the verified board +- `patternflow_v3_firmware.zip` — prebuilt ESP32-S3 binaries (the browser-flasher image) with esptool offsets in the included README +- `patternflow_case.blend` — Blender source for every printed part (stored in Git LFS; attached here so *Download ZIP* users get the real file) + +Full details in the [CHANGELOG](https://github.com/engmung/Patternflow/blob/v3.0.0/CHANGELOG.md). + +--- + +Firmware & web: MIT · Hardware & designs: CC-BY-SA 4.0 diff --git a/docs/releases/v3.0.0.md b/docs/releases/v3.0.0.md index 599220fb..f6299efc 100644 --- a/docs/releases/v3.0.0.md +++ b/docs/releases/v3.0.0.md @@ -1,51 +1,17 @@ -# Patternflow v3.0.0 — the hybrid-power generation - -> **Historical (2026-07).** Long-form notes for one release, kept as written. Release notes since 3.2 live on [GitHub Releases](https://github.com/engmung/Patternflow/releases); `CHANGELOG.md` at the root is the complete record. - -The biggest hardware revision since launch. The v3.0 board and enclosure were rebuilt around one goal: **anyone should be able to solder and assemble this.** - -**Building one? Start here → [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE.md)** — the PCB soldering is covered by a [full video walkthrough](https://youtu.be/NZCjMBCsDAc). - -## Highlights - -### The board: two power inputs, zero SMD -- **Screw-terminal power** — strip a USB cable, clamp two wires into the 2-pin terminal on the back of the board. No soldering iron needed for the power input. - > ⏸️ **Update (2026-07):** the board also carries a USB-C footprint (`USB1`), but **that input is on hold** — a USB-C-powered board ran fine for 20–30+ minutes and then smoked at a connector pin, destroying the receptacle and power path ([#221](https://github.com/engmung/Patternflow/issues/221)). Leave `USB1`/`R1`/`R2` unpopulated and power the board through `J4` for now. See [BUILD_GUIDE §2](https://github.com/engmung/Patternflow/blob/main/BUILD_GUIDE.md#2-power-input--use-the-screw-terminal). -- **No SMD passives at all** — every part you solder is through-hole. The whole board is beginner-solderable. -- Fabricated, assembled, and verified on the screw-terminal input. - -### The enclosure: snap-fit, printable on a 256 mm bed -- Folders named by **printer bed size**: `bed_256mm/encloser.stl` (one STL, ~10 h on a P1S) or `bed_330mm/` one-piece for large-format printers. Knobs print separately in black — white body, black knobs. -- **Bambu printers: one click** — the case is on [MakerWorld](https://makerworld.com/ko/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015) with tuned print profiles; the same 4-plate project is attached below as `patternflow_v3.3mf`. -- **Snap-fit back panel**, **two wall-mount holes**, and recesses for the LED panel's alignment bumps — **the nipper-trimming step that's been with us since v1.0 is gone** ([#19](https://github.com/engmung/Patternflow/issues/19)). -- Using a different LED panel than the recommended one? `bed_256mm/for_other_panels/` has an adjustable-mount variant ([#169](https://github.com/engmung/Patternflow/issues/169)). - -### The BOM: every part by part number -- [`hardware/bom/bom_v3.0.csv`](https://github.com/engmung/Patternflow/blob/v3.0.0/hardware/bom/bom_v3.0.csv) — machine-readable, every part specified by MPN, sourcing notes included. Order from Mouser/DigiKey/LCSC or find the same parts anywhere. - -### The guide: watch, don't decipher -- [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE.md) rewritten for v3.0 — **two full video walkthroughs** ([PCB soldering](https://youtu.be/NZCjMBCsDAc) and [assembly to first power-on](https://youtu.be/J9C9bZgkNKs)), photo-documented printing/bonding steps, a collapsible netlist-derived pin reference, and a dedicated OSC/Ableton build path. -- PCB ordering routed through the [PCBWay shared project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) (no Gerber upload needed) — any fab works with the attached zip too. - -### Firmware -- **One image for every board generation** — v2.x and v3.0 share an identical pin map, so the [browser flasher](https://patternflow.work) just works on both. No board selection to get wrong. -- **Bigger preset library, sturdier engine** — thirteen new presets from the June–July daily batch, three fresh custom-slot patterns, and a new PSRAM-first allocator (`PFMem`) so a fully loaded pattern image can't starve the Wi-Fi/DMA DRAM budget. - -## Breaking changes - -- **v2.x boards and v3.0 cases are not interchangeable** (and vice versa) — different board size, different port positions. -- Already own a v2.x build? Everything you need stays bundled at the [v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0), and the v2 guide lives on as [`BUILD_GUIDE_v2.md`](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE_v2.md). Bonus for v2 owners: `hardware/case/legacy_v2/encloser_v2.1.stl` is a newly verified snap-fit case for the v2.1 board — no gluing, no trimming. - -## Assets - -- `patternflow_v3_case_stl_pack.zip` — all v3 case STLs (256 mm kit, 330 mm one-piece, adjustable-mount variant, knob plates) -- `patternflow_v3.3mf` — Bambu Studio project (4 pre-arranged plates with print settings; same as the [MakerWorld listing](https://makerworld.com/ko/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015)) -- `patternflow_v3.0_gerber.zip` — the verified board -- `patternflow_v3_firmware.zip` — prebuilt ESP32-S3 binaries (the browser-flasher image) with esptool offsets in the included README -- `patternflow_case.blend` — Blender source for every printed part (stored in Git LFS; attached here so *Download ZIP* users get the real file) - -Full details in the [CHANGELOG](https://github.com/engmung/Patternflow/blob/v3.0.0/CHANGELOG.md). - ---- - -Firmware & web: MIT · Hardware & designs: CC-BY-SA 4.0 +## [3.0.0] - 2026-07 + +### Added +- **v3.0 board promoted to the recommended revision** (`hardware/pcb/gerber/patternflow_v3.0_gerber.zip`). Fabricated, assembled, and verified ([#114](https://github.com/engmung/Patternflow/issues/114)): hybrid power input — USB-C (`USB1` + 5.1k CC pull-downs) or a back-side 2-pin screw terminal (`J4`) as the beginner bypass — all-through-hole, no SMD passives. **Not size-compatible with v2.x cases (and vice versa).** +- **Machine-readable BOM** (`hardware/bom/bom_v3.0.csv`): every part by manufacturer part number, per-unit quantities, assembly-critical notes. The LED panel remains an AliExpress link (its mounting-screw positions match the case). +- **Firmware preset library + PFMem.** Curated patterns now live in `firmware/patternflow/presets/` — thirteen new presets (the June–July daily batch) join the library, and the three custom slots ship with fresh patterns. New `src/core_mem.h` (**PFMem**) allocates framebuffer-sized pattern state PSRAM-first, so a fully loaded preset image can no longer boot-loop the device on DRAM pressure. The browser-flasher image carries all of it and self-reports 3.0.0. +- **v3 build guide** — now the main `BUILD_GUIDE.md`: [PCB soldering](https://youtu.be/NZCjMBCsDAc) and [assembly-to-first-power-on](https://youtu.be/J9C9bZgkNKs) each covered by a full video walkthrough, photo-documented print/case/wiring steps, netlist-derived pin reference. The v2 guide moved to `BUILD_GUIDE_v2.md` for existing v2.x builds. + +### Changed +- **`hardware/case/` reorganized by printer bed size**: `bed_256mm/encloser.stl` (the standard print — body frame, back panels, and LED-panel mount in one STL, ~10 h) and `bed_330mm/` (one-piece snap-fit); knobs print separately from `knobs/` in black, required for every build. `bed_256mm/for_other_panels/` holds the community divided variant with an adjustable LED-panel mount ([#169](https://github.com/engmung/Patternflow/issues/169)-tested) for panels other than the BOM-linked one. All v2.x-board cases moved to `case/legacy_v2/`. The snap-fit design adds two wall-mount holes and recesses for the panel's alignment bumps — no more nipper trimming ([#19](https://github.com/engmung/Patternflow/issues/19)). +- **Browser-flasher firmware refreshed** to the latest build — one image, compatible with both v2.x and v3.0 boards. The `patternflow_v2_firmware.zip` release asset on v2.1.0 was updated to match. +- The defective `easyfit` plate variant was removed ([#154](https://github.com/engmung/Patternflow/issues/154)); it remains at the v2.1.0 tag. +- Encoder shaft guidance neutralized: 15mm and 20mm are functionally identical; the BOM reference part (Bourns PEC11R-4220F-S0024) is 20mm. + +### Fixed +- C11 schematic value typo (`1000uF/1.6V` → `1000uF/16V`). +- `bed_256mm/encloser.stl` briefly carried the **v2.1** cut of the enclosure; it now holds the v3.0 cut. The v2.1 version — print & assembly verified — moved to `legacy_v2/encloser_v2.1.stl` and is the recommended v2 print (replacing both the untested `divided_v2.1.stl` and the glued plates as the default v2 path). diff --git a/docs/releases/v3.1.0-notes.md b/docs/releases/v3.1.0-notes.md new file mode 100644 index 00000000..ef7271f9 --- /dev/null +++ b/docs/releases/v3.1.0-notes.md @@ -0,0 +1,48 @@ +# Patternflow v3.1.0 — the browser does everything now + +> **Historical (2026-07).** Long-form notes for one release, kept as written. Release notes since 3.2 live on [GitHub Releases](https://github.com/engmung/Patternflow/releases); `CHANGELOG.md` at the root is the complete record. + +**Hardware unchanged** — the v3.0 board and case carry over exactly as they are. This release is the software half of the instrument catching up: a pattern community, firmware builds that run on a server and flash from the browser, wireless updates, and one important piece of safety guidance. + +**Building one? Start here → [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.1.0/BUILD_GUIDE.md)** — about 30 minutes of (deliberately easy, all through-hole) soldering, 30 minutes of assembly, ~10 hours of printer time, around US$100 in parts. + +## ⚠️ Safety first: power through the screw terminal only + +The v3.0 board's USB-C power input is **withdrawn from service** ([#221](https://github.com/engmung/Patternflow/issues/221)): a USB-C-powered board ran fine for 20–30+ minutes, then smoked at a connector pin. The failure is *delayed* — "it seems to work" proves nothing. Until the redesign passes: + +- Power the board through **`J4`, the 2-pin screw terminal** (strip a USB cable, screw the wires in). +- **Leave `USB1`, `R1`, and `R2` unpopulated.** + +[BUILD_GUIDE §2](https://github.com/engmung/Patternflow/blob/v3.1.0/BUILD_GUIDE.md#2-power-input--use-the-screw-terminal) has the full story and photos. + +## Highlights + +### The Community +Patterns now have a home: **[community.patternflow.work](https://community.patternflow.work/community)**. A live feed you can play — hover any card and the pattern runs, scroll-wheel turns its knobs — with detail pages where you edit the code in place. Publish from Pattern Lab, and remixes record their fork lineage so you can see what grew out of what. Patterns that ship a hardware-verified `.h` are flagged and filterable for when you want something to flash right now. No account needed to browse or edit; username + password only to publish. + +### Firmware builds, from the browser +The toolchain is gone ([#230](https://github.com/engmung/Patternflow/issues/230)): a build server compiles a complete firmware image containing your pattern — about 30 seconds — and the browser writes it to the board over USB (desktop Chrome/Edge). No IDE, no board package, no registry editing. And after the first flash sets up Wi-Fi, **Send over Wi-Fi** ([#232](https://github.com/engmung/Patternflow/issues/232)) pushes the next builds to the device with no cable at all. The Arduino IDE is now only for firmware feature development or targeting a different matrix resolution. + +### Pattern Lab, the studio +Pattern Lab grew into the professional editing tool: batch AI generation in-app (bring your own free Gemini key), color ramps, custom frames carried end to end (`// @matrix` travels from the lab through the community sandbox into the firmware conversion), session autosave, and a mobile-first Copy prompt / Paste response flow. Unused features were cut so the tool stays sharp. + +### The website +The pattern section was rebuilt around how Patternflow actually works now — try 42 presets one tap away, make your own with AI, then Pattern Lab and the Community carry it to hardware. The build section answers the two questions every builder asks first (**~US$100, ~1 hour of hands-on work**) and routes ordering straight through the [PCBWay shared project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) and the [MakerWorld case listing](https://makerworld.com/en/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015). A new interactive [project roadmap](https://patternflow.work/roadmap) shows the whole plan on one map. + +## Hardware note + +No new board, no new case. `patternflow_v3.0_gerber.zip` remains the board to order, and the v3 case files are unchanged — grab either from the [v3.0.0 release](https://github.com/engmung/Patternflow/releases/tag/v3.0.0) or the attachments below. On v2.x hardware? Everything stays bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). + +## Assets + +- `patternflow_v3_firmware.zip` — prebuilt ESP32-S3 binaries (the browser-flasher image: 34 presets + 3 custom slots) with esptool offsets in the included README +- `patternflow_v3.0_gerber.zip` — the verified board (unchanged from v3.0.0) +- `patternflow_v3_case_stl_pack.zip` — all v3 case STLs (unchanged from v3.0.0) +- `patternflow_v3.3mf` — Bambu Studio project (4 pre-arranged plates; same as the MakerWorld listing) +- `patternflow_case.blend` — Blender source for every printed part (Git LFS; attached so *Download ZIP* users get the real file) + +Full details in the [CHANGELOG](https://github.com/engmung/Patternflow/blob/v3.1.0/CHANGELOG.md). + +--- + +Firmware & web: MIT · Hardware & designs: CC-BY-SA 4.0 diff --git a/docs/releases/v3.1.0.md b/docs/releases/v3.1.0.md index ef7271f9..55302827 100644 --- a/docs/releases/v3.1.0.md +++ b/docs/releases/v3.1.0.md @@ -1,48 +1,26 @@ -# Patternflow v3.1.0 — the browser does everything now - -> **Historical (2026-07).** Long-form notes for one release, kept as written. Release notes since 3.2 live on [GitHub Releases](https://github.com/engmung/Patternflow/releases); `CHANGELOG.md` at the root is the complete record. - -**Hardware unchanged** — the v3.0 board and case carry over exactly as they are. This release is the software half of the instrument catching up: a pattern community, firmware builds that run on a server and flash from the browser, wireless updates, and one important piece of safety guidance. - -**Building one? Start here → [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.1.0/BUILD_GUIDE.md)** — about 30 minutes of (deliberately easy, all through-hole) soldering, 30 minutes of assembly, ~10 hours of printer time, around US$100 in parts. - -## ⚠️ Safety first: power through the screw terminal only - -The v3.0 board's USB-C power input is **withdrawn from service** ([#221](https://github.com/engmung/Patternflow/issues/221)): a USB-C-powered board ran fine for 20–30+ minutes, then smoked at a connector pin. The failure is *delayed* — "it seems to work" proves nothing. Until the redesign passes: - -- Power the board through **`J4`, the 2-pin screw terminal** (strip a USB cable, screw the wires in). -- **Leave `USB1`, `R1`, and `R2` unpopulated.** - -[BUILD_GUIDE §2](https://github.com/engmung/Patternflow/blob/v3.1.0/BUILD_GUIDE.md#2-power-input--use-the-screw-terminal) has the full story and photos. - -## Highlights - -### The Community -Patterns now have a home: **[community.patternflow.work](https://community.patternflow.work/community)**. A live feed you can play — hover any card and the pattern runs, scroll-wheel turns its knobs — with detail pages where you edit the code in place. Publish from Pattern Lab, and remixes record their fork lineage so you can see what grew out of what. Patterns that ship a hardware-verified `.h` are flagged and filterable for when you want something to flash right now. No account needed to browse or edit; username + password only to publish. - -### Firmware builds, from the browser -The toolchain is gone ([#230](https://github.com/engmung/Patternflow/issues/230)): a build server compiles a complete firmware image containing your pattern — about 30 seconds — and the browser writes it to the board over USB (desktop Chrome/Edge). No IDE, no board package, no registry editing. And after the first flash sets up Wi-Fi, **Send over Wi-Fi** ([#232](https://github.com/engmung/Patternflow/issues/232)) pushes the next builds to the device with no cable at all. The Arduino IDE is now only for firmware feature development or targeting a different matrix resolution. - -### Pattern Lab, the studio -Pattern Lab grew into the professional editing tool: batch AI generation in-app (bring your own free Gemini key), color ramps, custom frames carried end to end (`// @matrix` travels from the lab through the community sandbox into the firmware conversion), session autosave, and a mobile-first Copy prompt / Paste response flow. Unused features were cut so the tool stays sharp. - -### The website -The pattern section was rebuilt around how Patternflow actually works now — try 42 presets one tap away, make your own with AI, then Pattern Lab and the Community carry it to hardware. The build section answers the two questions every builder asks first (**~US$100, ~1 hour of hands-on work**) and routes ordering straight through the [PCBWay shared project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) and the [MakerWorld case listing](https://makerworld.com/en/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015). A new interactive [project roadmap](https://patternflow.work/roadmap) shows the whole plan on one map. - -## Hardware note - -No new board, no new case. `patternflow_v3.0_gerber.zip` remains the board to order, and the v3 case files are unchanged — grab either from the [v3.0.0 release](https://github.com/engmung/Patternflow/releases/tag/v3.0.0) or the attachments below. On v2.x hardware? Everything stays bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). - -## Assets - -- `patternflow_v3_firmware.zip` — prebuilt ESP32-S3 binaries (the browser-flasher image: 34 presets + 3 custom slots) with esptool offsets in the included README -- `patternflow_v3.0_gerber.zip` — the verified board (unchanged from v3.0.0) -- `patternflow_v3_case_stl_pack.zip` — all v3 case STLs (unchanged from v3.0.0) -- `patternflow_v3.3mf` — Bambu Studio project (4 pre-arranged plates; same as the MakerWorld listing) -- `patternflow_case.blend` — Blender source for every printed part (Git LFS; attached so *Download ZIP* users get the real file) - -Full details in the [CHANGELOG](https://github.com/engmung/Patternflow/blob/v3.1.0/CHANGELOG.md). - ---- - -Firmware & web: MIT · Hardware & designs: CC-BY-SA 4.0 +## [3.1.0] - 2026-07 + +A software, safety-guidance, and docs release — **hardware unchanged**: the v3.0 board and case carry over as-is (the Gerber keeps its `v3.0` name). + +### Safety +- **USB-C power input withdrawn from service** ([#221](https://github.com/engmung/Patternflow/issues/221)): a USB-C-powered board ran fine for 20–30+ minutes, then smoked at a connector pin, destroying the receptacle and power path. Power through the `J4` screw terminal only and leave `USB1`/`R1`/`R2` unpopulated until the redesign passes. Guide §2, the assembly map, and the v3.0.0 release notes all carry the hold now. +- The USB-C solder-joint comparison photos returned to the guide (§5) — the safety reference stays visible while soldering, video or not. + +### Added +- **Pattern Community** ([community.patternflow.work](https://community.patternflow.work/community)): a paged feed with hover-to-play live previews and scroll-wheel knob control, detail pages with in-place code editing and live knobs, publishing with recorded fork lineage, likes and sorting, comments, user profiles, and a Discussions text board ([#217](https://github.com/engmung/Patternflow/pull/217)). Patterns shipping a hardware-verified `.h` are flagged and filterable; licence headers (CC-BY-SA, inbound = outbound) are baked into the shared source. Browse and edit without an account — username + password only to publish. A self-hosting deployment guide is included. +- **Browser firmware builds** ([#230](https://github.com/engmung/Patternflow/issues/230)): a build queue + worker compiles a complete firmware image containing your pattern (~30 s) and the browser flashes it over Web Serial — no IDE, no board package, no registry editing. OSC ships enabled by default ([#231](https://github.com/engmung/Patternflow/issues/231)). +- **Send over Wi-Fi** ([#232](https://github.com/engmung/Patternflow/issues/232)): finished builds hand off to the device's own update page — after the first USB flash (which also sets up Wi-Fi), new patterns go over the air. +- **Pattern frames, end to end**: a pattern declares the matrix it was composed for (`// @matrix`) and that single fact travels through Pattern Lab, the community sandbox, and the firmware conversion — portrait and custom resolutions render correctly everywhere, and Pattern Lab accepts any frame typed directly. +- **Pattern Lab**: in-app batch AI generation (bring-your-own Gemini key), session autosave to localStorage, and a mobile Copy prompt / Paste response action bar. +- **Interactive project roadmap** at [/roadmap](https://patternflow.work/roadmap): the whole project on one zoomable map, with a Korean toggle and a media/press lane. +- **Build map**: every pin gets its own link, collaboration credits, a photo lightbox, and a phone layout. +- **MakerWorld listing** for the case, with tuned print profiles; the same 4-plate Bambu Studio project ships in the repo as `hardware/case/patternflow_v3.3mf`. Linked from the build guide §4 and the case README. + +### Changed +- **Landing pattern section rebuilt** around the current flow: a preset jump bar above the editor (42 presets, numbered), Pattern Lab promoted beside Community, the wired first-flash folded into the how-to steps with an accented Flash button, and Pattern Lab / Community vision cards replacing the outdated Arduino/Discord guide. The build section gained a cost & time reality check (~US$100 in parts · ~1 h hands-on · first-build friendly) and a spotlighted Build Guide v3.0.0 card with PCBWay / MakerWorld / release-bundle shortcuts. +- **Pattern Lab decluttered**: snapshots, sweep, the Experiment layer stack, Copy JSON, pause, and the Discord share flow removed; Share to Community and Build firmware remain. Mobile UI reworked across Pattern Lab and the community feed (full-width knob rows, dense static-thumbnail feed on phones). +- **Docs caught up with the product**: the main README (browser build server, Wi-Fi updates, 42 Live-Editor / 34 firmware preset counts, Pattern Lab & Community sections), the assembly map (v2/v3 distinction with release bundles, corrected power guidance), and the BUILD_GUIDE intro (no soldering experience needed; ~30 min soldering + ~30 min assembly). + +### Fixed +- Community feed thumbnails rendered at the wrong frame size (a shadowed loop variable in the sandbox's still renderer) — non-default-frame patterns now preview correctly. +- Build service: `patternflow_secrets.h` is synced into the sketch directory on slot updates, and board build options are pinned so server builds match the IDE. diff --git a/docs/releases/v3.10.0.md b/docs/releases/v3.10.0.md new file mode 100644 index 00000000..79c372ec --- /dev/null +++ b/docs/releases/v3.10.0.md @@ -0,0 +1,19 @@ +## [3.10.0] - 2026-09-08 + +### Improved — Audio MIDI +- Network MIDI socket service runs independently of pattern rendering, with ordered, bounded event queues and PSRAM payload storage. Incoming controls still apply on the render task. Per-knob receive counters, queue overflow and RTP/parser diagnostics expose where traffic is lost. +- Absolute-bus compatibility clicks no longer appear as physical encoder MIDI output, preventing incoming automation from being reflected as outbound CCs. + +### Improved — firmware core +- **Pattern storage mutations wait outside the render loop.** Conditional frame-boundary requests retry until a loader finishes; uploads/deletes then hold new activations until the catalog is rebuilt. Selections made during the hold retain their file identity across reordering. Restoration is asynchronous, and interrupted uploads remove their partial file before resuming. A reusable loader worker reserves its stack at boot, avoiding repeated stack allocation and a synchronous fallback under fragmentation; that stack is 8 KB, against a measured 2,980 B high-water across the heaviest catalog modules. +- **Module allocations are priced once, for the whole module, before anything is allocated.** The service reserve now *places* data and *prices* code: a module's executable sections are summed from its ELF section headers and admitted against `free - reserve` in a single decision, so the verdict cannot depend on the order sections are walked. Executable sections are placed first, because they are the only part with no PSRAM to fall back on; data can lose its placement to PSRAM but can never fail a load on its own. Nothing allocates in order to discover whether it was allowed to. Dynamic module allocations retain the existing 16-slot lifetime model, with a configurable 4 MiB byte limit and usage/refusal counters. `/api/status` publishes both sides of the comparison as `moduleMemory.budget` and `codeBytes`. Pattern code and the frozen ABI are unchanged. +- **Loading work has a defined cadence and the whole loop is observable.** A loading loop has an 8 ms minimum period; unchanged running-mode thumbnails refresh at most every 64 ms. Status gains whole-loop timing, short mutation timing, module allocation/worker-stack diagnostics and network-maintenance counters. Normal pattern rendering remains uncapped. +- **Slow HTTP traffic leaves opportunities for Wi-Fi/name maintenance.** Page bodies use nonblocking socket writes under the existing progress and total-time budgets; request-parser waits and conditional loop waits also service a bounded maintenance hook. This adds no network worker or stack, and does not re-enter HTTP handling. +- **The HUB75 blit now emits word accesses for its paired columns.** Its `memcpy` preserved aliasing rules but hid the DMA buffer's alignment from Xtensa GCC, which generated byte loads/stores and a stack temporary. Supplying the proven alignment retains the same colour/control bits and reduces measured `presentUs` from about **6.86 to 5.90 ms (14%)**, without another framebuffer. An odd-width buffer uses the scalar path. The host suite compares over 30 million DMA words, including FIFO order and depths 2–8. Bench conditions and frame-time results: [`docs/investigations/2026-09-firmware-runtime.md`](../../docs/investigations/2026-09-firmware-runtime.md). +- **Thumbnail disk I/O leaves the frame loop.** One immutable PSRAM snapshot is handed to the existing network task; completed reads become visible at a frame boundary. No additional task stack, and optional thumbnails never fall back to scarce internal RAM. Deletion/format generations reject stale results; a new capture wins over a pending read. `/api/status` gains `thumbs` counters and separate capture/I/O timing. +- **Wi-Fi gives a brief dropout time to recover on the working SSID** before cycling saved alternatives. DHCP address changes also trigger the connection hook. Name-service startup and partial registration failures retry on the network task, preserving services registered by features; duplicate announcements for one connect edge are coalesced. The MIDI feature retries its own service registration. Status gains `network` diagnostics and `POST /api/wifi/reconnect` requests a delayed reconnect without rebooting or rewriting credentials. + +### Fixed — module loader +- **A module could be refused while the RAM it needed was free.** The service reserve was applied as a veto rather than a placement rule, and for executable allocations the check ignored the requested size entirely, so a 2 KB `.text` was refused exactly as hard as a 200 KB one whenever byte-addressable internal free dipped under the reserve. Combined with a persistent 12 KB loader stack, the module's share of internal RAM fell to about 3.7 KB against roughly 700 B of boot-to-boot variance — heavy patterns started intermittently, and which ones changed with every boot. Pricing the whole module up front and returning 4 KB of loader stack raises that share to about 7.8 KB, enough for the heaviest module in the shipped Basics pack (5,364 B of executable image) to clear it by 2.4 KB. An admission failure now counts as a refusal so the loader's existing retry absorbs a transient dip caused by an in-flight HTTP response. +- **`api->alloc()` is zeroed again.** The frozen ABI documents it as a PSRAM-preferred *zeroed* allocation, and the runtime rework dropped the fill, so a pattern that allocated a trail map or accumulator and read it before writing was reading whatever the previous module left behind. +- Reject overflowing allocation/relocation ranges and unterminated ELF string-table entries; share header validation between upload and load. Copy unresolved-symbol diagnostics before freeing the image they point into. The frozen module ABI is unchanged; native regression tests also accept all 33 shipped Basics modules. diff --git a/docs/releases/v3.10.1.md b/docs/releases/v3.10.1.md new file mode 100644 index 00000000..9eb3c05f --- /dev/null +++ b/docs/releases/v3.10.1.md @@ -0,0 +1,227 @@ +## [3.10.1] - 2026-09-10 + +### Fixed — the pattern SDK + +Two helpers that every pattern compiles into itself did not do what their +names said, and a third gained a variant it was missing. The two fixes change what a pattern draws, and because `abi/pf_module.h` +*includes* these files rather than calling into the host, the change reaches only +**newly built** `.pfm` modules: an installed module and a rebuild of its own source +will render differently on the same firmware. Rebuild a module to pick these up. + +- **`PFColor::sampleRampLerp` is new; `sampleRamp` is unchanged.** `sampleRamp` + holds the last stop at or below `t`, giving flat bands with hard edges. That + reads like a gradient function that forgot to interpolate, and it is not one — + Origin is built on it and its stepped colour is deliberate. Patterns that want + a gradient now call `sampleRampLerp`, which interpolates between the bracketing + stops and costs one soft-float division per sample; the C++ conversion prompt + names both and says which is which. +- **`PFColor::hsvToRgb` rounds.** Each channel was `(uint8_t)(f * 255.0f)` — + truncation, no `+ 0.5f` — so the function sat half a level dark on every channel, + always in the same direction: a mean of **−0.470 LSB** swept over the HSV cube. + `buildPowLUT`, four functions below in the same header, has always rounded. +- **`PFNoise::cellHash`'s seed decorrelates.** It was added to `gx` before the same + hash, making `cellHash(x, y, s)` identical to `cellHash(x + s, y)` at **every one + of 262,144 lattice points** — two layers seeded 0 and 1 were one field and a copy + of it slid a cell sideways. The cost is visible in `preset_0713`, which draws seeds + 7 through 13 for each firefly's speed, phase and brightness and got one sequence + offset by one index each time, so every "independent" parameter was the same + stream in disguise. The seed now passes through the permutation table, because it + cannot enter by addition anywhere in that expression: adding it to the final index + is the same as adding it to `gy`, and adding it to both coordinates merely + translates diagonally — measured against every shift within ±4 cells, that obvious + repair still correlates **1.0000** with the unseeded field. What shipped correlates + **0.0128**. Two extra reads of a 512-byte table that is already hot. + The two-argument overload is untouched, so `valueNoise2D`, `perlin2D` and + `fractal2D` render exactly as before. + +### Improved — pattern authoring + +- **`floorf`, `fminf` and `fmaxf` stop being calls inside the SDK's own helpers.** + On this chip each one is a call into libm, not an instruction. `PFMath` gains + `ifloor`, `floorF`, `clamp` and `clamp01`; `fract`, `hsvToRgb` and the noise + lattice now use them, with results identical bit for bit. Measured on a rebuilt + module on the panel: 1.2% faster. The C++ conversion prompt now tells new + patterns to use them — and stops telling them that `sqrtf` is cheap or that + `perlin2D` spans −1..1 (it runs to about ±1.51). Installed `.pfm` modules are + unchanged; the helpers reach a module when it is next built. + +### Fixed — the panel + +- **Raising a code no longer lowers the light.** A bit plane's contribution is + its OE window times the number of times the DMA descriptor chain repeats it, + and the repeats already carry one factor of two per plane. The window had to + supply the rest, and it was derived by shifting — which can only halve, and + could not express the ratios the repeat counts need. Worse, the plane index + was mapped through `(2 * depth - colouridx) % depth`, which sends plane 0 — + the least significant — to the branch that hands it the same full-length + window the most significant plane gets. + + Measured at depth 8: the planes delivered `125, 31, 126, 500, 1000, 2000, + 4000, 8000` where binary wants `62, 125, 250, 500, …`. Plane 0 twice what it + should be, plane 1 a quarter, plane 2 a half. Swept over all 256 codes and + three channels that is **26 places where raising the code lowers the light**, + two of them by half, back to back — and 44 of 256 levels whose channel ratio + departs from the configured white balance. Seen on a panel: level 14 reads red + where level 13 is neutral grey. + + The window is computed now rather than approached: `u · 2^d / repeats(d)`. + Inversions go to **zero** and the ratio departures to 11, at every brightness. + Full-scale luminance moves about 1%, so the eye-converged brightness and + white-balance constants keep their meaning. No per-pixel cost — this runs once + when brightness changes. + + The arithmetic is now a pure function, `pfOEWindowPixels()`, lifted verbatim + into a new host suite (`check_oe.py`). Nothing had ever tested it, because + reaching `setBrightnessOE` needs a live DMA allocation — but the defect was + never in writing the buffer. + +- **The blit refuses a colour depth it cannot represent.** Its plane fields pack + five planes into each of two words, so ten is the ceiling; above that the + shifts lost bits and then became undefined, while the configuration allowed up + to twelve. Nothing ships above eight, so this was latent — and the host suite + swept 2–8, so nothing would have found it. It now sweeps 2–10. + +- **Dark greys stop coming out coloured.** Only the top bits of the 16-bit CIE + value reach a bit plane, and the rest was dropped rather than rounded. Truncation + is biased downward by up to half a plane step everywhere, which is invisible in + the bright half and is the entire signal in the dark end: eight input levels + emitted nothing at all, and neighbouring neutral greys came out as *opposite* + colour casts, because each channel crossed its threshold at a different code. + Seen on the panel: a neutral level 9 read blue, 17 read cyan and 18 read red. + Rounding to nearest is one line in a table built once per depth change — no + per-pixel cost at all. Computed over all 256 levels and three channels: dead + levels 8 → 4, level 9 becomes neutral, 17 and 18 both become the same neutral + grey, and mid-tone ratios land closer to the intended white balance (level 64 + goes from 10/11/10 to 10/11/11). Full-scale white moves by one unit on blue + only, so the hand-converged `LED_WB_*` constants keep their meaning and do not + need re-converging. + + Because the blit is firmware rather than part of each module's compiled copy, + this is the one fix in this release that reaches **already-installed `.pfm` + modules** — every pattern gets it without being rebuilt. + + What this is *not*: the panel's response was already monotone. Claims that + raising a code could lower the light were tested directly — alternating levels + 63/64, 107/108 and 127/128 against a control pair that pulses obviously — and + nothing was visible at any of them. There are no inversions to fix. + +### Fixed — audio in + +- **A panel with no microphone no longer takes the knobs.** The Hann window used + the symmetric denominator (`N - 1`) where a stream analysed hop after hop needs + the periodic one (`N`). Only the periodic window has a DFT of exactly three + bins, so only it keeps a constant input out of every other band — and the + dead-rail check downstream is precisely a test for "constant input, nothing in + the bands". Computed over the shipped band edges with a flat rail, the + symmetric window gave 0.0666 / 1.8e-3 / 1.3e-4 / 1.3e-5, three of the four + above the 1e-4 threshold; the periodic one gives ~1e-13 in all four. So the + check returned false for every dead rail there has ever been, an audio-edition + panel with the mic switched on but nothing wired claimed all four lanes and + pinned them, and a hand on a knob sprang back. The measurement recorded in that + function's own comment was taken before there was a window at all, which is how + adding one broke it without touching it. +- **A backlogged microphone no longer colours the lanes for seconds.** The drain + loop keeps only the newest hop, so after it ran the window was built from two + pieces of audio that were not adjacent, spliced at index 256 — the exact centre, + where the Hann term peaks at 2.0 and attenuates the discontinuity by nothing. + Reading alternately into two hop buffers keeps the hop that actually precedes + the newest one. The drain itself was right and is unchanged; only the seam was + wrong. + +### Fixed — MIDI + +- **A stuck note can be cleared, and stops disabling the panel's own button.** + Held-note state had exactly one clearing edge — a note-off for that note — on + the one input path that can lose packets. Three ways out were missing: the + channel-mode messages (All Sound Off, Reset All Controllers, All Notes Off) fell + through the CC handler and did nothing, so the control a person reaches for when + a note sticks was inert; turning MIDI off at runtime discarded the note-off + while `fillInput` kept asserting the button, because it was the one handler with + no runtime gate; and an RTP peer that vanished took the release with it. All + three now clear. This matters more than it sounds: `observeFrame` reads + `btnHeld && !noteHeld` to decide a press was physical, so a stuck MIDI note also + silenced the encoder button under it. + +### Fixed — the parameter bus + +- **How far a legacy pattern travels no longer depends on how finely the sender + chopped the move.** An absolute write reaches a delta-only pattern as clicks at + ten bus units each, and the rule "never fewer than one for a change" turned a + smooth ten-second ease into one click per update: 830 of them at 83 fps where + the travel means 100, and half that at 41 fps. The remainder is carried now, so + the total is the travel and the frame rate is out of it — which is what the show + player already claimed of itself. +- **`dN` on an unheld channel works.** `applyRemoteDelta` accepted a click on any + channel but only the held branch drained the queue, so a delta sent to a channel + nobody was holding did nothing visible and then arrived in full the moment + something held it, or was wiped by the next release. It is also bounded now: + `d1=999999999` was accepted and handed to a pattern as that many clicks. +- **`POST /api/params?rN=1` releases a channel.** The bus could be written over + HTTP but not let go of over HTTP — the only ways back were a hand on that + encoder, or MQTT and the show player, which the default, audio and clock + editions do not carry. And since holding a channel also clears the audio lane + under it, one console slider could silence a mic lane with no way to undo it + from the same console. + +### Fixed — patterns and storage + +- **Re-uploading a pattern updates its picture.** Both upload paths and the + library pull invalidated the sidecar cache under a slug and not the thumbnail, + so editing a pattern in the Lab and sending it back under the same name left the + previous version's frame on the volume, and SELECT drew it under the new name. + It survived reboots. +- **A stalled frame no longer makes the picture jump.** `dt` was an unbounded + `millis()` delta, and installing a pattern, an NVS commit or a thumbnail write + all produce frames far longer than a nominal one — measured at 273,949 µs + against 12,000 µs on an ordinary upload session. Every pattern integrates + against that number, so it moved everything a quarter of a second in one step. + Bounded at 100 ms, at the single place it is produced. + +### Fixed — firmware core + +- **An upload that stalled could panic the board.** The wait loop in the request + parser's `_uploadReadByte()` was the one of three that never yielded: upstream + writes it brace-less, so inserting the maintenance poll above the `delay(2)` + without adding braces made the poll the entire body and left the delay outside the + loop. It runs on the network task — Core 0, priority 1 — where a loop that never + blocks starves the idle task, and this SDK configuration watches that idle task + with a panic at five seconds. Installing a pattern is precisely when the link is + under load. +- **A failed settings write no longer deletes a good pattern.** Two paths cleared + their own retry flag *before* the write it guarded. The damaging one is the boot + latch: it cleared its flag and then wrote `pat_trying = false`, so a write that + failed left the latch set on disk with nothing left to clear it — and the next + boot reads a set latch as "the last boot died with this pattern resident" and + forgets a pattern that was never at fault. Both now clear only once the write is + confirmed, the pattern save reuses its existing 3 s debounce as a retry backoff, + and the namespace open is checked. `/api/status` gains `nvs.usable` and + `nvs.failures`, because the whole difficulty with this failure is that its symptom + arrives a boot later. The brightness, power and Wi-Fi writes are unchanged. + +### Added — checks + +- **`check_math.py`** pins what `core_math.h`, `core_color.h` and `core_noise.h` + compute. `abi/pf_module.h` includes those three into every `.pfm`, so they are the + published pattern SDK — and nothing here had ever run a number through them. Their + accuracy lived in doc-comments, two of which were wrong about their own function + (`fastAtan2` is 7.4× tighter than it claims; `approxLength` is 6.8% where the + comment says ~5%). 19 rows, each a **band** rather than a ceiling, so an accuracy + change fails in either direction and has to be re-pinned deliberately. Note what + `abi.sums` cannot do here: it hashes the three files in `abi/`, so it passes + unmoved while the behaviour those files include changes underneath it. +- **`check_footprint.py`** reads each edition's elf and pins its static internal RAM. + CI built four images and then printed their flash sizes — the one resource that is + not scarce. Internal DRAM is, and the module code budget is its residual. Measured + by the *address* of the linker's own `.dram0.heap_start` marker rather than a sum + of section names, which would quietly measure less if a toolchain renamed them. +- **The shipped modules are priced, not just validated.** The ELF suite already + opened all 33 `.pfm` in the Basics pack; it now sums their executable sections by + the loader's own admission rule and fails over a ceiling. Nothing checked that the + modules this repository ships can be admitted by the firmware it ships, which is a + failure it has already had, and which took a hardware bisect to find. +- **A declared hook must be dispatched, and a dispatched hook must be called.** The + boundary checker had five rules and all five policed what the core must *not* say. + The worst regression this project has shipped went the other way: the per-frame + feature dispatch went missing and every feature's loop silently stopped, on a build + that compiled, linked, ran and served every route. Compiling four editions cannot + catch that — a hook nobody calls type-checks perfectly. diff --git a/docs/releases/v3.10.2.md b/docs/releases/v3.10.2.md new file mode 100644 index 00000000..9dab58e7 --- /dev/null +++ b/docs/releases/v3.10.2.md @@ -0,0 +1,23 @@ +## [3.10.2] - 2026-09-11 + +### Fixed — pattern storage + +- **A Format that did not take no longer reports success.** `FFat.format()` never + reads back what it wrote, and the flash driver never checks that a program + landed. On a board whose flash silently drops writes, every step returned OK: + the console said "storage ready", and the next mount failed with the same + `Error: -1` as an unformatted board, which sent the owner round the Format + button indefinitely (#424). The mount that follows a Format is the first read, + so it now decides the result: if it fails, Format fails, and says why. +- **A failed mount says why.** The core's line is `Mounting FFat partition + failed! Error: -1` whatever the cause, because FATFS's own reason is logged at + a level this SDK compiles out. The first failure in a boot now reads the boot + sector through the wear-levelling layer and records what it found: `not + formatted`, `format did not stick: storage reads back blank`, or the first and + last bytes of a sector that is not a filesystem. The reason goes to serial and + to the new `fsError` field in `/api/status`. It runs at most once per boot and + once after a Format, and never on a mounted volume. +- `/api/status` gains `flashId`, the flash chip's JEDEC id, so a fault that + follows one flash part is visible without USB. +- A comment in `pattern_registry.h` still described the automatic + format-on-failure removed in 3.2.0, and was being read as current behaviour. diff --git a/docs/releases/v3.10.3.md b/docs/releases/v3.10.3.md new file mode 100644 index 00000000..f4a2cb59 --- /dev/null +++ b/docs/releases/v3.10.3.md @@ -0,0 +1,23 @@ +## [3.10.3] - 2026-09-17 + +### Hardware + +- **v3.9 board** (2026-09-04). The USB-C power receptacle `USB1` and its CC pull-downs `R1`/`R2` — which v3.0 shipped and every document told you to leave unpopulated after the delayed burnout in [#221](https://github.com/engmung/Patternflow/issues/221) — come off the board. `J4`, the screw terminal, is the only power input there is. Edge cuts and mounting holes are byte-identical to v3.0, so the v3 cases fit both. The 330 mm case gains a pass-through to the DevKit's USB port (data, for wired MIDI/OSC and flashing — not power) and a cable exit slot. Gerbers, BOM, renders and schematic regenerated; `bom_v3.9.csv` is the BOM source of truth. + +### Firmware + +- **Purple and sky-blue fringes round bright edges are gone, greys stop reading pink, and the panel refreshes at 325 Hz.** A plane's bits are on the LEDs while the *next* buffer in the DMA chain clocks in, so the OE window written into buffer `b` lights the plane latched before it - the driver's own `clearFrameBuffer()` says so. 3.10.1 read upstream's full window on buffer 0 as an LSB bug and shrank it; it was the top plane's display time, and raising a code from 127 to 128 then lowered the light by 42% (upstream: 28%). On a pattern with a near-white background each channel crosses that code at a different point along every edge that fades to dark, so edges grew a purple and then a sky-blue fringe. 3.10.1's analysis was also made at transition bit 0; this panel ran at 4. Upstream's chain cannot be made binary without throwing light away - solved exactly it keeps 42% of the row lit, and on the panel that was too dark - so the chain is new as well: twelve buffers a row (`0 1 2 3 4 5 6 7 6 7 7 7`) instead of fifteen, windows `1, 1/32 … 1/2, 1, 1`, exactly binary, 66% of the row lit (upstream, not binary: 80%) and 325 Hz where it was 260. `begin()` now picks a chain *scheme* against `min_refresh_rate` rather than a transition bit: a slower clock costs brightness, never colour depth. `oe_test.cpp` walks the chain itself instead of sharing a formula with the driver; the 30,000 assertions that passed 3.10.1 had modelled delivered light the same wrong way. Checked on hardware at every step by switching builds. The power clamp's full-white figure was measured on upstream's windows and now over-estimates by about a fifth; left alone until measured. **White balance is identity again:** `LED_WB_*` 0.930 / 1.000 / 0.975 had been converged by eye at full white on top of upstream's weights, so it corrected the driver at one level and not the panel; it read pink across the greys, and the reference panel uncorrected reads neutral (checked on the GRAYS screen). A panel that carries its own `LED_WB_*` in `patternflow_secrets.h` should re-judge it. `/panel-tuner.html?device=` connects straight to a panel. +- **Knobs page.** `/knobs` on the device console, and `GET`/`POST /api/knobs` behind it: which way each encoder counts and how many edges make one click, per knob, with a live readout to check against. These are properties of the part soldered in - some encoders count the other way round, some give two edges per detent - and until now the answer was `INVERT_ENCODER` in `config.h` and a rebuild. Stored on the device; a saved change does not move the knob. The Live guide also gains the one setting that made a wired or wireless MIDI knob pin itself and tick: *Remote* on the port's Output row sends every mapped value straight back. +- **The Clock edition leaves the shelf.** The clock is a feature; `bundles/clock` stays in the tree as a composition CI keeps compiling, and its last image, `clock-v0.1.5`, stays on the v3.10.2 tag. The shelf is what its maintainer stands behind — Core and Audio, and Performance from its own — and it is short on purpose. +- **An edition ships on its own.** `release.py edition vA.B.C` bumps, shelves, points the card and commits: one command for an edition's maintainer to cut from a pull request, with no core release in between. Firmware CI builds the five compositions in parallel, one job each, and pins each one's static RAM. +- **MIDI edition** (`firmware/bundles/midi`, v0.1.0): the DevKit's USB port as a class-compliant MIDI device, beside RTP-MIDI — plug the panel into a computer with the cable it is flashed over and it is a MIDI port named `Patternflow`, no driver and no session. Same map as every transport (`docs/midi-spec.md`); `midiUsb` in `/api/status`. The port has to be the S3's USB-OTG controller for a MIDI device to exist, which is a build flag the other images cannot carry (it is what makes the port their serial console), so the edition builds in its own PlatformIO env — `env`, the third file a bundle may now have. Data only: the panel is powered from `J4` as always. In the tree and built by CI; not on the shelf yet. +- **Storage errors say why.** An upload whose destination will not open reports `errno`, so a missing `/patterns` (ENOENT) reads differently from a volume that will not write (EIO), and storage is diagnosed again after a successful mount, so a later failure carries a reason instead of an empty `fsError`. From RayWolters' investigation on [#424](https://github.com/engmung/Patternflow/issues/424). + +### Web + +- **The features page.** `/features` is the catalogue the shelf never was: every feature as one row — name, one line, and a tag saying where it lives (every firmware, Audio, Performance, a recipe in the tree, or an attempt that did not work) — so the whole list fits on a screen, and a row opens to what it needs, which firmware carries it, the links and a reel of it running. No headings between rows: which firmware carries a feature is a fact on its row, never a category over it, or the shelf would be back one level up. Reels are Instagram embeds by permalink, loaded only when a row is opened. The shelf and the catalogue share one header: Firmware and Features as a pair of tabs where the title was, the current one the title, the other a click away. +- **Try-out images on the features page.** The clock and the USB-MIDI build are not on the shelf, and until now trying either meant building it. Their rows carry a frozen image now — `clock-v0.1.5` as it shipped with 3.10.2, `midi-v0.1.0` built from the tree on 2026-09-17 — installed the way the shelf installs, through the panel's own `/update` page. Frozen means frozen: the image is the whole firmware as it was, core included, and nobody bumps it when the core moves; patterns, Wi-Fi networks and settings stay, and the way back is one click on the shelf. `check_versions.py` reads these folders from `features-data.ts`, so the rule that the shelf holds only what is named still holds. + +### Docs + +- **The repository explains itself to a contributor.** One table in `CONTRIBUTING.md` of what you have → where it goes → what CI runs on it; `docs/REPOSITORY.md` as the folder-by-folder map for people; `SUPPORT.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `CITATION.cff` and a root `LICENSE` pointer; a README in every contributor-facing subtree (`tools/`, `integrations/`, `firmware/modules/`, `firmware/toolchain/`, both `presets/`); `docs/mqtt-spec.md` beside the other contracts; `hardware/case/remixes/` as the named home for community enclosure variants, with a one-step CI check of its READMEs. Dead pointers removed: GitHub Discussions (never enabled), pattern pull requests (patterns are published from the Pattern Lab to the Community), the `.agents/` folder, `release.yml`. Stale facts fixed in place: the assembly map and `hardware/README.md` now name the v3.9 board, `firmware/README.md` no longer teaches the console pause that ended in 3.6.3, and the Ableton bridge says "install the Audio edition" instead of "rebuild with OSC". diff --git a/docs/releases/v3.10.4.md b/docs/releases/v3.10.4.md new file mode 100644 index 00000000..407d171e --- /dev/null +++ b/docs/releases/v3.10.4.md @@ -0,0 +1,5 @@ +## [3.10.4] - 2026-09-19 + +### Firmware + +- **The panel refreshes at exactly 300 Hz, because that is what a phone can film.** A row is lit once per refresh for 1/32 of it - a pulse, not a glow - so a rolling shutter collects a whole number of pulses per sensor line, and unless the exposure is an exact multiple of the refresh period some lines get N and some N + 1: bands. Phones pin exposure to 1/50, 1/100, 1/25 or 1/60, 1/30 to dodge mains flicker, and 300 Hz is the lowest rate all of those divide. 3.10.3's 325.5 Hz sat close to the worst case for both 1/50 (6.51 periods - the whole picture banded) and 1/60 (5.43), and it showed: more lines on video than the 260 Hz it replaced, which had happened to sit nearer whole numbers. Every frame now ends in 4,181 blank clocks, output off, bringing it to 16 MHz / 300 = 53,333. Simulated band contrast at 1/25, 1/30, 1/50, 1/60 and 1/100 goes to zero, and it looked that way through a phone. It costs about 8% of the light and no CPU at all - the DMA engine walks the chain by itself; frame and push times on the panel are unchanged. **1/120 and faster still band**, at any rate this clock can reach (that would take 600 Hz), and so does slow motion: lock the shutter at 1/60 or 1/50, or turn the panel down so the phone lengthens its exposure. `PF_TARGET_REFRESH_HZ` (0 to switch off); never pads below `min_refresh_rate`. The old claim in `firmware/README.md` - past 240 Hz a camera "averages 4+ cycles and the bands disappear" - was wrong, and is replaced with how it actually works. diff --git a/docs/releases/v3.10.5.md b/docs/releases/v3.10.5.md new file mode 100644 index 00000000..2b92dd8f --- /dev/null +++ b/docs/releases/v3.10.5.md @@ -0,0 +1,33 @@ +## [3.10.5] - 2026-10-02 + +### Firmware + +- **A panel that crashed says where.** `resetReason` in `/api/status` said `"panic"` or `"task_wdt"` and nothing else - not where, and not which of forty installed patterns was on. The SDK had been writing a core dump to its own flash partition on every panic all along, and nothing read it. At boot the firmware now reads that dump's summary - the task, the cause, the faulting address, the PC and up to sixteen return addresses, and the hash of the image that crashed - and it keeps a breadcrumb through the reset: the pattern's slug, which of its calls was running (loading, constructors, `setup`, `update`, `draw`, or none of them) and where the module's code was executing from. Status gains a `crash` object with both. With the two together an address inside a module comes out as an offset (`"+0x4e"`) that the `.pfm`'s own symbol table resolves, whether the code ran from PSRAM or from internal RAM. `DELETE /api/crash` clears it, and the same record is printed on serial at boot (`[CRASH] ...`). It reports and does nothing else: no pattern is skipped, no reboot is forced, and what the panel does after a crash is what it did before. The breadcrumb covers the boot that follows a panic or a watchdog and is gone with the power; the dump stays in flash, so a board that panicked on an older firmware shows that dump, marked `fromThisReset: false`, until it is cleared. Checked on a board: a module stored through a null pointer in `draw()` while running from PSRAM, and after the reboot status named the pattern, the phase, `pc` `+0x4e` and the caller at `+0xb6` - the offsets the module's own symbols give for those two functions - with the dump read in 62 ms; a dump left by an earlier firmware appeared as `fromThisReset: false`. It costs 296 bytes of internal RAM. `docs/rest-api.md` ("The crash record") says how to read one and decode it, and `firmware/toolchain/check_crash.py`, a new host check, boots the logic through every reset reason against every state of the breadcrumb and the dump; CI runs it. +- **Every pattern is 2.45 ms a frame faster.** Copying a finished frame to the panel's bit-planes was the largest fixed cost in the loop: 5.96 ms of every frame, nearly half of Origin's. The kernel was one long loop with more live values than the CPU has registers and a plane count it only learned at run time; it is now three short passes with the 8-bit case written in, 390 instructions a column pair where there were 635, emitting exactly the same words (`check_blit.py` compares 70 million of them). Measured on two boards, alternating images: `presentUs` 5,960 -> 3,496 µs on the default and Audio editions alike, so Origin goes from 12.1 to 9.6 ms a frame and a 30 ms pattern to 27.6. With white balance or gamma tuned away from the defaults it is 3.8 ms. It also takes 1 KB less internal RAM than the loop it replaces. `/api/status` gains `loopStackMin`. +- **A pattern's code no longer competes with the network for memory.** A loaded module's code used to need one contiguous block of internal executable RAM - the same RAM the console, lwIP and every feature run on - because "the S3 cannot execute from PSRAM". That is true only of the address the heap hands back: on the S3 the instruction and data buses share one MMU table, so the same PSRAM page can be fetched at that address + `0x06000000`. The loader now places code in PSRAM, writes and relocates it through the heap's pointer, and runs it through the alias (`src/core_module_memory.h`, `execAddress()` in `src/core_module_loader.h`). On the Audio edition a module with 23 KB or 32 KB of code was refused and now loads, and internal heap with a module resident is about 27.8 KB whatever the size of its code, where a 10 KB one used to leave 21-23 KB. On a second panel a community pattern with 8 KB of code was being refused one load in four for want of 528 bytes; it now loads every time and leaves 29 KB free instead of 22. Measured on two boards, same boot, alternating: catalogue and community modules within -0.1..+1.2% of their old frame time; modules built to be the worst case (10-32 KB of code, all of it executed every frame) 2.6-5.3% slower; 400 switches between modules of different sizes with no reset and no refusal. The code block is given whole cache lines to itself, the load reads it back through the instruction bus before calling it, and a unit on which that ever fails goes back to internal RAM until reboot (`moduleMemory.codePolicy`). `load.code` in `/api/status` says where the code is. Module files and the ABI are unchanged. +- **A request that stops half-way can no longer reboot the panel.** A multipart `POST` that ended after its first boundary - to any address, not only an upload - left the web server's form parser reading empty lines for ever with nothing to wait for, and the Core-0 watchdog reset the board five seconds later. The parser now knows a line that never ended from a blank one and gives the form up, tells an upload handler whose file had already arrived that it was aborted, and leaves none of the form's fields behind to answer for the next request (`src/webserver/VENDORED.md`, Fix 4). Reproduced on a panel before, gone after. +- **Three more requests that could reboot it, found on a PC first.** `curl -X POST` to `/api/patterns` or `/update` with no form in it called the upload handler with no upload to read and panicked the board; a multipart request naming a 9,000-character boundary overflowed the network task's stack; and a raw `PUT /update?size=N` never saw its own `size`. All three are fixed in the vendored server (`VENDORED.md`, Fix 5), and all three came out of `firmware/toolchain/check_parser.py`, a new host check that replays cut-off, stalled and malformed requests - 13,953 of them - through the real parser and fails if it ever spins or holds; CI runs it. +- **A quote in a pattern's name no longer breaks the pattern list.** `/api/patterns` wrote names into its reply unescaped, so one `"` or `\` in a title made the whole reply unparseable and the `/patterns` page could not list, select or delete anything - including the pattern responsible. Names are escaped now, a sidecar name is read as the JSON string it is - an escaped quote no longer ends it, and `\uXXXX` becomes the character, so "Dynamic Moiré" is that before the pattern has ever been loaded and not only after - and a name too long for its slot is cut between characters rather than through one. The hotspot password got the same escaping in `/api/hotspot`. +- **A module's global constructors can use the host.** They ran before the module had been handed its host API, so a pattern with a namespace-scope initialiser that allocated, logged or asked for a random number (`float* trail = PFMem::allocFloats(n);`) crashed the panel every time it was picked, having built and uploaded cleanly. Constructors now run after the entry point and before `setup()`. +- **Lanes keep working past day 25.** The five seconds a knob keeps to itself after a hand touches it was timed with a signed compare meant to survive the 49-day millisecond wrap; what it did was move the wrap to 24.9 days, after which a lane on any knob not touched since was held off for the following 24.9. A panel left alone would have stopped answering its microphone (Audio) or its weather (Performance) on day 25. Found by reading, not by waiting: the hold is a flag that ends once now. +- **A pattern that never returns no longer takes the console with it.** A module's `draw()` runs on the render loop, and the render loop is on no watchdog: one `while (a > PI) a -= 2 * PI` on a value that has reached infinity - which float32 does where the browser's doubles did not - and the panel holds its last frame for good. The first console request that needed the loop then waited for it without limit, and the `/patterns` page opens with one; the server answers one connection at a time, so `/api/status`, `/update` and Reboot, which need nothing from the loop, went silent behind it, and a frozen panel could be neither diagnosed nor restarted without pulling the plug. A request now gives the loop up once it has not reached its frame boundary for 20 s (`PF_LOOP_STALL_MS` - measured from the loop's own stamp, not from how long the request has waited, so an upload waiting out a slow `setup()` is not mistaken for it) and answers `503 render loop is not answering`; the request is taken back with one atomic exchange, so the loop can never run it on a stack frame that has already returned. `/api/status` carries `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, read without the loop's help, and every handler in the core and in the features answers the refusal instead of using what its body never set. The Status page says "not answering for 47s" in red, where it would have gone on showing the frame rate from before the stop under "awake". Nothing restarts the panel by itself: this reports and survives, and the Reboot button does the rest. Twenty seconds is clear of one feature lookup with the internet down and not of two in a row, so on Performance with a dead uplink a healthy panel can read "not answering" for some seconds and then carry on - which is why it does not say "hung". The console has to have come up once for any of this: its routes are registered by the loop on the first network link. Found by reading - no stock pattern hangs - then reproduced on a panel with a module that hangs on purpose: before, the pattern list got no reply and after it neither did status, until the board was reset over USB; after, the list answered 503 in 18 s, sixty more requests were refused at once with the heap unchanged, the Status, Update and home pages still loaded, and Reboot brought the panel back. Held by a host test that races the two sides against each other; `firmware/toolchain/tests/modules/_hang_probe` is a module that hangs on purpose, for the bench (`src/core_loop_sync.h`). +- **No more stray letters on the panel's own screens.** A portrait line holds ten characters, and Adafruit GFX wraps an eleventh onto the next line by itself: the KNOB MAP's "TURN = SHOW" put a lone "W" in front of "K3 = EXIT", the install screen's "web console" did the same, and the default host name "patternflow" lost its "w" on the hotspot and UPDATE screens. The labels are shorter now ("TURN=SHOW", "console"), and names that can be longer than a line are broken into lines of ten by `drawCenteredFit` — "pattern" / "flow-a1b2", "pattern" / "flow.local" — keeping every line where it was. +- **A deck sent from the community's dock keeps its order.** The console's one-click install from a link (`/patterns?src=`) took only `.pfm` and `.json` from a build's file list, so the `catalog.txt` every deck build carries was left behind and the deck landed in alphabetical order. It now takes exactly what a dropped zip does (`catalog.txt` and `.pfs` too) - one rule, `installable()`, for every way in. +- **A firmware upload survives a Wi-Fi hitch.** The `/update` connection now waits up to two minutes for a stalled upload instead of five seconds, and the page gives up after five minutes with a message instead of waiting forever. Checked on a panel with a 12 s stall halfway through a flash. `PUT /update` takes a raw image, for `curl -T`. From Simone Majocchi ([#450](https://github.com/engmung/Patternflow/pull/450)). +- **Two 64×64 modules chained make a 128×64 panel.** `PANEL_GEOMETRY` in `config.h` picks the stock 128×64, one 64×64, or two 64×64 daisy-chained (the `firmware64x2` PlatformIO env). The driver is told the module and the chain, and everything else sees the canvas; the stock build is unchanged. From Simone Majocchi ([#446](https://github.com/engmung/Patternflow/pull/446)). +- **Performance v0.4.0 carries the clock and a Weather face**, and its show catalogue holds 100 sequences instead of 48. An MQTT subscriber following a fast sequence stream now drains the broker and applies the latest value per knob each frame instead of falling one message per frame behind (a jump of more than 16 clicks in one frame is capped), and a publisher no longer replays its own retained snapshot when it reconnects. From Simone Majocchi ([#445](https://github.com/engmung/Patternflow/pull/445), [#448](https://github.com/engmung/Patternflow/pull/448), [#449](https://github.com/engmung/Patternflow/pull/449)). +- **The panel is a network of its own.** With no known Wi-Fi in reach it raises a hotspot, `patternflow-a1b2` (its alias), WPA2, password `patternflow` until changed, and the whole console is at `http://192.168.4.1/` on it - patterns, knobs, the Wi-Fi page to add the next place's network, updates. Modes on `/wifi`: `auto` (default: up fifteen seconds after the last link, down once a network is joined and nobody is on it), `always`, `off`; `GET`/`POST /api/hotspot`, and a `hotspot` object in status. The NETWORK screen shows the name while the hotspot is what there is. What the bench decided (`src/core_hotspot.h` says why, line by line): the channel comes from a scan - the least loaded of 1/6/11 - never a fixed one; alone, the radio runs AP-only, and the station comes back only for a rare probe with nobody connected or when credentials arrive; a DNS responder on the hotspot resolves every name to the panel and the phone's internet probe fails within a second instead of timing out for twenty; the hotspot is 20 MHz. Pretending to be the internet was tried and put a Samsung into its limited-connectivity state, which drops the network. +- **Console services start on any link.** Every core page, and the audio, microphone, clock and MQTT pages, used to wait for `WL_CONNECTED` before registering - on the hotspot a phone got an address and port 80 never opened. `PatternflowWifi::linkUp()` is the condition now, the station or the hotspot, and the hotspot raises the same link edge the station does. +- **Full transmit power.** The 13 dBm cap from 2026-08 is retired (`PF_WIFI_TX_POWER` is the radio's 19.5 dBm again). Measured on the hotspot: a phone next to the panel took 4-9 s per 10 KB page at 13 dBm and under a second at full power - the phone's own transmitter had hidden the asymmetry, and a router's antenna had hidden it on the home network. Owner's decision. +- **Switching console tabs no longer downloads the page again.** Page addresses carry the firmware's build (`/patterns?v=2a535f30`), and a page asked for under the running build is cached for good - a second visit to a tab costs no request at all (measured on a panel: `/patterns` 15.5 KB in 335 ms the first time, nothing and 16 ms after). A new build is a new address, so an update is never answered from an old copy; an open console notices the new `build` in `/api/status` and moves to it by itself, unless you are in the middle of typing or an upload, when it offers a reload instead. Pages without `v` revalidate every time (`ETag` + `304`), an old `v` gets a `302` to the current one, and the shared header script is addressed by its own checksum (`/pf-console.js?h=…`, stamped in by `console_pages.py`). Only names that can only be the panel - an IP, a bare name, `.local` - are ever given a cache lifetime: the hotspot answers every DNS name, and a phone must not keep the console as someone else's site. On a home network the other tabs are fetched in the background once the page is idle, so even a first visit is instant; not on the hotspot, where the link is the scarce thing. `/api/status` gains `build`, `viaHotspot` and `busy`, and escapes the network's and pattern's names (an SSID with a quote in it made the reply invalid JSON). +- **One header for the whole console, and one way to talk to the panel.** The shared header now lives in a shadow root, so no page's CSS moves it - it had a different height, width and position on every tab, which is most of why switching felt jumpy. On a phone it is two fixed rows with the tabs in one sideways-scrolling strip (Knobs is a tab now), and it says when the panel is not answering, restarting, or has been updated. Pages reach the device through `window.PF`: polls wait for the previous reply, back off on a slow link, pause in a hidden tab and stop the moment you tap another tab, so the next page is first in the one-connection server's queue; the page's own requests go before the header's status request; uploads hold everything else back and ask before you leave. A small fallback is stamped into every page, so a page still works if the header script fails to arrive. `console_serve.py --slow` makes the desk as slow as the hotspot (round trips, a 5.7 KB TCP window, one request at a time) to design against. +- **Setting the panel up from its own hotspot.** `/wifi` puts *Add a network* first when you are on the hotspot or nothing is saved, lists the networks the panel's channel scan saw so you can tap one instead of typing it, stops a case-only typo with an inline "did you mean" before it is saved, and reports what became of the network: joined, with the address to open, or why not - wrong password, not found, refused, no answer - on the page and on the panel's LEDs. On the hotspot with no station link, a network sent from the page is tried at once. The console's pages were gone over for phones on the way: sliders that let the page scroll, inputs that do not zoom iOS, `accept="*/*"` for the Android file picker, the hotspot shown as what it is instead of "offline", a status page that copies its diagnostics, an update page that confirms the new version after the reboot. +- **`always` no longer keeps the panel off its network after a boot.** Raising the hotspot starts with a channel scan, and the scan cuts the station's first connection attempt short; with the hotspot up the station is retried only every five minutes, so a panel set to `always` with good credentials sat off its network for five minutes after every boot (the console looked dead from the LAN). The hotspot now gives the station one attempt the moment it is up - on the bench it joined 1.4 s later - and goes AP-only only if that fails. The console also stopped calling a slow link dead: one reply over the timeout (now 12 s at least, not 5) no longer turns the header's state to offline, two failures in a row do - with the hotspot and the station both up, pages took 4-5 s and the console read "offline" after the first one. +- **The console's server waits one second, not five, for a connection that sends nothing.** A browser opens connections it never uses; each idle one held for 5 s put the page's real requests behind it, which on the hotspot was the console appearing only after the browser gave up on its spares. + +### Web + +- **A guide you can play along with, in three parts, at [/guide](https://patternflow.work/guide)** (work in progress, marked WIP). `/guide` is where you pick one by where your Patternflow is, each with its chapters, in order. **Build**, at [/guide/build](https://patternflow.work/guide/build), is for soldering one from bare parts: 01 Gather (the parts list is `bom_v3.9.csv` itself), 02 Order & print, 03 Solder, 04 Into the case, 05 Wire & power, 06 Firmware (which hands over to Play's 01 Flash) and 07 Check & close, each step played on the real v3.9 board and case in 3D, in the order the build happens; the board's known issues stay in `BUILD_GUIDE.md` §10, linked. **Play**, at [/guide/play](https://patternflow.work/guide/play), is the first hour with a built one, 01 Flash, 02 Knobs, 03 Patterns, 04 Console, on the real v3.9 hardware in 3D, running a port of the firmware's input logic and screens, with the real flasher dialogs and a live copy of the device console. **Make**, at [/guide/make](https://patternflow.work/guide/make), is 01 Community and 02 Pattern Lab, done by hand: on a big enough screen your own Pattern Lab, a practice community (placeholder patterns, connected to nothing) and a practice AI with set answers sit beside the steps, and a pointer shows each move and waits for yours; phones get the real screens. Sound, MIDI & OSC, MQTT, Clock, Performance and Editions join Make as they are written. Each guide numbers its own chapters from 01. Links into the first version, when Play was `/guide` itself (`/guide#flash`, `/guide#knobs-3`), land on the same step under `/guide/play`. English and Korean. Every step has a "Stuck here?" link to a GitHub issue that already says which guide, chapter and step (`guide_stuck.yml`). The 3D stage is one continuous world: moving between the hub, Build and Play is a scene rather than a page load, and pointing at a guide on the hub previews it on the device (Build comes apart, Play turns a knob, Make redraws the panel). The LED panel lights the room with the pattern it is playing, and the page's accent follows that colour. Pretendard is now served in four script-range files, so an English page no longer downloads the Hangul glyphs (a Korean page renders identically). README, BUILD_GUIDE, PATTERN_GUIDE and the assembly map link to the guide. +- **The community's Performances note says .pfs**, which is what the Director saves, instead of "Save-JSON". +- **A moderator can take a pattern or deck off the wall without deleting it.** Somebody else's public pattern or deck now offers moderators *Make private*, with an optional reason: the softer removal, for things like the same pattern posted a fifth time. Nothing about the work changes and its author keeps it — they can still open it, edit it, take it into the lab — it just stops being shown to anyone else. On a pattern the reason is posted underneath as the moderator's comment, where the author can answer it; either way the author is told in their alerts. While the take-down stands the author cannot make it public again (a take-down its subject can undo in one click is a request); *Restore to public* puts it back exactly as it was. A moderator still cannot publish what an author made private themselves. A deck slot left empty by a take-down reads "made private by a moderator", not "by its author". Moderators can now comment on private patterns and keep their alerts about them, so the thread under a take-down works both ways. Migration `0025` adds `hidden_at` to patterns and decks; `check:hidemod` drives it all through the real routes. +- **One press of Publish is one post.** A double-click on *Publish to the wall*, or a mouse switch that bounces, could put the same pattern on the wall twice in the same second ([#455](https://github.com/engmung/Patternflow/issues/455)). The button's disabled state was React state, a render too late for the second click, and after a successful publish it came back enabled while the page changed. The publish, thread, reply, deck, header, performance and report forms now refuse a second press in the same tick (`useSubmitLatch`), and a published pattern keeps its button down until the page changes; a thread whose files failed to attach offers *Open the thread* instead of posting it again. Behind them the server answers an identical submission from the same account within a minute with the post it already made, looked up and inserted in one synchronous transaction so two requests arriving together cannot both get through. `check:publish` fires two identical publishes into the route at the same instant; a version that awaits between the lookup and the insert fails it. diff --git a/docs/releases/v3.11.0.md b/docs/releases/v3.11.0.md new file mode 100644 index 00000000..4ad258a2 --- /dev/null +++ b/docs/releases/v3.11.0.md @@ -0,0 +1,70 @@ +## [3.11.0] - 2026-10-04 + +### Firmware + +- **Going back to a pattern no longer loads it again.** An installed pattern (a `.pfm` module) was unloaded the moment another pattern took the panel, so every return paid for a whole load: reading the file, relocating it, its constructors and its own `setup()`. For most of the catalogue that is 25-100 ms; for the heavy ones it was the pause you could see - Branched Flow 3.3 s, Two Stream 4.1 s, Burgers and Wave Cascade 0.6 s each. A module that has run now stays loaded in PSRAM ("parked") when another pattern takes over, and picking it again resumes it: nothing is read or relocated, `setup()` does not run again, and the pattern carries on from where it was, as the built-in presets always have. Measured on two boards, a return takes 115-197 µs. Picked while another pattern is still loading, it comes back once that load is done; picked during an install, it is loaded afresh afterwards, because an install empties what is parked. Up to 16 modules stay parked (`PF_MODULE_RESIDENT_MAX`; `0` turns this off). +- **SELECT shows patterns that are already in memory live.** A preset or a parked module comes on as the knob reaches it and runs behind the overlay instead of showing its thumbnail, as long as the pattern being left can be kept too. A pattern that has to be loaded still waits for the knob to rest. +- **A module's data now goes to PSRAM first, as its code already did.** Only a module that holds no internal RAM at all is kept, so the console and Wi-Fi give up nothing for parked patterns. Measured A-B-B-A on two boards: 13 of 16 module runs within noise (-1.1..+0.8%), Burgers +2.6% (38.9 -> 39.9 ms a frame), Wave Cascade +2.2%, Breakout Arcade +0.85%; in exchange 4-6 KB more internal heap with a module running, up to 9.9 KB. +- **Parked patterns give their memory back.** Least recently used first, whenever a new load would otherwise start with less than 4.5 MB of PSRAM free (the most one pattern may allocate, plus 512 KB), and whenever a running pattern's own allocation would leave less than 512 KB. A pattern installed, replaced or deleted - from the console, or by a feature that installs patterns - is never resumed from the old file. Checked on two boards with 12-13 modules parked: internal heap stayed flat, an install emptied the table and PSRAM came back by exactly the parked bytes (683 KB), and with five 1.5 MB test modules PSRAM never fell under about 3.1 MB free, with no refused load. `/api/status` gains `load.resumed`, `load.resumeUs` and `moduleMemory.resident`. +- **What this takes away:** a pattern that wants a fresh start every time it is picked cannot have one. `setup()` runs once per load, as `firmware/CUSTOM_PATTERNS.md` now says. A restart of the panel, or any install, delete or reorder on the Patterns page, is what starts one again. +- **The console is light, and its six core pages look like one thing.** It opens light; dark is still the toggle in the header, and a choice made there is remembered. Console, Patterns, Status, Wi-Fi, Knobs and Update now share one set of parts - buttons, chips, list rows, fields, notices, section titles - from a base block every page opens with (`console/README.md`, "The look"), with one accent, the LED orange, kept for what is live: the current tab, the playing pattern, the panel being on. The home page leads with the panel itself: what is playing with its arrows, on/sleep, brightness and the four knobs, with the device's facts under them in the same card; the arrow keys step through patterns as the arrow buttons do. Which edition the panel runs has a card of its own instead of being one more row among the facts: its name, its versions, the feature pages it carries as links, the update notice when there is one, and a button to the other editions (or a line saying the panel's own hotspot has no internet). The numbered index of every page with a paragraph each is now a list of one-line links. The header no longer scrolls sideways on a phone: Console and Patterns, the build's own feature pages, then Wi-Fi, Knobs, Update and Status, wrapped onto rows. Long explanations are a line, with the rest behind a disclosure where it is needed. Windows showed every small label in Courier New because no font in the monospace list was a Windows one; the list now includes Cascadia Mono and Consolas. The Knobs page has an *All* row that sets direction and edges per click for the four at once (it reads *Mixed* while they differ). The Status page shows the crash record (`crash` in `/api/status`) when there is one, with a button to clear it. Feature pages are not edited and take the light palette from the header. Home is 8.9 KB gzipped (was 10.9), Knobs grew by 0.15 KB for its All row, the others are within 40 bytes of what they were, and the header script is 9.6 KB. On a panel on the home network a page still opens in about 0.1 s the first time and in under 0.01 s after. Every request, control and message the pages had is still there; checked on a panel by stepping through patterns, sleeping and waking it, dimming it, dragging a knob and playing a pattern from the list. The guide's live console demo is regenerated to match. +- **The Audio page is a console page now: it says who has the knobs, and it saves only what you changed** (Audio edition). `/audio-in` was the one page the light console left behind: its own dark palette, 12 px type, square corners, a second brand line. It opens light like the rest now, with the console's buttons, chips and list rows, and it does not scroll sideways on a phone (checked at 390 and 320 px). Above the editor is a **Sources** list. *Microphone* has a chip that reads `off`, `starting`, `listening`, `not detected` (nothing on the data pin; the page says to check the DAT lead) or `not answering`, with the input gain beside it; a board with no microphone fitted used to look the same as a healthy one in a silent room. *Browser extension and phone app* is the Audio-React switch, the same one as `AUD` on the NETWORK screen, with `off`, `waiting`, `connected` or `phone app`, shown only on a build that has that path. The line at the top of the page says which of them has the knobs. It does not know about a knob pinned from the home page's sliders, MIDI CC 20-23 or `/api/params`; that knob stays pinned until its encoder is turned. Under it the mapping editor is the extension's, with what that gained in this release (see Tools): the knobs are K1 to K4 with their tabs under the plot, the level axis is in dB, and Preview takes the plot's place. On this page the line in each box, the cursor on the curve and the readout now show where the firmware has the level (`outputs` and `levelsN` from the poll); the page used to work that out a second time on its own dB scale, which is not the scale the firmware maps a hand-set window on. A panel that has heard nothing draws its stored windows dashed, where it drew four boxes flat on the axis. *Reset K1* asks the firmware for that knob's default: the page's own table of defaults was the extension's, and put a 5-16 kHz band on an axis that ends at 8. *Reset mapping* is pressed twice and says what it resets, in place of a `confirm()` dialog, which the phone app's WebView is not set up to show. Saving: nothing is written until the panel's mapping has been read. A read that failed used to leave the editor on the extension's defaults with nothing on screen to say so, and the next touch saved four default bands over the panel's own. An edit now sends the fields that changed, one request at a time - an Attack nudge is `attack=0.611`, a box's top edge is that band's `inMin` and `inMax` - where every edit posted the whole mapping, 41 arguments, and two open tabs wrote their stale copies over each other's edits. A save the panel did not take is said so, tried once more, and kept for the next change; an edit made just before leaving the page is still sent. Polling: the levels poll rides the console's shared lane (`PF.poll`) as every other page's requests do, so it waits behind an upload in another tab and slows itself on a slow link. Measured on the mock, about 9 polls a second with the microphone on, one every 1.5 s with it off (it had been 9.2 a second there too, for numbers that cannot change), one a second while the panel is not answering, and 0.6 a second on a link as slow as the hotspot. Weight: 17.2 KB gzipped as the panel sends it, where it was 19.9, because comments and indentation are dropped as the page is assembled; CI fails the page above 17.5 KB. `console_pages.py build` assembles the page as its first step, so there is one build command where there were two, and `console_serve.py` serves it assembled from its sources on every request against a port of the firmware's audio handlers, with `/mock?audio=` states for music, a quiet room, no microphone, a stalled one, the phone app and a read that never answers. Checked on that mock in Chromium, light and dark, with no console errors in any state, and on a panel with the microphone: the mapping stored on it survived the update, the page read before it wrote, one slider step posted one field (`attack=0.645`) and held across a reload, and frame time and microphone drops were unchanged with the page open. Not yet on a phone or in the phone app's WebView. +- **The Audio edition transmits at the core's Wi-Fi power** (19.5 dBm). It had been left pinned at 17 dBm when the core's 13 dBm cap was retired in 3.10.5, so it was the one build with less than full power. +- **A microphone that is not delivering no longer moves the knobs, and the microphone API takes one field at a time** (Audio edition). When the PDM driver did not start, or the microphone stopped returning samples, the analysis carried on over its built-in three-tone test signal and those tones reached the knobs, while `source` said `synth (mic stalled)`. The microphone now drives the knobs only while it is returning samples. `POST /api/audio-in` applies a band when any one of its suffixed fields is present; before, a request carrying only `inMax2` was answered `{"ok":true}` and changed nothing, because only `hzMin`, `lut`, `knob`, `muted` or `outMin` counted as naming a band. A `band` outside 0..3 is refused with `400` before anything else in the request is applied. `POST /api/audio-in/reset` takes `band=N` and resets that band and its curve alone, naming it in the answer; a full reset now includes `attack`, and still leaves the microphone switch and auto range alone. `hzRange` is printed to two decimals (`31.25`, the bound a band is really held to). The poll reply is built in one allocation where it grew through about forty, ten times a second. Nothing is removed or renamed: the phone app reads the same configuration it did, and `d=` stays in the WebSocket protocol for earlier extensions. Static RAM is what it was (161,216 bytes of DRAM, the pinned figure). `docs/rest-api.md` describes the microphone endpoints field by field, including what had been there unwritten (both spellings of a band field, `smoothing`, `attack`, `meta`, `lut`, `env`, the phone's `frame` post, every `source` string, and that a body sent without the form `Content-Type` is not read, which turns a `band=2` reset into a reset of everything). `docs/audio-ws-spec.md` 1.1 states the 500 ms lane release that version 1 said did not exist, and which senders hold a value through it: the extension does, the Android capture app does not yet. Checked on a host by running the old and the new handlers on the same requests (78 assertions), by building every composition, and by the one-field post above on a panel. + +### Web + +- **The guide has a fourth part: Audio** (`/guide/audio`, Korean at `/guide/audio/ko`). Getting the Audio edition onto the board first, then three sections in no order: sound from a browser tab, the on-board microphone, and MIDI with a DAW (network MIDI, Ableton Live as the worked example). The browser section runs the extension's own mapping editor on its demo source, and the boxes drawn there turn the knobs of the 3D board; a hand on a knob takes it for five seconds, as on the real one. The rest is captures of the real pages and the two photos of the microphone. OSC, the phone app and the Director's `.mid` export are links at the end. The hub (`/guide`) has four cards. + +### Docs + +- **The microphone is five leads on four places, and the docs say so.** `AUDIO_GUIDE.md`, the Audio card on `/editions` and `firmware/bundles/README.md` said four wires; GND and SEL both go to GND, which makes five. `AUDIO_GUIDE.md` also names the Audio page's editor by its heading, **Mapping**. +- **The MIDI console page points at Live's current settings page** (Settings → Link, Tempo & MIDI, not Preferences → MIDI) and no longer mentions a MIDI row on the board's NETWORK screen: that screen shows the first two switchable features, and on the Audio edition those are OSC and AUD. `docs/midi-spec.md` says the same. + +### Tools + +- **The audio extension holds the knobs through silence, and Live means the panel is being driven** (`tools/patternflow-audio-extension`, 0.2.0). The panel hands a knob back to its encoder 500 ms after the last message that set it, and the extension did not send a frame equal to the last one. So half a second into a silent tab, a paused track or a Gate curve resting on one step, the knobs were let go while the popup said Live: measured, not one message in ten seconds of silence. It now sends an unchanged frame again about every 210 ms (five a second, no gap longer than 223 ms), and the knobs rest where each band rests until Stop. A knob turned by hand during a pause goes back there when its five-second hold ends, and a microphone that is also switched on does not get the knobs during a pause. Owner's decision. Muting a knob hands it back at once (`off=N`), where it waited for the panel's timeout. *Live* used to mean the socket was open, which it also is on a panel whose Audio-React switch is off and which ignores every frame; the extension now asks the panel once when the socket opens (`GET /api/audio`), and off reads *Aud off* with a **Turn on** button. A socket that will not open is explained, where every failure read "WebSocket error": no panel at that address, a panel that does not run the Audio edition, or something that answers and refuses the socket. A silent tab reads "No sound from this tab yet." The popup has one button, Start until a tab is captured and Stop while one is; *Release knobs* is gone (Stop does that), and *Test without audio* holds each knob where its slider is put. The address is taken on Enter, on leaving the field and on Start, where it reconnected on every keystroke (typing `192.168.0.42` tried `192.168.0.4` on the way), and it has a storage key of its own, so an editor tab left open no longer writes an old address back. Closing the captured tab ends the capture and says so, and a restarted service worker no longer shows Idle over a running capture. The frequency labels under the popup's spectrum sit where their frequencies are ("1k" had been over 317 Hz). In the mapping editor the bands are K1 to K4 everywhere, the knob tabs sit directly under the plot, the level axis and the curve's readout are in dB, and the hints are plain sentences ("This band still runs its old boost curve", on a band nobody had touched, is gone). A new install starts with Auto range on and the Smooth curve, as the panel does and as `AUDIO_GUIDE.md` already said; a mapping that is already stored keeps what it has. Switching Auto range off goes back to the windows set by hand, where it replaced all four with wherever the boxes were; dragging a top or bottom edge is what takes them over. Preview puts its scope where the plot is, so the sliders, the curve and the output range it demonstrates stay in reach, where it was a dialog over them. The editor holds together at half a screen's width, and on a touch screen a swipe that starts on an empty part of the plot scrolls the page while one on a box drags it. One model (`mapping.js`) holds the defaults and the level-to-knob arithmetic for the capture, the popup, the editor and the panel's page, where the first three each had a copy. The extension keeps its cream skin. After pulling, press Reload on `chrome://extensions`; the card should read 0.2.0. Reload keeps the stored mapping, so a copy you already had opens as it was, which unless you had changed them is Auto range off and no curve: for the new defaults, press Reset on each of the four knobs in the editor and switch Auto range on, or remove the extension and load it again (which also forgets the panel's address). Checked with the unpacked extension in headless Chromium against a loopback socket and a stand-in for the panel's HTTP side (44 popup checks, and the wire in each state), and against a panel: a tone in the captured tab swung K1 between 0.30 and 0.85, four seconds of silence left the lanes held at their rest value where they used to be released after half a second, switching Audio-React off on the panel turned the popup to *Aud off*, and Stop handed the knobs back to the microphone. Touch was tried only in emulation, and the panel requests only in the Chromium Playwright ships, not in a current Chrome with its local-network permission prompt. + +### Patterns + +- **Installed patterns run faster, and nobody has to edit one.** A pattern on a panel is a small compiled program (a `.pfm`). Two things about how it was compiled were costing frames, and both change where a module is built: + - It was built for size (`-Os`), on a note in the SDK that `-O2` bought about 2 %. Modules are now built at `-O2` (`build_module.py`). + - Every `floorf`, `ceilf`, `truncf`, `roundf`, `fminf`, `fmaxf` and `fmodf` in a pixel loop was a call out of the pattern into the firmware's maths library: 185 to 840 ns each as timed (`truncf` was not), 8,192 pixels a frame. The module SDK now gives those seven inline code (`abi/pf_libm.h`), so a header that calls them per pixel - most community patterns do - stops paying for the call. + + Frame time on one board, in one boot. The Basics presets as they are today, GCC 8.4: + + | | 0510 | 0512 | 0515-4 | 0520 | 0531 | 0601 | + |---|---|---|---|---|---|---| + | `-Os` | 11.9 ms | 23.2 | 15.0 | 23.4 | 13.7 | 15.0 | + | `-O2` | 10.3 ms | 22.6 | 11.2 | 19.7 | 12.4 | 13.6 | + + That is 3 to 34 % more frames a second; GCC 14.2 at `-O2` was another 1 to 8 % faster. Four presets as they stood before they were tuned by hand, with `floorf` and `fmodf` per pixel: + + | | 0510 | 0515-4 | 0520 | 0601 | + |---|---|---|---|---| + | built as before | 14.8 ms | 25.3 | 26.9 | 26.0 | + | `-O2` | 13.3 ms | 23.7 | 23.1 | 24.5 | + | `-O2`, maths inline | 10.3 ms | 17.5 | 20.3 | 20.2 | + + The last row was measured with a first version of the header. With the header as committed, all 147 distinct public community patterns build (the old recipe's compiler crashed on two of them), and each was loaded and run on a board: no reset, no refused load. +- **What is exact, and what is not.** The seven functions are exact: for every input, each returns the bits the library function returns. Each computes inline only the inputs that have one right answer, and hands the rest - a NaN, an infinity, a magnitude past the range its proof covers - to the call the pattern made before. `check_libm.py` holds them to that in CI: all 4,294,967,296 floats through `floorf`, `ceilf`, `truncf` and `roundf`, 419 million pairs through `fminf` and `fmaxf`, 481 million through `fmodf` by each of its two paths - twenty billion comparisons, no mismatch, under GCC with sanitizers and under MSVC. `check_module_libm.py` then reads a module the real compiler built, to see that this code is what a pattern's calls became, and `tests/modules/_math_probe` is the module that checks on a board what no PC can: that the panel's multiply-subtract is fused. It reported `math ok 9/9` on a board, over 1.17 million comparisons against the firmware's own library. One footnote: for `fminf`/`fmaxf` of a zero against a zero, or of a NaN, "the call the pattern made before" means what that call got from this compiler, which is not always newlib's answer for the arguments as written - GCC answers a call with constant arguments itself, and GCC 12 and later may swap the fallback's operands. + + What is not exact is the pattern's own arithmetic. `-O2` and inline code both move where GCC fuses `a*b+c` into one instruction (`-ffp-contract=fast`), so a value in a rebuilt pattern can differ in its last bit from the build before. Two cases, worked through: + - The cell hash written as one line, `sinf(floorf(x)*12.9898f + floorf(y)*78.233f)*43758.5453f`: 20 of 128 cells hash differently. + - A helper that ends in a multiply and that `-O2` inlines - a smoothstep: `mul.s` then `sub.s` becomes one `msub.s`. + + It shows only where a pattern amplifies the last bit: a float hash, state carried between frames, rarely the edge of a `floorf` band. Of 168 public community headers, one uses the 43758 float hash; 21 use the integer `PFNoise::cellHash`, which is unaffected. +- **The Basics pack is rebuilt**, for the first time since 15 August: the same 33 patterns in the same order, from today's preset sources, at `-O2`, with the firmware's own compiler. Against the August binaries on the same board, every one of the 33 is faster: 1.11 to 2.36 times the frames a second, 1.52 at the median (47 fps to 77 across the pack). + + | | 0510 | 0512 | 0515-4 | 0520 | 0531 | 0601 | + |---|---|---|---|---|---|---| + | August pack | 18.1 ms | 25.1 | 26.1 | 34.4 | 15.5 | 28.5 | + | rebuilt | 10.3 ms | 22.6 | 11.1 | 19.7 | 11.1 | 12.7 | + + It is also the first rebuild to carry the two SDK fixes of 3.10.1, and those change what is drawn. `cellHash`'s seed decorrelates, so Firefly Hollow and Midsummer Sea get a different random stream. `hsvToRgb` rounds, so the 16 patterns in the pack that colour through it are up to one level brighter on a channel. The pack is 166 KB where it was 122, and holds 104 KB of code where it held 63. +- **What the new builds need from the firmware.** + - **3.5.1 or later, for Basics.** Its modules are now stamped for the parameter bus (descriptor 2, where August's were 1), so a show can drive them - and firmware 3.2.0 to 3.4.0 refuses all 33. Update the firmware *before* reinstalling the pack: reinstalling replaces files that load with files that firmware will not. + - **3.10.5 or later, for the larger ones.** Firmware before 3.10.5 places a module's code in internal RAM, and the new builds are larger: 1.36 times the code at the median, over 145 community patterns built both ways with GCC 8.4. Thirteen of those 145 fitted an Audio build's budget of about 7.8 KB before and no longer do; nor does Breakout Arcade in Basics, at 14.2 KB where it was 5.4. From 3.10.5 code runs from PSRAM, and its size does not decide whether a module loads. + - Nothing else: a module built this way imports only names every loader since 3.2.0 resolves. +- **What you have to do.** A pattern already on a panel keeps the build it was installed with; nothing is rebuilt on the device. Update the firmware if the list above says to. Then install the Basics pack again from the site, and send a community pattern or a deck again: the community server keeps one build of each pattern, keyed by everything that goes into a build, so once it is redeployed each is compiled again the first time someone asks for it. +- **A module is 2.7 % slower than the same pattern compiled in, not 20 %.** The SDK said 20 % (a 2026-07 measurement: Origin, 53 fps against 43) and blamed the relocatable code model. Measured again on one board, A-B-B-A in one boot: Origin compiled in draws a frame in 9.56 ms, Origin as a module in 9.81. diff --git a/docs/releases/v3.2.0.md b/docs/releases/v3.2.0.md new file mode 100644 index 00000000..9a357027 --- /dev/null +++ b/docs/releases/v3.2.0.md @@ -0,0 +1,33 @@ +## [3.2.0] - 2026-07 + +Patterns stop needing a firmware build. **Hardware unchanged** — v3.0 board and case carry over as-is. + +### Added +- **Loadable pattern modules (`.pfm`)** ([#232](https://github.com/engmung/Patternflow/issues/232), [#242](https://github.com/engmung/Patternflow/pull/242)): a pattern compiles to a relocatable Xtensa ELF of a few KB, is sent to the device over Wi-Fi, and appears in the list immediately — no reflash, no reboot, no 1 MB image for a 6 KB pattern. Design and the working proof of concept came from **Simone Majocchi ([@SimonePDA](https://github.com/SimonePDA))**: a frozen C ABI between host and module, a linker script collapsing each module to four sections, and an on-device relocator. Switching patterns costs 6–11 ms; up to **128** modules can be installed, and installing costs nothing at runtime. +- **Module cart**: collect patterns across the community — from a card's own button or the pattern page — and build the lot in one request. "Send over Wi-Fi" points the device's pattern manager at the result and it fetches and installs every file itself. +- **Pattern manager on the device** (`/patterns`): install, list, multi-select delete, and format the pattern partition, with a per-file queue showing progress, retries and per-file results. Console navigation now spans every device page. +- **Pattern Lab: a staged hardware flow.** `To hardware` converts the composition to a `.h` first — one prompt for a single layer, a deterministic scaffold with a prompt per layer for a stack — then offers what a header makes possible: install it as a module, build a full image, or publish it to the community with the header attached so the pattern lands hardware-ready. +- **Recent works in Pattern Lab**: opening someone else's pattern replaces the canvas instead of stacking its layers onto yours, and what was in progress is parked in a ring of three. +- **Pattern Lab rebuilt as a layered, dockable editor platform.** The single-pattern lab is now a workspace for compositions: + - **Layer stack** — code-pattern layers and pixel-art layers composite bottom-to-top, each with visibility, opacity, and a blend mode (normal / add / multiply / screen). Layers select, rename, duplicate, reorder (drag), and report their runtime errors in place. + - **Layer masks** — any layer can flip from painting to masking the layer directly below it, binary on purpose (one compare per pixel on the ESP32): a pixel-art mask reveals wherever something is drawn, a code mask reveals where its ramp-colored output crosses 0.5 luminance — so the color ramp is the mask control. Invertible, stackable (masks intersect), and replicated 1:1 in flattened exports. + - **Per-layer color ramps with alpha** — the ramp is chained to its code layer (two stacked patterns keep their own palettes), and ramp stops carry an alpha value drawn over a checkerboard: a value field can fade to transparent and reveal the layers beneath ("Fade low" sets that up in one click). Recolor maps alpha by luminance too, so RGB patterns can become translucent layers. The default ramp is neutral black→white (Reset restores it), which maps a value field straight onto the mask threshold. + - **Pixel panel** — draw straight onto the matrix: pencil/eraser/line/rect/ellipse/fill with brush sizes, eyedropper (alt-click anywhere), a magic eraser with tolerance for background removal (contiguous or whole-layer chroma cut), undo/redo, zoom with middle-drag panning, pixel grid, and a live onion backdrop of the other layers for alignment. A select tool lifts a boxed region to move it (drag or arrow keys) and rescale it nearest-neighbour — pixels stay crisp — before stamping it back down. Image import places in context: the other layers animate behind the preview while you drag the image into position and scale it (wheel or slider), with fit modes, nearest/smooth scaling and source-corner background removal — Apply stamps over the layer's existing pixels. + - **Dockable panels** (dockview) — Preview, Layers, Code, Pixel, Gallery, Knobs, and Color Ramp move like Photoshop panels: drag tabs between groups, split, float. The arrangement persists to localStorage, with a Panels menu and one-click layout reset. + - **Flattened exports** — publishing flattens the visible stack into one standalone JS pattern: each ramp baked as an RGBA lookup table, pixel art embedded as base64 with a small portable decoder, blend/opacity/mask compositing inlined. The community sandbox and fork lineage consume it unchanged. + - **Compositions that survive sharing** — the published code also carries the FULL layer project as one `// @stack` comment line (deflate-compressed, size-guarded): the sandbox ignores it, but "Open in Pattern Lab" — or pasting the code — restores every layer, ramp, mask and pixel bitmap as an editable stack on top of whatever is in progress, with fork lineage intact. + - **Firmware .h for layer stacks, the safe way** — a deterministic C++ scaffold generator plus per-layer AI translation. Pattern Lab emits finished code for everything mechanical (layer buffers, one shared knob accumulator, RGBA ramp LUTs byte-for-byte, pixel art as RLE data, mask tests, blend compositing — same math as the preview) with compiling no-op stubs in marked slots; each code layer gets its own small conversion prompt whose output is just one `namespace L` block that the export wizard validates and swaps in. The model never sees the machine data it used to corrupt, and the two historic conversion bug classes — hand-rolled knob delta accumulation and reproduced color LUTs — are gone from the prompt entirely. The assembled header flows straight into the existing browser build + flash pipeline. + - **One shared knob set** — the four knobs stay global, like the four physical encoders: every code layer reads the same input (per-layer knob targeting stays open as a future mode). `@knobs` lines retune the shared set on load; range digit-dragging and encoder push buttons carry over, and the previous draft migrates into a one-layer project on first open. Gallery cards can also stack a generated variant as a new layer. + +### Fixed +- **The browser flasher was shipping firmware without the module loader.** It served the image built for 3.1.0, so anyone who flashed from the site got a board that could not receive a single pattern from the community. The stock image is now built from the release source and version-stamped per release. +- **A pattern named "Dynamic Moiré" could neither build nor load** — three separate faults, all from one non-ASCII character in a name: the toolchain scripts crashed printing it on a cp949 console, a loader guard tested names for printable ASCII and called anything else a relocation bug, and the panel's ASCII font drew it as mojibake. Names are UTF-8 everywhere now; only the panel folds them. +- **A pattern that fails to load says why.** `/api/status` reports `loadError` and the panel draws the reason, instead of a torn frame that reads as "the pattern is broken". +- **"Uploaded" means "verified"** — after writing a `.pfm` the device reopens it and checks it is structurally a module, which is what catches truncation. Bad files are deleted and reported rather than counted as success. +- **A never-used board can start.** The pattern partition ships unformatted; `/patterns` offers an explicit Format button exactly when the volume will not mount. Formatting is never automatic — an earlier auto-format wiped every installed module after a crash corrupted the FAT. + +### Known issues +- **Repeated uploads can wedge the device**, and the way out is a power cycle. Seen while installing many modules in a row. Not diagnosed — `/update/status` now reports `lastError`, `received`/`expected` and an attempt counter, so the next occurrence can be read rather than guessed at. +- **A firmware upload from the browser occasionally aborts partway** ("Connection lost during upload", ~29 % in the observed case) and succeeds on a retry. Two candidate causes were tested on hardware and disproved: request contention (an upload completes normally with another client polling every 2 s) and the page's own status poll. Same instrumentation applies. +- **Opening a console page still pauses the running pattern.** A resident module holds ~7 KB of the ~15 KB internal heap, dropping the largest free block to ~3 KB, and lwIP cannot deliver a 16 KB page from there. Three fixes were tried and are recorded in `firmware/README.md`; an async server would not help, since it shares the same heap. +- **A module runs ~20 % slower** than the same source compiled into the firmware — the cost of the relocatable code model, not something a compiler flag reaches. diff --git a/docs/releases/v3.3.0.md b/docs/releases/v3.3.0.md new file mode 100644 index 00000000..9b22f6fa --- /dev/null +++ b/docs/releases/v3.3.0.md @@ -0,0 +1,32 @@ +## [3.3.0] - 2026-08-09 + +Color, end to end: perceptual ramps in the editor and a panel you can actually calibrate. **Hardware unchanged** — v3.0 board and case carry over as-is. + +### Added +- **Runtime display calibration** ([#287](https://github.com/engmung/Patternflow/pull/287)): white balance, gamma and saturation were compile-time constants, so finding a number cost a reflash per guess. They are runtime state now, tunable live over `GET /api/display` while looking at the panel. Values are session-only; the converged ones go back into `config.h` as the shipped defaults. Compile the endpoint out with `-DPF_DISPLAY_HTTP_ENABLED=0`. +- **Calibration test card**, as an overlay rather than a pattern — knob browsing stays a curated list. `?screen=0..3` draws it over the running pattern (which freezes underneath) and `?screen=-1` resumes; a long-press on K4 or any pattern switch also dismisses it, so a closed browser tab can never strand the panel on a test screen. Four screens: white field at an adjustable level, a 16-step staircase over a smooth gradient, color bars at full and half drive, and sRGB-versus-OKLab ramp pairs shown on the actual LEDs. +- **[Panel tuner](https://patternflow.work/panel-tuner.html)** (`web/public/panel-tuner.html`): sliders driving `/api/display` beside a canvas rendering the same test frames in raw sRGB. The reference is the point — you cannot judge a color against a memory, only against another color. Generates a `config.h` block once the values settle. Plain HTTP on the device's LAN, so run it from a local dev server. +- **Remote pattern selection** — `GET /api/patterns/select?name=|index=`, through the same deferred-consume path OSC already used. +- **Perceptual color ramps in Pattern Lab and the community sandbox**: `oklab`, `oklch short` and `oklch long` join the existing modes. Blending complementary stops in sRGB collapses the middle into grey and sweeping hue in HSV makes lightness pulse; interpolating in OKLab does neither. Out-of-gamut blends are pulled back by lowering chroma at constant lightness and hue rather than clipping channels. Because ramps bake to a 256-entry lookup table web-side, the new modes reached the firmware, the C++ conversion and `.pfm` builds with no device-side change. The ramp panel gained a lightness strip and a monotonicity read-out. + +- **[LED panel compatibility guide](../../docs/panel-compatibility.md)** ([#258](https://github.com/engmung/Patternflow/pull/258), [#259](https://github.com/engmung/Patternflow/issues/259)): a panel matching our spec line for line can still stay completely black, because HUB75E is a connector and not a protocol — the driver ICs decide. The catch is that the driver IC is essentially never in the listing (we audited our own: it appears in the title, spec table, marketplace AI summary and attached PDF manual zero times), so the guide leads with what actually works — reading buyer reviews for anyone running it off an ESP32 — then asking the seller, with the chip taxonomy kept as reference for a panel already in hand. Includes a verified-panel table with photos, how to tell the LED driver from the buffer and the row driver on a board, and a symptom→cause table. Opened up by **[@SimonePDA](https://github.com/SimonePDA)**, who hit the failure and researched it properly. +- **`PANEL_PROFILE` covers all six library drivers** instead of two — `PANEL_STANDARD`, `PANEL_HIGHREFRESH`, `PANEL_FM6124`, `PANEL_ICN2038S`, `PANEL_MBI5124`, `PANEL_DP3246`. Existing values keep their numbers and the default is unchanged. + +### Changed +- **Shipped color defaults are now one panel's measured numbers**, not plausible ones: `LED_WB` 0.930/1.000/0.975 (that panel leans warm — red needed trimming hardest, not the green cut the folklore predicts) and `LED_SAT_BOOST` 1.62. The saturation direction reversed the theory that predicted it: narrow-band LED primaries cover more than sRGB, which argues for cutting saturation, but placed beside a monitor showing the same frame the panel reads washed out, and the boost held at both 14 % and 100 % brightness. **Existing devices will look different after this update** — more saturated, less warm. Panels vary; tune your own with `/api/display` and land the numbers in `config.h`. +- **Encoder acceleration removed — one detent, one step** ([#262](https://github.com/engmung/Patternflow/pull/262)). A fast spin used to multiply each detent ×2 to ×5. Compared against a linear build with a temporary per-knob tuning page (four curves live at once) and linear won outright — the multiplier was worst on the parameter that most needs landing on a value, since Origin's Mode knob picks a discrete preset and a quick turn skipped five at a time. OSC deltas had already been routed around it for that reason, which was the tell. +- **Knob travel now derives from the parameter's range.** It was a fixed per-knob constant that never looked at min/max — so a 0..1 knob crossed in one turn while a 0..100 knob needed a hundred, and the step handed to the C++ conversion inherited the same blind spot (which is why ~80 of ~120 step constants in the preset library are the identical `0.05f`). Travel is now `(max - min)` per two turns, so every knob crosses its range in the same motion whatever it drives. **Existing presets keep their compiled-in constants** — this lands on patterns generated from here on. + +### Fixed +- **Calibration tables no longer sit in internal DRAM** ([#288](https://github.com/engmung/Patternflow/pull/288)). The test card's ramp tables shipped as a 1.5 KB namespace static — the exact mistake `core_mem.h` exists to prevent. Statics lock internal DRAM from boot, and internal DRAM is what lwIP's page delivery starves on: with them resident every console page truncated at ~5.6 KB and module uploads failed. They allocate from PSRAM in `setup()` now, and `/patterns` delivers its full 16 KB in 0.4 s. +- **Encoder decoder resynced onto bounced states** ([#262](https://github.com/engmung/Patternflow/pull/262)). The quadrature ISR updated its reference state on transitions that cannot physically happen, so a bounced jump moved the decoder to the wrong phase and the following clean edges read as a missed detent or a step in the wrong direction. Illegal transitions are now discarded, plus a `ENC_BOUNCE_US` time filter — the A/B lines have no RC filter and only the ESP32's weak internal pull-ups, so the decoder has to do that work itself. +- **`ENCODER_CLICKS_PER_TURN` was 20; the reference encoder has 24.** It sat next to a comment promising one web rotation equals one physical turn, so the preview it claimed to mirror was 20 % out of step. The BOM part is a Bourns PEC11R-4220F-S0024 — the `S0024` suffix means 24 detents, confirmed across five distributors. Sourcing a different encoder? Generic EC11s ship as 20, 24 or 30, and this constant has to match what is actually in the build. +- **Panel-profile guidance was backwards.** `config.h` advised setting `PANEL_HIGHREFRESH` for an FM6126A/FM6124 panel — which would send anyone holding the reference panel to change a setting they don't need, since its `FM6124EJ` runs on `PANEL_STANDARD` with no init at all. The rule is now empirical: stay on the default unless the panel comes up completely dark. It also advised "swap to FM6124 if dark/distorted"; `FM6124`, `FM6126A` and `ICN2038S` all dispatch to the same `fm6124init()` with no per-chip branch, so that swap does nothing. +- **"Walk away if the listing mentions a receiving card"** would have rejected our own verified panel — that sentence is factory boilerplate in every LED module manual. The red flag is now scoped to the listing, not the PDF. +- Brightness is **K1** long-press, not K2 (`README.md`). +- Removed the Pattern/Video content mode from the docs — it was deleted from the firmware, but `firmware/README.md`, `docs/osc-spec.md` and the OSC address tables still described it. `/patternflow/content/toggle` is now documented as an accepted no-op. +- `firmware/README.md` long-press reference rewritten against the code: the K2 screen is NETWORK (not "OSC info"), its toggles are knob **rotation** not clicks, and K3 long-press is the KNOB MAP screen. +- `CUSTOM_PATTERNS.md` file tree updated to the `presets/` layout; `firmware/patternflow/README.md` no longer describes the removed hand-edited `custom1..3` slots or enumerate a preset list that had fallen thirteen behind. +- v2 build guide realigned to the current firmware: **USB CDC On Boot enabled**, browser flashing on the left/native USB port and Arduino IDE on the right. One image serves every board generation, and the site can't ship a per-version build. +- Panel mounting screws are **6–12**, not 6 (v2) or 12 (v3) — all 12 holes is the exact fit, 6 holds it firmly. +- The v2 BOM said the panel's ribbon and power cable are "both used as-is" while §6.2 tells you to cut the power cable. diff --git a/docs/releases/v3.4.0.md b/docs/releases/v3.4.0.md new file mode 100644 index 00000000..44cb5703 --- /dev/null +++ b/docs/releases/v3.4.0.md @@ -0,0 +1,26 @@ +## [3.4.0] - 2026-08-12 + +Patterns leave the firmware and live on the filesystem, so a board ships nearly empty — and a pack ships with it. **Hardware unchanged**; v3.0 board and case carry over as-is. + +### Added +- **A pattern set ships with Patternflow.** The **Basics pack** — 33 patterns — is built from `firmware/patternflow/presets/*.h` by the new `firmware/toolchain/make_pack.py`, committed to the repo at `web/public/packs/basics.zip`, and offered at the top of the community's decks shelf. No account, no build queue, and it works against a database that has never been seeded, because the person who just got their board lit has none of those. Built from our own sources rather than a ready-made set: every module carries an author its source actually declares, and the tool refuses to publish one that does not. `Patternflow` names the pack as publisher; the per-pattern author stays in each `.json`, where the licence expects to find it. Rebuilds are byte-identical, so an unchanged pattern set produces no diff. +- **A pack installs from a link.** `/patterns?src=` already ferried a build's file listing; it now also takes a plain `.zip`. Your browser fetches it, unpacks it and posts the modules to the board, so the device never needs TLS — it has nowhere near the heap for a handshake. This closes the deck loop too: a deck's download URL was a file you fetched and dragged, and is now one click. +- **Firmware updates have their own page** ([patternflow.work/update](https://patternflow.work/update)), reached from a banner on the device console when a newer release exists. Your browser downloads the image and hands it to the board over the LAN. Building a whole firmware image with a pattern baked into it is gone — a `.pfm` module does that job in 6 KB without a reflash. +- **A deck has a downloadable pack at a stable URL**, built once per running order and cached against a fingerprint of that order, so a deck nobody edits is compiled once ever. No sign-in: a public deck id is the capability, and the compile is charged to the deck's owner rather than to whoever pastes the link. +- **Install a whole pattern pack from one `.zip`**, contributed by **Simone Majocchi ([@SimonePDA](https://github.com/SimonePDA))**. A pack of `.pfm` + `.json` — what a community deck export produces — is unpacked **in the browser** by a vendored [fflate](https://github.com/101arrowz/fflate) and its members join the ordinary upload queue, so the device never sees an archive and its upload path is untouched. `catalog.txt` may ride along as the running order. Entry names are split on both `/` and `\`: Windows-made zips use backslashes, and without that the junk filter and the duplicate check silently stop matching, files upload under their full path, and the device accepts them, lists them, and loses them on reboot. +- **MQTT sidechannel** (`/mqtt`), also from **[@SimonePDA](https://github.com/SimonePDA)** — his protocol design, role model and topic layout. Knob clicks and the pattern name go out as retained topics (`/knob/1..4`, `/pattern`); a panel set to Subscriber follows them, so two panels stay in sync and Home Assistant sees plain values on plain topics. The role (Off / Publisher / Subscriber) is chosen at runtime and kept in NVS; compiling it in dials nothing until a broker host is set in `patternflow_secrets.h`. +- **Play a pattern from the list** — click a name on `/patterns`. **Arrange the running order** by dragging rows, then `Save order`, which writes the `catalog.txt` the registry reads. **`Select all`** beside the existing bulk delete. + +### Changed +- **Origin is the only pattern compiled into the firmware.** Every preset costs internal DRAM, and with 34 of them a 128×64 board had roughly 1 KB of headroom — `/patterns` returned a truncated page as soon as anything else wanted RAM, which is what made MQTT look impossible to fit. Dropping them frees ~6 KB of DRAM and 49 KB of flash: internal heap goes 11,052 → 16,648 and `/patterns` 1.98 s → 0.03 s. The sources stay in `presets/` as the editable originals and ship as a pattern pack instead. **Updating loses the built-in showcase until you install the Basics pack** — which is one click from the decks shelf. +- **The device console is dark**, and reorganised around what each page is for. The home page leads with a device card (now playing, patterns, storage, network, memory, MQTT, uptime) over grouped rows, two columns on a desktop screen, and tells you when a newer firmware exists — the check runs in your browser against the public flasher manifest, so the device still never talks to the internet. Audio sync is honestly labelled Early. The marketing site stays on paper cream; the device is the thing glowing in a dark room. +- **Pattern names on the panel wrap instead of running off the edge.** The SELECT screen is 64 px wide in portrait — about ten characters — so a community pattern called "Retro Digital Tapestry" was clipped at both ends, and the old size-2 branch clipped from six characters on. Names now word-wrap in the stock 5×7 font. (A narrower bitmap font fits more characters and could not be read at this pixel pitch.) +- **Clearing a library is one request instead of one per pattern.** Each delete used to make the device rescan FATFS and reload the resident module, so emptying fifty was a minute of watching rows vanish one at a time. The endpoint takes the whole list — or `*` for everything, which also catches modules the loader rejected at boot and that therefore never appear in the list — and rescans once at the end. Deliberately without collecting the names into an array first: a `String[128]` of filenames is ~11 KB against ~7 KB of free internal heap, so `*` reopens the directory each pass and carries one name at a time. +- **A deck holds twenty patterns** instead of ten. +- **The delete looks like it is working.** It is one request the device answers only when finished, so the page sat still for about five seconds, which is how a hang looks. The selected rows now dim on the click, a bar sweeps under the status line, and the message counts the seconds. The bar is deliberately indeterminate — the device reports nothing from inside the pass, so a percentage would be invented. + +### Fixed +- **Release images are scanned before publishing.** `net_config.h` bakes in whatever `patternflow_secrets.h` defines, and v2.1.0 and v3.0.0 shipped with the maintainer's home Wi-Fi credentials in plaintext because of it. Images are now built from a sketch copy with that file removed and scanned for every value it defines, with a build that still *has* the secrets used as a control — if the control passes, the scan is broken rather than the build clean. +- **The README described a device nobody has**: 34 built-in presets (it is Origin alone now) and a full-firmware path that carries a pattern along (there isn't one). +- **An unreachable MQTT broker no longer looks like a dead device.** Resolving the host and opening the socket ran synchronously in the render loop every 5 s, so a broker that stopped answering held the loop for seconds at a time — the panel still pinged and still accepted TCP, but served nothing. The address is resolved once and cached, the TCP connect and socket reads are bounded, and retries back off 5 s → 15 s → 60 s. +- **A subscriber no longer jumps when it joins.** A retained knob value was added to the local count rather than differenced against it, so a panel whose knob was not at zero landed at local + remote instead of matching the publisher. diff --git a/docs/releases/v3.4.9.md b/docs/releases/v3.4.9.md new file mode 100644 index 00000000..517eef8a --- /dev/null +++ b/docs/releases/v3.4.9.md @@ -0,0 +1,4 @@ +## [3.4.9] - 2026-08-18 + +### Fixed +- **The browser flasher was still serving v3.4.0, which predates the power clamp** — units flashed from the site between 08-12 and 08-18 could pull ~4.8 A at full white against the "max 2.4 A" printed on the box. This release moves the served image to a build that carries the clamp (`BUDGET_DEFAULT_MA = 2400`, enabled by default), the 13 dBm Wi-Fi TX cap, sleep mode (#314) and the boot latch (#316). Devices flashed from the site in that window need a reflash to pick the clamp up. diff --git a/docs/releases/v3.5.0.md b/docs/releases/v3.5.0.md new file mode 100644 index 00000000..cb1b75d7 --- /dev/null +++ b/docs/releases/v3.5.0.md @@ -0,0 +1,4 @@ +## [3.5.0] - 2026-08-18 + +### Changed +- **Releases build on Arduino core 2.x (IDF 4.4) via PlatformIO** (`firmware/patternflow/platformio.ini`). Core 3.x occupies ~71 KB more internal RAM before the sketch starts — mostly cache carve-out — and on this board that was the difference between a large community pattern loading and being refused, and between a heavy pattern running and the console dying. Measured after services: 15,320 B free / 7,668 B largest block on core 3.3.8 against **98,708 / 90,100** on core 2.0.17. No firmware source changed; a `.pfm` needs one contiguous internal executable block, so the largest-block number is the ceiling on pattern size. Local builds should install ESP32 board package **2.0.x**, not latest — the build guides now say so. diff --git a/docs/releases/v3.5.1.md b/docs/releases/v3.5.1.md new file mode 100644 index 00000000..501b59cb --- /dev/null +++ b/docs/releases/v3.5.1.md @@ -0,0 +1,4 @@ +## [3.5.1] - 2026-08-19 + +### Fixed +- **Every wireless upload was paying a fixed five-second stall.** The core 2.x `WebServer` raw-body loop asks `readBytes()` for a full buffer even on the final chunk, so every pattern install, sidecar and catalog write sat out the 5 s stream timeout (a 447-byte file took 5.4 s; a body of exactly 1,436 bytes took 0.4 s — that probe is what convicted it). The library is now vendored under `src/webserver/` with the core 3.x read-exactly-remaining behaviour; a 29 KB pattern installs in 0.8 s and `/update` doubled to ~107 KB/s. The full investigation — where the core 3 heap went, the dead ends, the probe method — is written up in [docs/investigations/2026-08-core2-heap-and-the-5s-stall.md](../../docs/investigations/2026-08-core2-heap-and-the-5s-stall.md). diff --git a/docs/releases/v3.5.2.md b/docs/releases/v3.5.2.md new file mode 100644 index 00000000..4fdb8e99 --- /dev/null +++ b/docs/releases/v3.5.2.md @@ -0,0 +1,4 @@ +## [3.5.2] - 2026-08-19 + +### Fixed +- **`cosh` and its relatives now resolve, so soliton patterns stop reading as impossible.** The module loader's host-symbol table carried `tanhf` but not `sinhf`/`coshf` — and sech(x) = 1/cosh(x) is the closed form of a soliton, so an entire genre of wave patterns failed to load with `unresolved symbol: coshf`, which looks exactly like "too heavy for the board". A systematic audit (every Basics-pack module plus a synthetic probe of the full libc/libm surface) added 40+ names in bulk: both hyperbolic families, `erff`/`erfcf`, the `rintf` family, 64-bit integer helpers, `qsort`/`bsearch`/`strto*`, string/ctype, and an `atexit` shim. `build_module.py` now checks every `.pfm` against the loader's own table at build time, so a pattern that the device would refuse fails on the build machine with the missing names spelled out. The Pattern Lab C++ prompt learned the new reality too: lookup tables are the house style (measured: 0–64 KB of static LUTs all render at 62 fps; a 4 KB sech² table took a soliton from 18 to 33 fps), and the old ~2 KB static ceiling — set when the largest free block was 7.7 KB — is now ~32 KB. diff --git a/docs/releases/v3.6.3.md b/docs/releases/v3.6.3.md new file mode 100644 index 00000000..3397e484 --- /dev/null +++ b/docs/releases/v3.6.3.md @@ -0,0 +1,47 @@ +## [3.6.3] - 2026-08-25 + +The performance release. It started as groundwork to meet **Simone Majocchi's ([@SimonePDA](https://github.com/SimonePDA)) performance-director work**, and ships as the whole thing: his on-device show player, scheduler and weather stack integrated onto this tree's core, a show-authoring Director inside Pattern Lab, and a show format that finally moves the way the curves were drawn. A pattern, its show, its firmware port and its published post now share **one name**, end to end. + +### Added +- **The panel plays shows by itself.** Simone's performance-director firmware is integrated whole: `.pfs` show tables install from the new **Sequences** console page (`/show`), play on a wall-clock (cues fire by `millis()`, not by frame), chain into playlists with per-run variance, and follow a night/wake schedule with a dimmed clock face. His weather stack (`/weather`), his `MatrixLight6/8X` console fonts and his **FlowLocal** MQTT appliance mode (off by default, hard backoff, anti-brick guards kept) came along. The show buffers live in PSRAM, so a 256-cue show costs the pattern engine nothing it can feel. +- **A show pauses and resumes in place.** Pause banks the wall clock, resume re-bases it, so cues — and a v2 ease mid-ramp — continue exactly where they stopped, and the paused look stays on the panel (absolute holds are kept). The Sequences page carries the Pause/Resume button; `POST /api/shows/control` accepts `op=pause` / `op=resume`; status reports `paused`. Start with nothing ticked does the obvious thing too: it continues a paused show, or replays what is loaded, instead of scolding about checkboxes. +- **Pick which saved network the panel boots on.** Slot order is recency, which is wrong the moment a panel lives somewhere it was not last provisioned — every boot then fails on an absent network before the walk wraps around. `/wifi` grows a Boot network picker (stored in NVS, applied on the next boot) and a Reboot button. Ported from @SimonePDA's tree. +- **PFST v2 — the show file learned to glide.** Version byte 2 reinterprets cue time as **deciseconds** and adds one cue flag, EASE: *lerp the set channels toward each channel's next cue*. Byte layout unchanged, v1 files play exactly as before, and the player's cost was measured on the panel at **zero** — frame time is indistinguishable idle / v1 / v2 (15.7–16.0 ms), because the lerp is a pure function of the clock. A 60-second authored curve that took 61 staircase cues ships as 11 eased pieces within 0.8 % of the drawn bezier. The full proposal with the hardware table is [docs/pfst-v2-spec.md](../../docs/pfst-v2-spec.md). +- **Pattern Lab grew a Director panel** — show authoring where the pattern already lives. Four knob lanes plus a message lane; double-click drops a keyframe, segments hold or ease along **Blender-style bezier handles you grab and pull**. Keyframes sit on the v2 wire grid (0.1 s) — the **snap toggle** (1 s / 0.5 s / 0.2 s / 0.1 s) is an authoring aid, not a format limit — and playback drives the real knobs through the authored curves, so the live preview *is* the show. Export downloads the `.pfs` the panel plays; import round-trips a v2 file back into editable segments exactly. Publishing a pattern offers to attach the show on the community's performance rail. +- **The piece has a name, and everything answers to it.** A single name field sits center of the Pattern Lab header. To hardware pins the C++ `NAME` to it — and stamps it back onto a pasted translation when the AI "improves" it, which is not hypothetical: a show authored for `HexagonalHivePulse` once animated nothing because the model had quietly retitled the port `HexHivePulse`. The Director names the `.pfs` after it, **stamps it into the show's opening cue so the device switches to the right pattern the moment the show starts** (verified on hardware), and Share opens titled with it. Projects from before the field existed inherit the name they were already living under. +- **One console chrome.** Every device console page now loads a single `/pf-console.js` that owns the header band, the nav — and a **light theme**, one toggle on every page, remembered across all of them. Adding a console page to the nav is now one edit in one file, which is how `/show` stopped being a page half the console never linked to. +- **A pixel drawing copies out as pattern code** — the Pixel layer's *Copy as code* hands the drawing to whatever AI you use as an editable, commented pattern program, and the layer stays put. The C++ conversion prompt also honours the layer's **Recolor** toggle with pre-baked ramp LUTs. +- **Your own post reopens as itself.** Opening one of your published patterns in the lab now marks the session as *editing* that post — Share updates it in place, same page, same likes and forks — instead of publishing a fork of your own work. Detach with one click if you did want a new post. +- **A moderator can repair a broken `.h`** — and only the `.h`: the firmware port on a community pattern can be fixed in place when it will not compile, while the title, the JavaScript and the licence stay exactly the author's. A pattern with no port cannot grow one this way. +- **Pattern Lab grew a Capture panel** — the pattern as a picture or a clip, not only as a thing that goes on the panel: a PNG for a business-card background, an MP4/WebM loop for a post, at 1050 × 600 @ 300 dpi, Full HD, 4K, square, vertical, or any size up to 4096 px, turned 90°/180°/270° if the panel would hang that way, over black, a colour, or real transparency (unpainted areas clear, or dark-to-clear so it glows on white paper). Pause on the frame you want, step frame by frame, run it at ¼× to 4×. Clips render offline at a fixed 24/30/60 fps, so a frame that takes half a second to draw is still one frame in the file. All of it runs in the browser, in a Web Worker with its own engine — the live preview keeps its 60 fps while the stage grinds through 4K, and nothing is uploaded anywhere. + The interesting part is **what "re-render at a bigger size" means for pattern code that was written for 128 × 64.** Most of it was: `cx = 64`, `Math.sin(x * 0.1)`, a `Float32Array(128 * 64)` — and run at 1024 px such a pattern comes back as four tiles, a small picture in a corner, or nothing, with no way to tell from the source which it will be. So the *Auto* look runs a probe first: the stack at its own matrix and at an aspect-true multiple, both box-filtered back to the matrix, compared on texture density and layout. Measured over all 46 bundled presets, the dozen that scale cleanly and the rest separate with no overlap. Those that scale are re-rendered for real; the others are upscaled as crisp pixel blocks at the requested size with the verdict spelled out, plus a **Copy any-size prompt** button that asks whatever AI you use to rewrite the pattern in frame-relative units — the same copy-paste loop as the C++ conversion. Paste the answer back and Auto re-checks it by itself. +- **Home Assistant integration** (`custom_components/patternflow/`) — a panel shows up as a device, discovered over mDNS, with an On/Sleep switch, a pattern picker, the four knobs, and the numbers worth watching. It also ships a **dashboard card**: the running pattern playing live with the four encoders laid over it as zones you drag — mouse or touch, unlike the community wall's card, which is wheel-only and therefore does nothing at all on a phone. Everything runs on the LAN over the device's own HTTP API. No cloud, no account, and **no broker for anything except turning a knob**. + That exception is the whole shape of this. Knob *positions* are readable over plain HTTP — `/api/mqtt` reports them in any MQTT role and with no broker configured, because the firmware copies the input frame into that state before it checks the role. Knob *writes* have no HTTP path at all: `/api/knob` and `/remote` were built and removed as unused, so a write goes out over MQTT and only a **Subscriber** obeys it. Setup offers the role change when that is the only thing missing, and says what it costs — a Subscriber stops publishing its own knob turns. A refused write names which of the four ways it was refused rather than failing quietly. + The card **never asks the panel for pixels**, which is not a shortcut but the design: a device-streamed frame preview was built, shipped and removed the same day because polling it captured the render loop until the device read as dead. So the preview is the pattern's own JavaScript running in a sandboxed iframe in your browser, driven by the values Home Assistant holds — exactly what `core_patterns_http.h` says a live preview should be if one ever came back. The card bundles the Basics pack's 33 patterns, matched to the modules on your device by slug; a community pattern or a preset shows the controls without a picture rather than an error, because a device stores compiled modules and no source. Installable **through HACS** as a custom repository: the component sits at `custom_components/patternflow/` in the repository root, because that path is hardcoded in HACS and nothing in `hacs.json` can point elsewhere. Its tests, lint config and scripts stay in `integrations/homeassistant/`. Each release carries a `patternflow-homeassistant.zip`, which is what HACS actually downloads, and the project version is stamped into the manifest on the way in. +- **The device's HTTP API has a written contract**, `docs/rest-api.md`, alongside `docs/osc-spec.md`. It documents what exists today and, more usefully, the rules that are invisible from an endpoint list and have each already cost a feature: the server takes **one connection** and pauses drawing while it answers, so automated clients keep to `/api/*`; and `POST /api/sleep` and `/api/patterns/select` report the state **before** the transition, because stopping a DMA engine does not belong inside an open response. Also written down: that there is no MAC, serial or unique id on any endpoint, so two panels need distinct `PF_OTA_HOSTNAME` values. (The third rule it originally recorded — console pages evicting the pattern for 25 s — died during this release; see *Changed*.) +- **Sleep mode** — the panel goes dark and the board idles without anything being unplugged. There is an **On / Sleep switch on the device console's home page**; you can also turn K1 on the NETWORK screen, publish to MQTT `​/sleep`, or `POST /api/sleep`. Any knob turn or button press wakes it, as does an incoming firmware image. Waking gives the pattern straight back even when the open console was holding it paused. It stays **on the network** throughout, which is the whole design: `esp_deep_sleep` would take the draw to microamps and take the radio with it, and then "lights out from the sofa" has no way back. So the LEDs go off, the HUB75 DMA transfer stops (the driver ICs are otherwise clocked at 15 MHz whether or not anything is lit), Wi-Fi drops to modem sleep, the CPU to 80 MHz, and the loop yields instead of spinning. The device publishes `​/sleep/state` on every change, so Home Assistant sees it — and obeys the command in **either** MQTT role, because a panel that publishes its knobs is still a panel you want to be able to switch off. `PF_SLEEP_STOP_DMA 0` in `config.h` is the fallback if a particular panel comes back garbled. +- **The selected pattern survives a power cut.** It used to start at Origin on every boot. It is saved as a **slug**, not an index, because installing or deleting a single `.pfm` renumbers the list and an index would come back as somebody else's pattern; the write is debounced so spinning K4 through fifty patterns is one NVS write rather than fifty. A remembered pattern that no longer exists — or no longer loads — falls back to Origin with a log line instead of greeting you with the PATTERN FAILED screen. +- **Performances live under patterns**, on the same social rails as firmware ports: anyone records a timed knob ride in the Director tool and publishes the Save-JSON on the pattern's page — live immediately, credited to the recorder, with the author's own recording (or their pin) deciding which one represents the pattern. Each recording downloads as the editable `.json` or the packed `.pfs` the panel's player reads. +- **Like from the wall.** Every pattern card grew a heart beside its add-to-deck button — the count updates in place, and signed-out visitors get the sign-in modal at the moment they click, same rule as everywhere else. +- **Packs carry performances.** A deck owner can attach a Director performance JSON to their deck; the pack zip then includes `performance.json` (the editable source) and the encoded `.pfs` show table. Today's firmware simply ignores the `.pfs` on install — the pack stays a normal pattern pack — and the performance firmware picks it up the day it ships. The server-side PFST encoder is byte-identical to the Director's own saves. +- **Absolute parameter bus (0..1000), prepared.** MQTT `​/param/1..4` can pin any knob of an absolute-ready pattern to an exact value; physical encoder motion releases the hold, and plain deltas keep working exactly as before. Patterns opt in through one `PFParams::apply` line per knob — every preset, the Basics pack, and all convertible community headers are converted, and everything the site generates is absolute-ready from birth. `/mqtt` grew channel presets (Broadcast / Ch 1–4 / Live) with retained per-channel snapshots, and a **Director mode** that points the panel at a local authoring broker without disturbing the saved one. + +### Changed +- **Opening a console page no longer pauses the pattern.** The 25-second eviction was the honest price of serving whole pages from the core-3 builds' ~15 KB of free DRAM; the core-2 builds freed ~96 KB, and the page sender now streams PROGMEM in small slices under a hard 5-second budget, tolerating socket stalls instead of holding the loop. `status.consolePaused` still exists and now means exactly one thing: a pattern-install batch is in progress. `docs/rest-api.md` is updated to match. +- **Module ABI descriptor is now 2** (the appended absolute-param fields). The loader accepts 1 and 2, so every existing `.pfm` keeps loading; **pre-absolute firmware refuses new modules cleanly** instead of misreading them. This was the gate that tied the release to the performance firmware — and this is the release that ships both. +- **MQTT retention now follows the show policy**: knob/pattern topics publish non-retained; the broadcast banner and channel snapshots are the retained exceptions. +- **Release builds pin `platform = espressif32@7.0.1`** so a machine with a different PlatformIO fork installed cannot silently resolve another core. + +### Fixed +- **A show could not move the knobs of a pattern built by the site.** The module build targeted ABI 1 unless `PF_TARGET_ABI=2` was set, and at ABI 1 `pf_params.h` compiles the absolute tier *out* — so a converted pattern declaring `ABSOLUTE_READY = true` shipped unable to read the absolute bus. A Director show then set the parameters, the device held them (`paramActive` true, values sweeping), the pattern ignored them, and every layer reported success: the show ran and nothing moved. Builds now target ABI 2 by default, which the loader has accepted since v3.5.1 and the browser flasher has served ever since; `PF_TARGET_ABI=1` still builds for firmware older than that. Caught on the first show authored end to end in the lab, and folded into this release. +- **The reconnect path deleted the Wi-Fi network that had just worked.** `addNetwork` took its arguments by reference and the reconnect path passes a saved slot itself, so the shift loop overwrote the referenced string mid-move: joining the second saved network turned `[home, studio]` into `[home, home]` — the working network gone, the absent one duplicated, and the panel unreachable after the next reboot. It now copies before it shifts, and duplicate slots already written into NVS are healed on load. +- **The panel clock was documented as 15 MHz and it is 16, which is the wrong place to look for it.** `HZ_15M` is `16000000` and `HZ_10M` is `8000000` — identical to `HZ_8M`, so there is no step between 8 and 16 MHz at all. Both the `core_display.h` EMC warning and the firmware README repeated the enum's name instead of its value, which sends anyone hunting harmonics on a spectrum analyser to 15 MHz and its multiples rather than 16, and made "drop to `HZ_10M`" read as a small step when it halves the clock. Corrected in both, along with the trade the two settings actually make: `i2sspeed` and `min_refresh_rate` move together, and dropping the clock while leaving `min_refresh_rate` at 240 is the worst case — it forces the library's maximum colour-depth sacrifice to hold a refresh rate you were not keeping. The measured table for this panel is now in the README. + This surfaced because **cartoonmonkeystudio (CE), on Discord**, found that the same clock was desensitising their unit's Wi-Fi — 83–95% packet loss on their board — and bisected it properly, down to a display-free sketch behaving normally on the same hardware. Reproduced here on a second board at much lower severity (no packet loss either way, but median latency 15 → 4 ms at 8 MHz), and **not adopted**: every `min_refresh_rate` that preserves colour depth bands on video at 8 MHz. Their patch also raises the Wi-Fi TX power, which cannot be taken — 13 dBm is a conformance setting, not a conservative default. The measurements, the parts that cannot ship, and what to try first if your unit's Wi-Fi is bad are written up in [docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md](../../docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md). +- **A board that has never been formatted let you try to install patterns anyway, and only said so eight failures later.** Pattern storage is a FAT volume nothing has ever written to on a new device, so it will not mount until somebody formats it once — deliberately, because an earlier revision formatted automatically on a failed mount and one crash mid-write turned that into "the next boot wipes your library". The prompt for that one deliberate act was a grey `Format storage` button sharing a row with `Retry failed` at the foot of the upload column, annotated in the smallest type on the page, and nothing stopped you dropping files past it — so the first thing a new panel did was refuse a whole batch, one `filesystem not mounted` at a time, which reads as "uploading is broken" rather than "press that". An unmountable volume now announces itself above both columns, with the button in the banner and the reason written out; the drop zone goes inert and says why when clicked, instead of accepting work that cannot land; and a `/patterns?src=…` install link from the community is **held rather than spent** — the page waits for the volume to be usable and then runs the install by itself, so arriving from a pattern page on a fresh board is still one click. The format warning is written for the state it actually appears in, too: the device cannot list what is on an unreadable volume, and on a new board the honest answer is that there is nothing there to lose. Reported by akacoda on Discord, from a first install. +- **Sharing a layer stack with a pixel layer on top failed — two ways, and one of them looked like success.** A pixel layer flattened as 44 KB of raw RGBA, so one imported image (or two pixel layers of anything) pushed the published code over the community's 100 KB cap and the modal said "missing or over 100KB" with no hint why. Pixel layers now embed run-length coded, the same `{count, r, g, b, a}` shape the `.h` exporter writes — an empty or flat-filled layer is a few hundred bytes, an imported photo falls back to raw — the modal says what is too big *before* the round trip, and the editable `@stack` line is dropped only when the total would not fit, instead of the whole publish being refused. + The worse one: the publish route strips licence wrapping before storing, and its footer pattern deleted from the **first** "Made with Patternflow" line to the end of the file. Every preset and every AI variant ends with that footer; flattened, it sits mid-file inside the layer's function with the composite `draw()` after it. The upload returned 201 and stored half a pattern — "render error" on the card, a SyntaxError on its page. A footer is now a footer only when nothing but comments follows it. `npm run check:flatten` and `check:license` cover both. +- **Coming back to the wall from a pattern no longer hammers the server.** Three things fired at once on Back: the feed rebuilt its scroll position in viewport-sized batches (sixteen requests for a 300-card feed, each a full-source page with four correlated counts per row), every card entering the viewport prefetched its detail page — a `force-dynamic` route, so each prefetch was a server render of the community layout, and the href changed on every wheel tick over a card, so *each tick* registered another — and each of those ticks also queued a fresh thumbnail render behind one shared iframe at five seconds' timeout apiece, with the queue and its cache unbounded and outliving the page. On a Raspberry Pi with a synchronous SQLite this was a freeze that read as "the server went down". The rebuild now fetches full pages (seven requests at most), card links do not prefetch, a card re-renders its still only when the cursor leaves it, withdrawn renders leave the queue, and the cache forgets past 400 stills. `docs/SERVICES.md` now also says the systemd units need `Restart=always`, which nothing had written down. +- **A repeated `?k=` on a pattern page was a 500**, the detail page queried the pattern twice per view (metadata and body), and a pattern accepted just under the size cap was refused on an unchanged re-save because the cap was measured with the licence wrapping still on. +- **An AI conversion no longer forces a color ramp onto an RGB pattern** — the "does this pattern use the value field" check read the whole source, so a mere mention of `setValue` in a comment recolored a pattern that painted its own RGB. The check now blanks comments and strings first. +- **Fractional Director keyframes survived export but not a reload** — the autosave restore path rounded keyframe times to whole seconds, silently flattening a 0.1 s-grid show back onto the 1 Hz grid. Restores now keep the wire grid. +- **The show list reported v2 lengths in raw ticks** (a 90-second show read as 900) — the header scan now divides by the version's tick rate. diff --git a/docs/releases/v3.7.0.md b/docs/releases/v3.7.0.md new file mode 100644 index 00000000..7ef86066 --- /dev/null +++ b/docs/releases/v3.7.0.md @@ -0,0 +1,25 @@ +## [3.7.0] - 2026-08-27 + +The release where a firmware can have a name. Everything Patternflow does still ships on the board — MQTT, sequences, weather, OSC, audio, all of it — and now the same tree can also publish *named* firmwares for the things the default cannot carry: a microphone that needs four wires soldered on, a radio setting that must not be universal, a build somebody needs pinned so a show behaves the same at the next gig. + +**Nothing left the core, and nothing is going to.** An earlier plan would have moved features out into separately-owned repositories; that was withdrawn before it happened, and the measurement that killed it is in the changelog below. + +### Added +- **A named firmware is two files, and it never edits a core one.** `addons_local.h` says which addons a firmware has, in what order, and may drop or reorder the defaults; `overrides.h` reaches any `#ifndef`-guarded setting — transmit power, panel clock, the firmware's own name — because it is included before any default applies. Both are gitignored here and belong to whoever built the variant, which makes taking a core update a file copy rather than a merge, forever. Measured three ways: default 1,412,457 B, a sound-only build 1,133,925 B, `PF_ADDONS_NONE` 1,094,813 B. +- **A panel says which firmware it is running.** `/api/status` reports `variant` and `variantVersion`, and the console wears the variant's name as a badge next to the wordmark on every page, linking to its entry on the new [variants shelf](https://patternflow.work/variants). Core shows nothing — it is the default and a badge on every panel would say nothing. The core version is now labelled `core v` beside it, because unlabelled it read as the variant's and was wrong. +- **The update banner stays quiet on a variant.** The manifest here describes *core* releases; offering one to a panel running somebody else's firmware would talk a person into flashing away the thing they chose, on a version comparison that means nothing across two release lines. +- **`/api/status` reports knob positions, the parameter bus and transmit power.** Knob state used to be readable only through `GET /api/mqtt` — inside an addon — so a build without MQTT could be written to but not read, and an HTTP-only integration would have lost it the day MQTT moved out. `txDbm` joins it because a variant can now change the radio. +- **A shelf you can install from.** [patternflow.work/variants](https://patternflow.work/variants) lists core and every listed variant as a card, with one-click install for images served from here — the panel's own `/update` page does the fetching, the same trick the core updater already used. Hosted copies are checked against the maintainer's latest GitHub release and stand down when they have drifted. + +### Changed +- **The split was withdrawn before it shipped.** Steps 1–4 of the RFC were right and they are in this release; step 5 — deleting features from the core — is cancelled. A build with three addons removed was measured beside the full one: the ceiling on loadable patterns is **identical** — 73,716 bytes either way — on a board using 45 % of its flash. Stripping all five moves it 18.4 KB, in a build with no shows, no MQTT, no weather, no OSC and no sound that nobody would ship; the largest pattern module anyone has built is 29 KB — the 42-pattern community library tops out at 17.5 KB — against a ceiling already two and a half times that. The case for a slim core rested on headroom that was never scarce. It would also have redefined a product already sold — the funded page lists MQTT and bidirectional OSC as things the device does — and, worse for the stated motive, a separate repository is precisely where trees drift apart. In this tree, widening `observeFrame` for OSC broke MQTT in the compiler a second later and was fixed in the same commit. Elsewhere it would have rotted in silence. +- **The formats are frozen.** The `.pfm` ABI and the `.pfs` show format do not move: fields are appended, never reordered, never reinterpreted; old files play forever; anything else takes a version byte and the old version keeps working. This was already the practice — it is a promise now, which is the guarantee a performer actually needs. Not "the firmware never changes", but "the files keep working". +- **OSC is an addon.** It stayed in the core on the grounds that it "needs no infrastructure", which does not survive being said out loud — a panel running a standalone AP transport needs none either. OSC is how a panel talks to Max, TouchDesigner and Ableton; it is sound integration and it belongs with the rest of it. Nothing is stranded: a build without it still updates, still runs every community pattern, and still takes remote control over HTTP. +- **The console pages are HTML files.** They lived inside `R"HTML(...)"` literals, which is the right way to ship them and the wrong way to work on them. `firmware/patternflow/console/*.html` plus a mock device on `localhost:8322` means save-and-refresh with devtools; a generator splices them back and CI checks the two stay in sync. + +### Fixed +- **Remote pattern picks are remembered again, and show cues still are not.** An addon that *claims* the pattern owns the panel while it runs, so its choices are transient; one that only *asks* is relaying a person, and that should survive a reboot the way turning the knob does. The distinction existed and was simply not reported. +- **Three places a removed feature stayed visible**: a preset hardcoded from another addon's directory, so a firmware with no show player still carried its Black pattern; a pattern counter that counted entries it would never stop on, so a list with one hidden entry read "1 / 3" then "3 / 3"; and a status row reporting MQTT on builds without it. All the same shape — the build stopped carrying something and the interface never found out. +- **The home page ignored `caps`.** The nav learned about capabilities; the page body never did, so a stripped build still advertised four features it did not have. Rows now disappear with the features, groups left empty disappear with them, and what remains is renumbered. +- **Adding a Wi-Fi network could delete the one that was working.** `addNetwork` took its arguments by reference and the reconnect path passed the stored entry itself, so the shift loop overwrote it mid-call. +- Eight console pages carried an orphaned CSS block — the body of a deleted rule — costing a parse error and 492 bytes on every page. diff --git a/docs/releases/v3.7.1.md b/docs/releases/v3.7.1.md new file mode 100644 index 00000000..27f33f78 --- /dev/null +++ b/docs/releases/v3.7.1.md @@ -0,0 +1,12 @@ +## [3.7.1] - 2026-08-28 + +The 3.7.0 seam shipped without the line that runs it. + +### Fixed +- **Every addon's per-frame hook was dead.** `PFAddons::loop()` is not called anywhere in 3.7.0. The addon port moved the per-frame work out of the sketch — correctly, it belongs to the addons — removed the concrete calls that used to do it, and never added the dispatcher that replaced them. Two orphaned comments were left where the calls had been. So the show player never ticked, MQTT never pumped, weather never fetched, and the audio websocket never completed a handshake. **Nothing failed loudly:** `onNetwork` still ran, so routes registered and port 81 opened, TCP connected, and the console's audio page loaded and said DISCONNECTED forever. Every addon was present, initialised, and frozen. On-board audio was the exception — `audio_in` runs on a Core 0 task, not the loop hook — which is why this read as an audio problem rather than a seam one. Verified on hardware: a raw websocket handshake was an 8 s timeout before and `HTTP/1.1 101 Switching Protocols` in 121 ms after; `/api/shows`, `/api/weather` and `/api/mqtt` all answer again. +- **The audio page's file picker worked exactly once per load.** `createMediaElementSource()` can be called once per media element, ever; calling it for a second file throws, so the first track analysed and every one after it did nothing while still playing. +- **One empty FFT bin froze the audio meters for the session.** `getFloatFrequencyData` returns `-Infinity` for a bin with no energy, which makes the band average `-Infinity` and then NaN inside the smoother — and NaN is absorbing, so nothing recovered until a reload. +- **The connected-client count only ever went up.** It was kept by hand and a client whose TCP died without a close frame never produced the event that decremented it, so an idle panel reported one client connected. The websocket library already knows; it is asked now. + +### Added +- **`/api/status` reports `audioRuntime` and `audioClients`.** Whether audio was switched on at all was invisible from outside the device: a browser could hold a socket open, send knob messages, and have every one dropped because the AUD row on the panel was off, with nothing anywhere saying so. diff --git a/docs/releases/v3.8.0.md b/docs/releases/v3.8.0.md new file mode 100644 index 00000000..d448304f --- /dev/null +++ b/docs/releases/v3.8.0.md @@ -0,0 +1,20 @@ +## [3.8.0] - 2026-08-31 + +One instrument, three editions. The firmware split into **editions**: compositions of features over one unchanged core, installable in one click from [patternflow.work/variants](https://patternflow.work/variants) and switchable any time without losing patterns, networks or settings. + +### Added +- **Editions.** `firmware/patternflow/features//` holds every feature behind one hook struct (`pf_feature.h`); the core names none of them. An edition is two files under `firmware/bundles//` — which features compile in, and what the build calls itself. Three ship: **Patternflow** (core: patterns, four knobs, and the most contiguous memory a pattern can get), **Patternflow Audio** (on-board PDM microphone, OSC, the browser and phone audio paths), **Patternflow Performance** (sequences, MQTT in every role, FlowLocal and the Director, weather — Simone Majocchi's work, running as a composition of this tree). The full reasoning is `docs/EDITIONS.md`. +- **The tree speaks the vocabulary EDITIONS defined.** `addons/` became `features/`, `PFAddon` became `PFFeature`; the old spellings are shimmed for out-of-tree bundles. +- **The boundary grows teeth.** `firmware/toolchain/check_boundaries.py` fails CI when the core references a feature namespace or branches on a feature flag — and caught its first violation on its first day. `check_abi_freeze.py` locks the module ABI; what the core promises modules is now written down under `firmware/patternflow/abi/`. +- **The default build stops claiming features it does not have.** The console's edition card, `/api/status` and the panel's info screens describe the firmware that is actually running. +- **A new board and the shelf install the same thing.** The browser flasher and the one-click edition install draw from one set of images, staged by `firmware/bundles/shelf.sh`. +- **The on-board microphone works in the actual device**, with a gain stage (Espressif's PDM path is quiet), auto-ranged bands over a measured noise gate, and bands that ride their own envelopes. +- **The mapping editor.** Bands are boxes drawn on the live spectrum; response curves — rise, fall, gate, steps, arch, hand-drawn — bake to 33-point tables the firmware only interpolates. Glide ballistics (attack and damping, set separately) and an in-editor preview that runs test signals through the chain. The device's `/audio-in` console page is assembled from the extension's editor, so the two cannot drift. +- **An Android capture app** (`tools/patternflow-audio-android`): what the phone plays drives the panel, with the mapping fetched from the device. + +### Fixed +- **The input frame was never initialised**, and only the edition split made it show: a build without a feature that happened to zero it read garbage on the first frame. +- The console's old `/audio` page, which could never have worked against the port-80 server, is gone; `/audio-in` is the page. + +### Changed +- `PF_IMPROV_FW_VERSION` is `3.8.0`; the Audio edition ships as v0.4.0 and Performance as v0.2.1, on their own version lines. diff --git a/docs/releases/v3.9.0.md b/docs/releases/v3.9.0.md new file mode 100644 index 00000000..5d642d57 --- /dev/null +++ b/docs/releases/v3.9.0.md @@ -0,0 +1,32 @@ +## [3.9.0] - 2026-09-03 + +A tidy-up under the hood. **Nothing changes in how the panel is used**: the same knobs, the same console, the same shelf, the same patterns and `.pfm` modules. The tree was audited end to end after two months at three hundred commits a month, and this release is what that produced — documentation that describes the code as it is, CI that runs every check that existed, firmware settings filed with the feature that reads them, a web `lib/` with a shape, a Pattern Lab whose panels are registered in one place and tested under a DOM, and the Home Assistant integration moved out to its author's own repository. Two things did arrive on the shelf during this cycle — the Audio edition's network MIDI (v0.5.0) and the panel answering to more names — and they are listed below; their proper write-ups, with the walk-throughs they deserve, come with the feature releases to follow. + +### Added — Audio edition v0.5.0 +- **The panel is a MIDI device.** A new `midi` feature (`features/midi/`) makes it an RTP-MIDI port over Wi-Fi — macOS/iOS natively, Windows through rtpMIDI. CC 20–23 pin the four knobs absolutely (the same controllers the Director's `.mid` export writes, so a Live clip made from a show now drives the panel), CC 24–27 turn them relatively, notes 60–63 are the buttons, Program Change picks the pattern; the encoders, buttons and pattern changes go back out the same way. `POST /api/midi?outDiv=N` sets how many detents make one outbound step (1–16, persisted) — one per detent was a lot of parameter per wrist in the first Live session. Contract: `docs/midi-spec.md`. Costs ~6 KB of internal RAM on the audio edition. A `tools/rtpmidi-probe/` script exercises the whole map from a PC with no MIDI driver. +- **A Bluetooth Wi-Fi-setup feature, in the tree but in no edition.** `features/ble/` speaks Improv-BLE (the flasher's Improv-Serial RPC on a GATT service) and its lifecycle works on hardware: advertise only while the panel cannot join, take credentials after a touch on the panel, join, then stop the stack and return the controller's memory. It is not composed anywhere: linking it costs ~12 KB of internal RAM (libbt's IRAM code) and ~36 KB more while advertising, and the one phone it was tried with never listed the panel in Chrome's chooser. Left as an opt-in for whoever wants to finish it; the header records what was measured and the two traps below. +### Added — core +- **The panel answers to more names.** `http://patternflow/` over NetBIOS for Windows machines whose mDNS is broken or blocked, and a per-panel mDNS alias `patternflow-<4 hex of the MAC>.local` (`hostAlias` in `/api/status`) so two panels on one network stop fighting over one name. Both re-announced on every reconnect. And every link from the console to patternflow.work now carries `?device=`, which the site stores where its "Send over Wi-Fi" buttons look — so anyone who has opened the console once, by any route, gets uploads that never depend on `.local` resolving. The Android case, which no firmware can fix at the resolver, is fixed at the link. + +### Notes +- Two traps found on the way, recorded in `features/ble/core_ble_improv.h`: the coexistence module aborts if Bluetooth starts while Wi-Fi power save is off (the core runs `WIFI_PS_NONE`), so modem sleep is switched on for the radio's lifetime; and NimBLE 2.x deletes callback objects it was handed, so they are heap-allocated. +- **Two guides** landed after the release: `AUDIO_GUIDE.md` (the extension, the mapping editor, building the on-board microphone, OSC, MIDI) and `FEATURE_GUIDE.md` (writing a feature or cutting an edition). +- **`shelf.sh` refuses an image that does not carry the version it is being published as** — Audio v0.4.0 had shipped still believing it was v0.3.1. +- **Pattern Lab's Graphic Export renders posters with a renderer of their own** — a print is not an LED panel, so the poster path stops pretending to be one. +- **The shelf is `/editions`.** The page that installs a firmware in one click moved from `/variants` to the word the docs use; the old URL — baked into every 3.8.0 console page — redirects permanently. `docs/EDITIONS.md` now carries the vocabulary table: feature / composition / edition, and which older spellings (`bundles/`, `PF_VARIANT`, the `variant` field in `/api/status`) stay because other software reads them. +- **Feature settings live with their feature.** `PF_OSC_*`, `PF_AUDIO_*`, `PF_MQTT_*`, `PF_WEATHER_*` and `PF_SHOW_HTTP_ENABLED` moved out of the core's `net_config.h` into `features//_config.h`, still `#ifndef`-guarded so a secrets file or an edition's `overrides.h` wins exactly as before. `net_config.h` is the device's network settings and nothing else. The absolute bus scale has a name, `PF_BUS_MAX` (`BUS_WIRE_MAX` on the site), where it used to be the literal 1000 in eleven places. +- **Web and firmware presets are checked as twins.** `firmware/toolchain/check_presets.py` (in CI) fails when a web preset appears without a firmware `.h` or an entry in its web-only list, or a firmware preset has no web source — the web preset is the source of truth and a hand-edited `.h` used to be silently overwritten. +- **`/api/community/*` is closed at the door.** `web/src/proxy.ts` answers 404 for the whole prefix on a deployment without `COMMUNITY_ENABLED`; each route still checks for itself, but a route that forgot is no longer open by default. +- **`web/src/lib` has a shape.** `lib/pattern/` (the runtime, the colour ramp math, the `@matrix` / `@ramp` / `@knobs` annotations, the `.pfs` codec, knob-scale constants), `lib/ai/` (Gemini, with its settings vocabulary in a dependency-free module the lab persists against), `lib/hooks/`. The `@knobs` parser exists once, shared by the lab and the community cards, where each side had its own copy of the regex. +- **The community's server side has a door.** `lib/community/server/` holds everything that touches SQLite, the filesystem or a session (`db`, `schema`, `queries`, `auth`, `attachments`, `retention`, `builds`, `admin`, `deckZip`, `notify`, `serialize`); ESLint refuses a value import of it from a component or a `*Client.tsx`. Type imports still pass — that is all any client file ever took. +- **The lab store is six slices.** `lib/lab/store/` — project, layers, knobs, ramp, director, gallery — composed by `store.ts`, with the helpers they share in `store/shared.ts`. Same actions, same bodies, one file per concern instead of 800 lines in one. The two biggest panels shed what was not the panel: the image-import modal (`ImportImageModal.tsx`) and the pixel undo stacks (`lib/lab/pixelHistory.ts`) out of `PixelPanel`, the lane components and the wire-to-pixel geometry out of `DirectorPanel`. +- **`/editions` no longer mismatches on hydration.** The install links read the remembered device host through `useSyncExternalStore` with a server snapshot, so the first client paint matches the HTML and the saved address arrives on the next render; the same hook served `/update` and the deck panel, which were quietly doing the same thing. +- **The lab's two big panels are compositions, and there is a harness that says so.** `PixelPanel` (957 lines) is 240 lines over six hooks — tool state, the select tool, the viewport, the three canvases, pointer gestures, the keyboard — and two toolbar components; `DirectorPanel` (897) is 230 over the transport view, time zoom, show editing, the file helpers and two bars. Before either was touched, `check:panels` (Vitest + jsdom + Testing Library, `web/test/setup.ts` stubs the canvas) pinned 23 behaviours — pen, eraser, fill, shapes, select/lift/nudge/commit/cancel, undo/redo by key and button, tool keys, the picker, clear, copy-as-code, the import modal; keyframes by double-click, snap grids, the selection editor, messages, length/loop/title, .pfs/.mid export, Space — and they pass unchanged after. It runs in `check:ci`. +- **Each lab panel owns its stylesheet.** `PatternLab.module.css` was 1,618 lines shared by eleven files; the rules only one panel draws with moved beside that panel (`panels/.module.css`), 33 rules nothing used since the v1 lab went, and the shared sheet is what two files or more need. The lab's one door to the community is `pattern-lab/community.ts`, and ESLint keeps it the only one. +- **Community queries, one file per domain.** `server/queries.ts` (1,364 lines) is a barrel over `server/queries/{patterns,decks,workshop,moderation,users,notifications,shared}.ts`; bodies unchanged, importers unchanged. +- **The audio-in console page proves it was rebaked.** `build_audio_in_page.py --check` runs in CI on every change to the extension's editor or the console, so a stale bake fails there instead of shipping. +- **The Wi-Fi setup portal is a record.** The parked branch is written up in `docs/investigations/`, named in `docs/EDITIONS.md` as belonging to no edition and not to be merged as it stands, and pushed so the code is kept. +- **The case folder says what each file is.** The Bambu Studio project sits with the 256 mm kit it slices, the v1-era laser-cut drawings are `legacy_lasercut/`, and the README names both. +- **Pattern Lab panels are a registry.** `panels/registry.tsx` is the one list — id, title, component, where it docks by default — and the shell derives the component map, the Panels menu and the default layout from it; the Pixel, Director, Graphic Export and Gallery editors load on demand instead of riding in the route's first chunk. Everything the lab keeps in `localStorage` is listed in `lib/lab/persist.ts`, which every persistence site now goes through. The pixel editor's undo stacks are dropped when their layer is deleted; they used to live for the tab. +- **The Home Assistant integration has left this repository.** It was a community contribution, and its author now maintains it in a repository of their own; the component (`custom_components/`, `hacs.json`), its tests and scripts (`integrations/homeassistant/`), the dashboard card's source (`web/src/ha-card/`) and the three workflows that built, validated and released it are gone from here. The device's HTTP API it was built against is unchanged (`docs/rest-api.md`). +- **Housekeeping (2026-09-03).** Removed the frozen `firmware/patternflow_stream/` sketch, `firmware/tools/` (a `.pfv` uploader for a Video pattern that no longer exists), retired toolchain scripts, the pre-`features/` agent skills under `.agents/`, dead web components, and every flasher image folder the manifest no longer serves (older images stay on their release tags). Documentation that still described the pre-editions tree — `firmware/README.md`'s layout, library list, OSC default and LED calibration values, `AGENTS.md`'s version and hard rules, `hardware/pcb/README.md`'s USB-C wording — was brought in line with the code. diff --git a/docs/releases/v3.9.1.md b/docs/releases/v3.9.1.md new file mode 100644 index 00000000..bd76ae67 --- /dev/null +++ b/docs/releases/v3.9.1.md @@ -0,0 +1,9 @@ +## [3.9.1] - 2026-09-03 + +### Added — core +- **The network runs on its own core.** Wi-Fi maintenance, the console's HTTP server, Improv-Serial and OTA moved to a FreeRTOS task pinned to Core 0 (`src/core_net_task.h`); `loop()` on Core 1 is input, features, draw and present, and nothing else. A Wi-Fi reconnect or a page load no longer costs the render a frame. Handlers that touch what the frame is using — evicting or restoring the resident module, walking the pattern list, the show engine, the MQTT client, weather's config — are handed to the loop task and run at the frame boundary (`src/core_loop_sync.h`); the rule for feature authors is in `FEATURE_GUIDE.md`. `/api/status` reports `httpCore`, `netStackMin`, `loopSyncServed` and `loopSyncMaxUs`. If the task cannot be created, `loop()` serves the network as it did before. +- **Live knobs on the console home page.** Four sliders under *Now playing* write the absolute bus (`POST /api/params`), one request in flight at a time with the newest value queued behind it. A legacy pattern that reads only `knobDeltas` follows too: the device turns the change in a held value into clicks, so the sliders, OSC and a show all move such a pattern the same way. `d1`..`d4` on `/api/params` add relative clicks for a client that has only those. +- **Stepping through patterns from the home page.** `←` / `→` beside the pattern name call `GET /api/patterns/select?step=+1|-1`, which skips hidden entries and wraps. + +### Added — Audio edition v0.5.1 +- **Audio-React on the console.** The `/audio-in` page (nav label now *Audio*) gains an *Audio-React (AUD)* toggle — the switch that lets the browser extension and the phone capture drive the knobs, which until now lived only on the panel's NETWORK screen — backed by `GET`/`POST /api/audio` (`on=0/1`, persisted), and a link to the extension's guide. diff --git a/docs/releases/v3.9.2.md b/docs/releases/v3.9.2.md new file mode 100644 index 00000000..eb14bd66 --- /dev/null +++ b/docs/releases/v3.9.2.md @@ -0,0 +1,9 @@ +## [3.9.2] - 2026-09-03 + +### Changed — core +- **Pushing a frame to the panel costs 7.0 ms, down from 9.9.** The whole-frame blit in the vendored HUB75 driver used to extract each colour plane's bit from six CIE values with six masked tests per plane — thirty-six per pixel pair, and that loop was 60% of every frame. Two 256-entry tables now spread a channel's plane bits across 6-bit slots, so every plane's word comes from one OR of six reads and each plane is a shift and a mask. Bit-exact with the loop it replaces (the DMA words are identical; 6.2 million plane words were checked), so nothing about the picture changes — only the time. Origin runs at 13.8 ms a frame where it ran at 16.6; a heavy pattern like `0510` drops from 24.8 to 21.8. Every pattern gets it, installed modules included. +- **`PFColor::hsvToRgb` no longer calls `fmodf`.** The sector and its fraction come from one truncation. Mathematically the same colour (a last-bit rounding difference that never reaches an 8-bit channel), but `fmodf` in a module is a call into the host's libm on every pixel; `0510` rebuilt against the new header renders 1.0 ms faster a frame. Reaches a module when it is rebuilt — the site's build service and the toolchain pick it up now; the modules already on a panel, and the prebuilt ones in the shipped Basics pack, keep the code they were built with. +- `PFNoise::perlin2D` takes one floor per axis instead of two. The other two of a four-point list this cycle was handed — a joint `fastSinCos` and a bit-trick `fastHypot` — were declined: the compiler already inlines `sqrtf` and `floorf` to single FPU instructions here, and the trig lookup saves a few cycles nobody would measure. + +### Editions +- Audio v0.5.2 and Performance v0.2.2 are the same firmware on the new core — the network task and the faster frame push — with no feature changes of their own. diff --git a/docs/releases/v3.9.3.md b/docs/releases/v3.9.3.md new file mode 100644 index 00000000..0f2dca81 --- /dev/null +++ b/docs/releases/v3.9.3.md @@ -0,0 +1,10 @@ +## [3.9.3] - 2026-09-03 + +### Changed — core +- **Rebuilding the pattern list no longer stops the render for two seconds.** After an install batch, and on deleting the module that is drawing, the registry rescanned `/patterns` and opened every module's sidecar twice; with forty modules that was 2.4 s with the frame held (3.9.1's `loopSyncMaxUs` put the number on it). The sidecar's two facts are now cached by path and forgotten only by the writer that changed them (upload, delete, format, library pull), and the scan reads the directory with `readdir` instead of opening every entry to build a `File`. Deleting the drawing module answers in 0.29 s where it took 2.4; the render pauses 55 ms where it paused 2,160. Boot still reads every sidecar, once. +- **`PFMath::jsMod` — JavaScript's `%` without `fmodf`.** A pattern ported from the lab reached for `fmodf` on every pixel for a hue wrap like `(h + 0.33) % 1`, and in a module that is a call into the host's libm. `jsMod(x, m)` is one division and one truncation with JS's sign rule; the 22 preset twins that used `fmodf` (52 calls) now use it, and the lab's C++ prompt says to. `0510` rebuilt: 20.8 → 17.9 ms a frame. +- **The frame push writes two columns at a time.** Adjacent columns share one aligned 32-bit word in every plane, so the blit does one read-modify-write per plane for two pixel pairs instead of two; the CIE table it sums on-time from lives in DRAM, and the function in IRAM. Bit-exact, like 3.9.2's tables. 7.0 → 6.9 ms; the DMA engine's own reads of that memory are what remains. + +### Added +- **Firmware compiled in CI.** `.github/workflows/firmware-build.yml` runs `firmware/bundles/build.sh all` — default, audio, performance and the marker scan that proves each image carries exactly its features — on every pull request that touches `firmware/`, with the PlatformIO packages cached. Until now the checks in CI were greps; the compiler ran at a desk. +- **Edition write-ups are releases too.** The firmware-release workflow no longer fails on a tag that is not a core `vX.Y.Z`; an edition's own release (`audio-v0.5.3`) attaches its image by hand from the shelf. The first one is the Audio edition's. diff --git a/docs/releases/v3.9.4.md b/docs/releases/v3.9.4.md new file mode 100644 index 00000000..e8249de7 --- /dev/null +++ b/docs/releases/v3.9.4.md @@ -0,0 +1,10 @@ +## [3.9.4] - 2026-09-04 + +### Changed — web +- **One pattern runtime.** The community's sandbox page (`public/pattern-sandbox.html`, the XSS boundary every preview and thumbnail renders inside) was a hand-kept plain-JS copy of the lab's runtime, with "keep the two in sync" notes on both sides. It is now built from the lab's own code: `scripts/build-sandbox.ts` bundles `src/sandbox/sandbox.ts` — only the canvas, the live loop and the postMessage protocol — with `lib/pattern/harness.ts`, `ramp.ts` and `matrix.ts`, and `check:sandbox-sync` (in `check:ci`) fails when the committed page is not that build. Two drifts came out in the wash: the sandbox turned a knob 20 detents a revolution where the lab and the encoder turn 24, and a pattern that threw reported its message without the line the lab quotes. The default ramp for a `setValue` pattern with no `@ramp` line is kept; every other output was compared pixel for pixel against the old page before it was replaced. + +### Docs +- `docs/SERVICES.md` records the community host as it is: the build worker's systemd sandbox (the drop-in, the three writable paths, what it does not cover — files the worker's own user can read still reach a compile error, and the two steps that close that), and the host update procedure. + +### Added +- **A release is two commands.** `python firmware/toolchain/release.py cut vX.Y.Z [--audio vA] [--performance vB]` bumps every version site, dates the changelog, runs `shelf.sh` for each image, points the flasher manifest and the `/editions` cards at them, runs the web checks, commits and tags; `release.py publish vX.Y.Z --notes FILE` pushes, opens and merges the dev → main pull request, and creates the GitHub release with the edition images attached. `check_versions.py` (in CI) reads the six files a version lives in and the shelf, and fails on any disagreement. `docs/RELEASING.md` keeps the checklist as the description of what the two commands do. diff --git a/docs/releases/v3.9.5.md b/docs/releases/v3.9.5.md new file mode 100644 index 00000000..525d3588 --- /dev/null +++ b/docs/releases/v3.9.5.md @@ -0,0 +1,27 @@ +## [3.9.5] - 2026-09-06 + +### Added +- **Clock edition** (`firmware/bundles/clock/`, v0.1.0) — a panel that tells the time, and carries nothing else. Cut for a request from outside, and shelved on `/editions` under Community rather than Official: good fun, and not what Patternflow is about — Official is the maintainer's own list of what is. +- **`features/clock/`** — the hours and minutes cut out of whatever pattern is running: huge anti-aliased digits, hours over minutes on an upright panel, four across on a wide one. Inside the digits the pattern or a solid colour; outside them the pattern dimmed to taste or a solid colour; between the rows an optional bar (across, the face's own colon), cut from the pattern exactly as the digits are; the gap adjustable; the minute change crossfaded. So one layout is a cut-out, a stencil, solid digits over the pattern, or a plain clock on a coloured ground. Seven faces to choose from — Bebas Neue, Anton, Oswald, Saira Extra Condensed, Barlow Condensed, Six Caps, Squada One, all OFL — rasterised offline into 4-bit alpha cells by `toolchain/build_clock_glyphs.py`, which is also where a face of your own goes (a TTF and one line). Everything is blended into the frame on its way to the panel rather than stamped over it afterwards. `/clock` sets it up with a live preview that draws the same glyphs the panel does and sends every change as it is made — one-tap presets for the four fills, the faces as chips drawing their own digits, and the browser's own colour picker beside the swatches; `GET`/`POST /api/clock` and `GET /clock/glyphs.bin` are the contract (`docs/rest-api.md` 1.3). The time zone is a POSIX TZ string with its DST rule — the weather page's UTC offset cannot say when summer time starts — and the clock owns it: whatever else sets `TZ` is put back within a second. On first boot the offset a panel had already given the weather page is read, never written, as the starting zone. Nothing is drawn until NTP has answered once. Orientation counts quarter turns from the wide way; 1, upright, is the default. +- Core: `PatternflowClock::beginSyncTz()` and `assertTz()` in `src/core_clock.h`, the POSIX-zone entry points beside the minutes-offset one; and **`composeFrame`**, a new hook at the tail of `PFFeature` — the finished canvas on its way to the panel, before the blit, so a feature can blend with real alpha or mask the whole frame. `PFCanvas::present()` asks one function pointer, the sketch wires it to the dispatcher, and the canvas itself is never written (patterns that accumulate across frames own it). +- The tooling knows the fourth composition: `build.sh all` builds and scans `clock` (the feature's marker is `[CLOCK] /clock ready`), `release.py cut --clock vX`, `check_versions.py`, the console page list and the fake device (`/api/clock`, `/clock/glyphs.bin`). + +### Added — panel +- **Browsing with K4 no longer loads every pattern it passes.** A module's cost on the way in is its own `setup()` — 10 ms for a light one, 2.3 s for *Branched flow*, 2.9 s for *Two-stream* — and SELECT mode paid it on every detent: the loop stood still for that long while the encoder kept counting, and the accumulated detents landed all at once when it came back, a panel that froze for two seconds and then jumped five patterns. The knob now moves only the highlight, three detents per pattern (one per detent overshot); the pattern loads once the knob has rested for a third of a second, and until then the panel shows the pattern's **thumbnail** — the 128×64 frame it last drew, taken off the canvas when a pattern is left after running for two seconds, kept in PSRAM and written beside the module as `/patterns/.thumb` (16 KB each; `src/core_thumbs.h`). Captured before gamma and white balance and painted back through the same `present()`, so the picture is what the pattern looked like. A pattern nobody has played yet shows only its name until it has; the first slow pass through the list builds the set. Deleting a module deletes its picture, formatting the volume forgets them all, and `GET /api/patterns/file?ext=thumb` hands one to a console or a site. The load itself runs on a task on Core 0 (`activatePatternAsync`): the frame never stops and the knob keeps answering while a heavy `setup()` runs, the panel shows the thumbnail until the pattern is ready, and where the knob rests last is what loads next. The same path serves switches from the console, OSC, MIDI and a show; `/api/status` says `loading` while one is on its way, and uploads and deletes wait for a load in flight before evicting. + +### Fixed — panel +- **The UPDATE card no longer tears against the pattern while an image is received.** Since the upload handler moved to the network core (3.9.1) its progress callback kept drawing the card and flipping the panel from there, while `loop()` went on drawing the pattern — two tasks flipping one DMA buffer for the length of the upload. The callback now only reports the percentage; `loop()` shows the card, as it does every other one. +- **The name that appears when a pattern is chosen from the console reads upright.** It was drawn along the panel's long axis — sideways on a panel stood on its end — and is now portrait like the SELECT screen, wrapped the same way for long names. + +### Fixed — core +- **The console survives a slow Wi-Fi link.** A panel whose link was moving 2–5 KB/s (round trips of ~0.4 s with retransmissions; a healthy panel on the same network does 3–60 ms) got every console page cut short: the page sender's hard 5-second budget — written for the days when a send ran inside `loop()` and stalled the render — broke off with the header already promising the full `Content-Length`, so `/` arrived as 10–25 KB of 26 KB and the 63 KB audio page never at all, and the browser showed a page whose script ends mid-word. Since 3.9.1 the sender runs on the network core and the render never sees it, so the budget was only ever hurting. It now gives up only when the peer stops accepting bytes for ~20 s (or after a two-minute ceiling), and when it does it closes the connection instead of leaving a short body looking complete; on the single-core fallback the 5 s cap stays. Measured with a client holding a 1 KB window: 17.5 KB delivered whole in 22 s where it used to be cut at 5; a client that reads nothing is dropped at 20 s and the next one is served in 30 ms. +- **Console pages are sent gzip-compressed** — `console_pages.py build` lays a gzip twin under every page literal (and under `pf-console.js` and the unzip library), the handlers send that with `Content-Encoding: gzip`, and `check` proves each twin still decompresses to its page in CI. A third of the bytes cross the air (26.5 → 8.6 KB for `/`, 63 → 17.6 KB for `/audio-in`), which on a bad link is a third of the time; the raw literals stay in the headers for the tooling and drop out of the image, which shrinks by ~140 KB. +- **The network core no longer reboots on a request that stalls.** The vendored `WebServer` read request lines, headers and raw upload bodies through Arduino's `Stream::timedRead()`, a spin loop that never yields. Harmless while the server ran on Core 1; since 3.9.1 it runs on Core 0, whose idle task feeds a 5-second watchdog that panics — so a request that paused for five seconds mid-header, or a `.pfm` upload trickling in over a slow link, took the whole board down. Reproduced on a bench panel (`Task watchdog got triggered: IDLE0`, reset, back in ten seconds) and gone after: the parser now waits with `delay(1)` between bytes, bounded by the same timeouts as before (`src/webserver/VENDORED.md`, fix 2). +- **A big pattern can no longer take the console down with it.** The module loader placed a module's data sections in internal RAM whenever they fit, and internal RAM is what Wi-Fi, lwIP and every HTTP connection are made of. A community pattern whose tables came to 46 KB left 2.7 KB free after boot: the panel drew on, every connection died at the SYN, and nothing anywhere said why. Data sections over 16 KB, or any that would leave less than 24 KB of internal heap, now go to PSRAM first (`PF_MODULE_DATA_INTERNAL_MAX`, `PF_MODULE_INTERNAL_RESERVE`); the same pattern now leaves 42 KB free with 3.9 KB of it internal, and the console answers in 50 ms while it plays. + +### Changed — features +- **MIDI and OSC report hands, not lanes, and MIDI knob output is paced.** On the Audio edition the microphone moves the knobs through lanes, and by the time the frame reached the MIDI and OSC features that motion looked like an encoder — so a quiet room's noise floor went out as 26–43 control changes a second (two datagrams per knob per frame over OSC, once a host is known), filled the Wi-Fi driver's eight transmit buffers, and starved the console's own traffic behind a wall of `could not send data: 12` on the serial log. Both features now skip a knob a lane is moving (`PF_MIDI_OUT_LANE_MOTION` / `PF_OSC_OUT_LANE_MOTION` restore the old behaviour), and MIDI sends at most one CC per knob per 50 ms, trailing-edge, so the last value always lands (`docs/midi-spec.md` 1.1, `docs/osc-spec.md` 1.1). + +### Added — API +- **A brightness slider on the console home page**, under the panel switch — the value K1's long-press session sets, written over `GET /api/display?brightness=5..255` (applied through the power clamp on the next frame, saved to NVS once it has been still for 3 s) and reported in `/api/status` as `brightness`, so the slider follows the knob and the knob follows the slider. +- `GET /api/status` gains `resetReason` (`"poweron"`, `"sw"`, `"panic"`, `"task_wdt"`, `"brownout"`…) next to `uptime` — two reboots went unnoticed in one afternoon because from the network they look exactly like a power cut — and `load.internal` / `load.psram`, where the resident module's sections landed. The same reset reason is printed once at boot. `docs/rest-api.md` 1.4. diff --git a/docs/rest-api-vectors/README.md b/docs/rest-api-vectors/README.md new file mode 100644 index 00000000..d9ee0f45 --- /dev/null +++ b/docs/rest-api-vectors/README.md @@ -0,0 +1,9 @@ +# REST vectors + +Each file here is one sentence of [rest-api.md](../rest-api.md) and the requests that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:rest-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A file's name starts with the part of the spec it reads. `state-` is `GET /api/status`, `POST /api/params` and `POST /api/sleep`. `device-` is the rest of device state (`/api/display`, `/api/knobs`, `DELETE /api/crash`) and what every route shares, such as the plain-text 404 and `no-store`. `patterns-` is the pattern list, select and the module files. `absent-` is a route a build leaves out because its feature is not in it. The vectors for a route only a feature serves start with that feature's directory in `firmware/patternflow/features/`, as `mqtt-` does for `/api/mqtt`, and name the feature in `features`. + +Some of the spec has no vectors. The virtual panel has no radio and no flash volume, so Wi-Fi and the hotspot, firmware updates, and installing, deleting or formatting modules are not replayed. It has no microphone and does not run the clock or shows, so `/api/audio-in`, `/api/clock` and `/api/shows` are not replayed either. The console's pages are for a person and have no vectors. + +Two firmware rules are not in rest-api.md, and the virtual panel keeps both. A hand does not take back a channel that `POST /api/params` wrote less than 250 ms before (`ABSOLUTE_RELEASE_GRACE_MS` in `core_bus.h`). A sleeping panel ignores its knobs and buttons for the first 800 ms (`WAKE_GUARD_MS` in `core_sleep.h`). A vector that puts a hand on the panel after either one waits it out: `state-params-hand.json` holds the channel a second before the turn, and `state-sleep-wake.json` lets the panel sleep a second before waking it. diff --git a/docs/rest-api-vectors/absent-caps.json b/docs/rest-api-vectors/absent-caps.json new file mode 100644 index 00000000..3cc4f5d5 --- /dev/null +++ b/docs/rest-api-vectors/absent-caps.json @@ -0,0 +1,6 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "The default build reports `[\"patterns\",\"params\",\"sleep\"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc`, `audio`, `audio-in` and `midi`, Performance adds `clock`, `weather`, `mqtt` and `shows`.", + "features": [], + "steps": [{ "send": { "http": "GET /api/status" }, "expect": { "reply": { "status": 200, "body": { "caps": ["patterns", "params", "sleep"] } } } }] +} diff --git a/docs/rest-api-vectors/absent-feature-nav.json b/docs/rest-api-vectors/absent-feature-nav.json new file mode 100644 index 00000000..50a50e9e --- /dev/null +++ b/docs/rest-api-vectors/absent-feature-nav.json @@ -0,0 +1,6 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "What the console header and home screen build their feature links from; empty on the default build.", + "features": [], + "steps": [{ "send": { "http": "GET /api/status" }, "expect": { "reply": { "status": 200, "body": { "featureNav": [] } } } }] +} diff --git a/docs/rest-api-vectors/absent-routes.json b/docs/rest-api-vectors/absent-routes.json new file mode 100644 index 00000000..ab55dec3 --- /dev/null +++ b/docs/rest-api-vectors/absent-routes.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#conventions-for-future-additions", + "says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "POST /api/midi?outMode=rel" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "POST /api/audio?on=1" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/audio-in" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/clock" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/shows" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } } + ] +} diff --git a/docs/rest-api-vectors/audio-caps.json b/docs/rest-api-vectors/audio-caps.json new file mode 100644 index 00000000..2d0ac072 --- /dev/null +++ b/docs/rest-api-vectors/audio-caps.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#conventions-for-future-additions", + "says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.", + "features": ["audio"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "caps": ["patterns", "params", "sleep", "audio"] } } } }, + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "status": 200, "headers": { "content-type": "application/json" } } } } + ] +} diff --git a/docs/rest-api-vectors/audio-get.json b/docs/rest-api-vectors/audio-get.json new file mode 100644 index 00000000..84e71a92 --- /dev/null +++ b/docs/rest-api-vectors/audio-get.json @@ -0,0 +1,8 @@ +{ + "contract": "docs/rest-api.md#audio-react-audio-edition", + "says": "`audioRuntime` (the switch) and `audioClients` (WebSocket senders connected on :81).", + "features": ["audio"], + "steps": [ + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "status": 200, "body": { "audioRuntime": "", "audioClients": 0 } } } } + ] +} diff --git a/docs/rest-api-vectors/audio-post.json b/docs/rest-api-vectors/audio-post.json new file mode 100644 index 00000000..80bbec28 --- /dev/null +++ b/docs/rest-api-vectors/audio-post.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#audio-react-audio-edition", + "says": "`on=0/1`. Persisted in NVS; answers with the new state.", + "features": ["audio"], + "steps": [ + { "send": { "http": "POST /api/audio?on=0" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "audioRuntime": false } } } }, + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "body": { "audioRuntime": false } } } }, + { "send": { "http": { "request": "POST /api/audio", "form": "on=1" } }, "expect": { "reply": { "status": 200, "body": { "ok": true, "audioRuntime": true } } } }, + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "body": { "audioRuntime": true } } } } + ] +} diff --git a/docs/rest-api-vectors/device-compressed.json b/docs/rest-api-vectors/device-compressed.json new file mode 100644 index 00000000..c9d82972 --- /dev/null +++ b/docs/rest-api-vectors/device-compressed.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Nothing under `/api/` is compressed.", + "features": [], + "steps": [ + { "send": { "http": { "request": "GET /api/status", "headers": { "Accept-Encoding": "gzip, deflate" } } }, "expect": { "reply": { "status": 200, "headers": { "content-encoding": "" } } } }, + { "send": { "http": { "request": "GET /api/patterns", "headers": { "Accept-Encoding": "gzip, deflate" } } }, "expect": { "reply": { "status": 200, "headers": { "content-encoding": "" } } } }, + { "send": { "http": { "request": "GET /api/patterns/file?slug=wave_saw", "headers": { "Accept-Encoding": "gzip, deflate" } } }, "expect": { "reply": { "status": 200, "headers": { "content-encoding": "" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-cors.json b/docs/rest-api-vectors/device-cors.json new file mode 100644 index 00000000..16254fc1 --- /dev/null +++ b/docs/rest-api-vectors/device-cors.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Only `/api/patterns/select` and `/api/display` send `Access-Control-Allow-Origin: *`.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/display" }, "expect": { "reply": { "status": 200, "headers": { "access-control-allow-origin": "*" } } } }, + { "send": { "http": "GET /api/patterns/select?index=0" }, "expect": { "reply": { "status": 200, "headers": { "access-control-allow-origin": "*" } } } }, + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "GET /api/knobs" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "POST /api/params?p1=500" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "GET /api/patterns/file?slug=wave_saw&ext=json" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "GET /api/nothing" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-crash.json b/docs/rest-api-vectors/device-crash.json new file mode 100644 index 00000000..5f0039c9 --- /dev/null +++ b/docs/rest-api-vectors/device-crash.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#delete-apicrash", + "says": "`{\"ok\":true}`, or `404` with `{\"ok\":false,\"error\":\"no crash recorded\"}` when there was nothing to clear.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "crash": "" } } } }, + { "send": { "http": "DELETE /api/crash" }, "expect": { "reply": { "status": 404, "body": { "ok": false, "error": "no crash recorded" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-display-brightness.json b/docs/rest-api-vectors/device-display-brightness.json new file mode 100644 index 00000000..7de00efc --- /dev/null +++ b/docs/rest-api-vectors/device-display-brightness.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "K1's long-press session and `GET /api/display?brightness=` move the same value; what the power clamp lets through is `power_applied` on `/api/display`.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/display?brightness=77" }, "expect": { "reply": { "body": { "brightness": 77 } }, "status": { "brightness": 77 } } }, + { "send": { "http": "GET /api/display?brightness=255" }, "expect": { "reply": { "body": { "brightness": 255 } }, "status": { "brightness": 255 } } } + ] +} diff --git a/docs/rest-api-vectors/device-display-read-only.json b/docs/rest-api-vectors/device-display-read-only.json new file mode 100644 index 00000000..8e95976e --- /dev/null +++ b/docs/rest-api-vectors/device-display-read-only.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#get-apidisplay", + "says": "Read-only in the response: `calib`, `sleep`, `power_ma`, `power_demand`, `power_limiting`, `power_applied`.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/display?sleep=1" }, "expect": { "reply": { "status": 200, "body": { "sleep": "" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } } + ] +} diff --git a/docs/rest-api-vectors/device-display-state.json b/docs/rest-api-vectors/device-display-state.json new file mode 100644 index 00000000..f4d58076 --- /dev/null +++ b/docs/rest-api-vectors/device-display-state.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#get-apidisplay", + "says": "Any subset of these may be set in the query string; the response always reports the full state afterwards.", + "features": [], + "steps": [ + { + "send": { "http": "GET /api/display?sat=2&gamma_g=2.5" }, + "expect": { "reply": { "status": 200, "body": { + "wb_r": "", "wb_g": "", "wb_b": "", "gamma_r": "", "gamma_g": 2.5, "gamma_b": "", "sat": 2, + "brightness": "", "screen": "", "level": "", "power_budget": "", "power_limit": "", + "calib": "", "sleep": "", "power_ma": "", "power_demand": "", "power_limiting": "", "power_applied": "" } } } + }, + { "send": { "http": "GET /api/display?brightness=128" }, "expect": { "reply": { "body": { "gamma_g": 2.5, "sat": 2, "brightness": 128 } } } } + ] +} diff --git a/docs/rest-api-vectors/device-favicon.json b/docs/rest-api-vectors/device-favicon.json new file mode 100644 index 00000000..0b7d2e50 --- /dev/null +++ b/docs/rest-api-vectors/device-favicon.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "`GET /favicon.ico` answers `204`, with `Cache-Control: public, max-age=31536000, immutable` on a panel `Host` and `no-store` on any other (see Host gating); pages carry their icon inline.", + "features": [], + "steps": [ + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.local" } } }, "expect": { "reply": { "status": 204, "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.fritz.box" } } }, "expect": { "reply": { "status": 204, "headers": { "cache-control": "no-store" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-host.json b/docs/rest-api-vectors/device-host.json new file mode 100644 index 00000000..1d61cd37 --- /dev/null +++ b/docs/rest-api-vectors/device-host.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Pages, the chrome and the favicon are cacheable only when the request's `Host` can only be the panel: an IPv4 address, a name with no dot (`patternflow`) or a `.local` name, with or without a port.", + "features": [], + "steps": [ + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "192.168.1.42" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "192.168.1.42:80" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow:8080" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow-a1b2.local" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.local:80" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.fritz.box" } } }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.work:80" } } }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-knobs-get.json b/docs/rest-api-vectors/device-knobs-get.json new file mode 100644 index 00000000..a59190ae --- /dev/null +++ b/docs/rest-api-vectors/device-knobs-get.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#get-apiknobs-and-post-apiknobs", + "says": "`GET` returns `inv` (four booleans: reversed or not), `sub` (edges per click per knob: 4, 2 or 1 — most encoders are 4), `clicks` (each knob's click count as the frame sees it) and `raw` (the edge counters underneath, for telling a 2-edge part from a 4-edge one).", + "features": [], + "steps": [ + { "send": { "hand": { "knob": 1, "turn": 2 } } }, + { "send": { "hand": { "knob": 3, "turn": -1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "knobs": [2, 0, -1, 0] } } }, + { + "send": { "http": "GET /api/knobs" }, + "expect": { "reply": { "status": 200, "body": { + "inv": ["", "", "", ""], "sub": ["", "", "", ""], + "clicks": [2, 0, -1, 0], "raw": ["", "", "", ""] } } } + } + ] +} diff --git a/docs/rest-api-vectors/device-knobs-post.json b/docs/rest-api-vectors/device-knobs-post.json new file mode 100644 index 00000000..8a8a8686 --- /dev/null +++ b/docs/rest-api-vectors/device-knobs-post.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#get-apiknobs-and-post-apiknobs", + "says": "`POST` takes any subset of `inv0`..`inv3` (`0`/`1`) and `sub0`..`sub3` (`4`/`2`/`1`), or `inv=` / `sub=` for all four, form-encoded; a change is applied at a frame boundary without moving the knob, stored on the device (NVS `pf_knobs`) and answered as `GET`.", + "features": [], + "steps": [ + { "send": { "hand": { "knob": 2, "turn": 3 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "knobs": [0, 3, 0, 0] } } }, + { "send": { "http": { "request": "POST /api/knobs", "form": "inv=0&sub=4" } }, "expect": { "reply": { "body": { "inv": [false, false, false, false], "sub": [4, 4, 4, 4], "clicks": [0, 3, 0, 0], "raw": "" } } } }, + { "send": { "http": { "request": "POST /api/knobs", "form": "inv1=1&sub2=2" } }, "expect": { "reply": { "body": { "inv": [false, true, false, false], "sub": [4, 4, 2, 4], "clicks": [0, 3, 0, 0], "raw": "" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "knobs": [0, 3, 0, 0] } } }, + { "send": { "hand": { "knob": 2, "turn": 1 } } }, + { "send": { "hand": { "knob": 3, "turn": 1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "knobs": [0, 2, 2, 0] } } } + ] +} diff --git a/docs/rest-api-vectors/device-no-store.json b/docs/rest-api-vectors/device-no-store.json new file mode 100644 index 00000000..76618bc4 --- /dev/null +++ b/docs/rest-api-vectors/device-no-store.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Every JSON endpoint sends `Cache-Control: no-store`, and so does the plain-text `404`.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/display" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/knobs" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": { "request": "POST /api/knobs", "form": "inv=0" } }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "POST /api/params?p1=500" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "POST /api/sleep?on=0" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "DELETE /api/crash" }, "expect": { "reply": { "status": 404, "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/patterns/select?index=0" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/patterns/select?index=-1" }, "expect": { "reply": { "status": 404, "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/patterns/file?slug=wave_saw&ext=json" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/nothing" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain", "cache-control": "no-store" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-removed.json b/docs/rest-api-vectors/device-removed.json new file mode 100644 index 00000000..5bc888d4 --- /dev/null +++ b/docs/rest-api-vectors/device-removed.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#knobs-and-parameters", + "says": "`/api/knob` and `/remote` existed and were removed as unused alongside the `/api/frame` incident; do not look for them.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/knob" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "POST /api/knob?knob=1&delta=1" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /remote" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/frame" }, "expect": { "reply": { "status": 404 } } } + ] +} diff --git a/docs/rest-api-vectors/midi-caps.json b/docs/rest-api-vectors/midi-caps.json new file mode 100644 index 00000000..3809b9f2 --- /dev/null +++ b/docs/rest-api-vectors/midi-caps.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#conventions-for-future-additions", + "says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.", + "features": ["midi"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "caps": ["patterns", "params", "sleep", "midi"] } } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "status": 200, "headers": { "content-type": "application/json" } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-get.json b/docs/rest-api-vectors/midi-get.json new file mode 100644 index 00000000..85f5214f --- /dev/null +++ b/docs/rest-api-vectors/midi-get.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "channel, `outDiv`, `outMul`, `outMode`, `host`, session state, counters", + "features": ["midi"], + "steps": [ + { + "send": { "http": "GET /api/midi" }, + "expect": { + "reply": { + "status": 200, + "body": { + "channel": 1, "outDiv": [1, 1, 1, 1], "outMul": ["", "", "", ""], "outMode": "abs", "host": "", + "rtpPeers": "", "rtpPeer": "", "rx": "", "tx": "" + } + } + } + } + ] +} diff --git a/docs/rest-api-vectors/midi-host.json b/docs/rest-api-vectors/midi-host.json new file mode 100644 index 00000000..a2b1de4c --- /dev/null +++ b/docs/rest-api-vectors/midi-host.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "Empty string clears it.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?host=192.168.0.23" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "host": "192.168.0.23" } }, "status": { "midi": { "host": "192.168.0.23" } } } }, + { "send": { "http": { "request": "POST /api/midi", "form": "host=" } }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "host": "" } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-knob.json b/docs/rest-api-vectors/midi-knob.json new file mode 100644 index 00000000..11545bf9 --- /dev/null +++ b/docs/rest-api-vectors/midi-knob.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "Apply `outMul`/`outDiv` to one knob; without it, to all four.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outDiv=4&knob=3" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "POST /api/midi?outMul=2&knob=2" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [1, 1, 4, 1], "outMul": [1, 2, 1, 1] } } } }, + { "send": { "http": "POST /api/midi?outDiv=2" } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [2, 2, 2, 2], "outMul": [1, 1, 1, 1] } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-out-div.json b/docs/rest-api-vectors/midi-out-div.json new file mode 100644 index 00000000..8963ab1f --- /dev/null +++ b/docs/rest-api-vectors/midi-out-div.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "outbound sensitivity: detents per outbound step, `1..16`, persisted on the panel.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outDiv=16" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [16, 16, 16, 16] } }, "status": { "midi": { "outDiv": [16, 16, 16, 16] } } } }, + { "send": { "http": "POST /api/midi?outDiv=1" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [1, 1, 1, 1] } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-out-mode.json b/docs/rest-api-vectors/midi-out-mode.json new file mode 100644 index 00000000..467a3081 --- /dev/null +++ b/docs/rest-api-vectors/midi-out-mode.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "knobs out as a virtual position (default) or as relative steps.", + "features": ["midi"], + "steps": [ + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outMode": "abs" } } } }, + { "send": { "http": "POST /api/midi?outMode=rel" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outMode": "rel" } }, "status": { "midi": { "outMode": "rel" } } } }, + { "send": { "http": { "request": "POST /api/midi", "form": "outMode=abs" } }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outMode": "abs" } }, "status": { "midi": { "outMode": "abs" } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-out-mul.json b/docs/rest-api-vectors/midi-out-mul.json new file mode 100644 index 00000000..b2e21522 --- /dev/null +++ b/docs/rest-api-vectors/midi-out-mul.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "Setting one of `outMul`/`outDiv` above 1 resets the other to 1", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outDiv=4" } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [4, 4, 4, 4] } } } }, + { "send": { "http": "POST /api/midi?outMul=8" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [1, 1, 1, 1], "outMul": [8, 8, 8, 8] } } } }, + { "send": { "http": "POST /api/midi?outDiv=2" } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [2, 2, 2, 2], "outMul": [1, 1, 1, 1] } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-rx-detail.json b/docs/rest-api-vectors/midi-rx-detail.json new file mode 100644 index 00000000..835d68aa --- /dev/null +++ b/docs/rest-api-vectors/midi-rx-detail.json @@ -0,0 +1,37 @@ +{ + "contract": "docs/midi-spec.md#status", + "says": "Unreleased 1.2 adds `rxDetail` to both `midi` in `/api/status` and the root of `GET /api/midi`.", + "features": ["midi"], + "steps": [ + { + "send": { "http": "GET /api/status" }, + "expect": { + "reply": { + "body": { + "midi": { + "rxDetail": { + "worker": "", "abs": ["", "", "", ""], "rel": ["", "", "", ""], + "queueDropped": "", "txQueueDropped": "", "packetGaps": "", "parseErrors": "", "bufferOverflows": "", + "pollMaxGapUs": "", "pollMaxUs": "", "queueMaxAgeUs": "", "stackMin": "" + } + } + } + } + } + }, + { + "send": { "http": "GET /api/midi" }, + "expect": { + "reply": { + "body": { + "rxDetail": { + "worker": "", "abs": ["", "", "", ""], "rel": ["", "", "", ""], + "queueDropped": "", "txQueueDropped": "", "packetGaps": "", "parseErrors": "", "bufferOverflows": "", + "pollMaxGapUs": "", "pollMaxUs": "", "queueMaxAgeUs": "", "stackMin": "" + } + } + } + } + } + ] +} diff --git a/docs/rest-api-vectors/midi-status.json b/docs/rest-api-vectors/midi-status.json new file mode 100644 index 00000000..5037a54a --- /dev/null +++ b/docs/rest-api-vectors/midi-status.json @@ -0,0 +1,24 @@ +{ + "contract": "docs/midi-spec.md#status", + "says": "`GET /api/status` on a build with this feature lists `\"midi\"` in `caps` and carries", + "features": ["midi"], + "steps": [ + { + "send": { "http": "GET /api/status" }, + "expect": { + "reply": { + "status": 200, + "body": { + "caps": ["patterns", "params", "sleep", "midi"], + "midi": { + "runtime": "", "channel": "", "outMode": "", "host": "", + "outDiv": ["", "", "", ""], + "outMul": ["", "", "", ""], + "rtpPeers": "", "rtpPeer": "", "rx": "", "tx": "" + } + } + } + } + } + ] +} diff --git a/docs/rest-api-vectors/mqtt-caps.json b/docs/rest-api-vectors/mqtt-caps.json new file mode 100644 index 00000000..1fd06945 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-caps.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#conventions-for-future-additions", + "says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "caps": ["patterns", "params", "sleep", "mqtt"] } } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "status": 200, "headers": { "content-type": "application/json" }, "body": { "ok": true } } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-fields.json b/docs/rest-api-vectors/mqtt-fields.json new file mode 100644 index 00000000..5fa3568c --- /dev/null +++ b/docs/rest-api-vectors/mqtt-fields.json @@ -0,0 +1,26 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "Also reported: `normalHost`, `normalPort`, `normalUser`, `normalPrefix`, `normalHasPassword` (the saved Normal-mode broker, kept while Director mode overlays it) and `directorHost`.", + "features": ["mqtt"], + "steps": [ + { + "send": { "http": "GET /api/mqtt" }, + "expect": { + "reply": { + "status": 200, + "body": { + "ok": true, "role": "", "channel": "", "state": "", + "host": "", "port": "", "user": "", "prefix": "", + "pattern": "", "error": "", "mode": "", + "connected": "", "configured": "", "hasPassword": "", "forcesSub": "", + "knobs": ["", "", "", ""], + "params": ["", "", "", ""], + "paramActive": ["", "", "", ""], + "normalHost": "", "normalPort": "", "normalUser": "", "normalPrefix": "", + "normalHasPassword": "", "directorHost": "" + } + } + } + } + ] +} diff --git a/docs/rest-api-vectors/mqtt-forces-sub.json b/docs/rest-api-vectors/mqtt-forces-sub.json new file mode 100644 index 00000000..0820ee27 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-forces-sub.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "The selected channel (1–4) pins the role to Subscriber", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow5&role=publisher" }, "expect": { "reply": { "body": { "channel": "live", "role": "publisher", "forcesSub": false } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow3" }, "expect": { "reply": { "body": { "channel": "ch3", "role": "subscriber", "forcesSub": true } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "reply": { "body": { "channel": "ch3", "role": "subscriber", "forcesSub": true } } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "channel": "ch3", "role": "subscriber", "forcesSub": true } } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-knobs.json b/docs/rest-api-vectors/mqtt-knobs.json new file mode 100644 index 00000000..072f5e18 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-knobs.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "Absolute accumulated click counts, signed, unbounded. Live in every role.", + "features": ["mqtt"], + "steps": [ + { "send": { "hand": { "knob": 1, "turn": 3 } } }, + { "send": { "hand": { "knob": 1, "turn": 2 } } }, + { "send": { "hand": { "knob": 2, "turn": -4 } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "role": "off", "knobs": [5, -4, 0, 0] } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "hand": { "knob": 4, "turn": 30 } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "role": "publisher", "knobs": [5, -4, 0, 30] } } } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "hand": { "knob": 2, "turn": -1 } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "role": "subscriber", "knobs": [5, -5, 0, 30] } } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-password.json b/docs/rest-api-vectors/mqtt-password.json new file mode 100644 index 00000000..e0f15339 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-password.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "Passwords are never returned — only whether one is set.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "hasPassword": false, "normalHasPassword": false } } } }, + { + "send": { "http": { "request": "POST /api/mqtt", "form": "host=broker.lan&user=ha&password=hunter2" } }, + "expect": { "reply": { "status": 200, "body": { "host": "broker.lan", "user": "ha", "hasPassword": true, "normalHasPassword": true, "password": "", "pass": "" } } } + }, + { + "send": { "http": "GET /api/mqtt" }, + "expect": { "reply": { "body": { "host": "broker.lan", "user": "ha", "hasPassword": true, "normalHasPassword": true, "password": "", "pass": "" } } } + } + ] +} diff --git a/docs/rest-api-vectors/mqtt-role.json b/docs/rest-api-vectors/mqtt-role.json new file mode 100644 index 00000000..1de3b29c --- /dev/null +++ b/docs/rest-api-vectors/mqtt-role.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "`\"off\"`, `\"publisher\"` or `\"subscriber\"`.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "mqttRole": "off", "mqttState": "", "mqttConnected": false } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "role": "publisher" } }, "status": { "mqttRole": "publisher" } } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "role": "subscriber" } }, "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?role=off" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "role": "off" } }, "status": { "mqttRole": "off" } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-same-fields.json b/docs/rest-api-vectors/mqtt-same-fields.json new file mode 100644 index 00000000..9ed8dcb4 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-same-fields.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/rest-api.md#knobs-and-parameters", + "says": "`GET /api/mqtt` reports the same three fields and has since before `/api/status` did — but it lives in the MQTT addon, so a build without MQTT does not serve it.", + "features": ["mqtt"], + "steps": [ + { "send": { "hand": { "knob": 3, "turn": 2 } } }, + { "send": { "http": "POST /api/params?p2=800" } }, + { + "send": { "http": "GET /api/mqtt" }, + "expect": { "reply": { "body": { "knobs": [0, 0, 2, 0], "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } } + }, + { + "send": { "http": "GET /api/status" }, + "expect": { "reply": { "body": { "knobs": [0, 0, 2, 0], "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } } + } + ] +} diff --git a/docs/rest-api-vectors/patterns-file-404.json b/docs/rest-api-vectors/patterns-file-404.json new file mode 100644 index 00000000..5ab7153c --- /dev/null +++ b/docs/rest-api-vectors/patterns-file-404.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "`404` if the file is not there, `409` if storage is not mounted.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/file?slug=no_such_module" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/file?slug=no_such_module&ext=json" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/file?slug=no_such_module&ext=thumb" }, "expect": { "reply": { "status": 404 } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-file-download.json b/docs/rest-api-vectors/patterns-file-download.json new file mode 100644 index 00000000..9a6617a7 --- /dev/null +++ b/docs/rest-api-vectors/patterns-file-download.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "This is a file download (`Content-Disposition: attachment`), one file at a time — the server is single-connection.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/file?slug=wave_saw" }, "expect": { "reply": { "status": 200, "headers": { "content-disposition": "" } } } }, + { "send": { "http": "GET /api/patterns/file?slug=wave_saw&ext=json" }, "expect": { "reply": { "status": 200, "headers": { "content-disposition": "" } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-file-preset.json b/docs/rest-api-vectors/patterns-file-preset.json new file mode 100644 index 00000000..18777bd6 --- /dev/null +++ b/docs/rest-api-vectors/patterns-file-preset.json @@ -0,0 +1,6 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "Presets have no sidecar.", + "features": [], + "steps": [{ "send": { "http": "GET /api/patterns/file?slug=origin&ext=json" }, "expect": { "reply": { "status": 404 } } }] +} diff --git a/docs/rest-api-vectors/patterns-file-sidecar.json b/docs/rest-api-vectors/patterns-file-sidecar.json new file mode 100644 index 00000000..210c1886 --- /dev/null +++ b/docs/rest-api-vectors/patterns-file-sidecar.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "`ext=json` returns the module's **sidecar** — the only place per-pattern metadata is exposed over HTTP:", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/file?slug=wave_saw&ext=json" }, "expect": { "reply": { "status": 200, "body": { + "name": "", "abi": "", "knobs": ["", "", "", ""], "slug": "wave_saw" } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-list.json b/docs/rest-api-vectors/patterns-list.json new file mode 100644 index 00000000..41e80de7 --- /dev/null +++ b/docs/rest-api-vectors/patterns-list.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#get-apipatterns", + "says": "`active` is an index into `patterns`, or `-1` while nothing is loaded.", + "features": [], + "steps": [ + { + "send": { "http": "GET /api/patterns" }, + "expect": { "reply": { "status": 200, "body": { + "active": "", "presets": 1, "mounted": true, "free": "", "patterns": "", "pendingRev": "", "pending": "" } } } + }, + { "send": { "http": "GET /api/patterns/select?index=2" } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 2 } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-select-404.json b/docs/rest-api-vectors/patterns-select-404.json new file mode 100644 index 00000000..5512e049 --- /dev/null +++ b/docs/rest-api-vectors/patterns-select-404.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsselect", + "says": "`404` for an out-of-range index or an unknown name.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=0" } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?index=-1" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/select?index=9999" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/select?name=No%20such%20pattern" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Origin" } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-select-name.json b/docs/rest-api-vectors/patterns-select-name.json new file mode 100644 index 00000000..3dcbe1a1 --- /dev/null +++ b/docs/rest-api-vectors/patterns-select-name.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsselect", + "says": "Display name, exact match. Only if you have no index.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "activeIsModule": true } } }, + { "send": { "http": "GET /api/patterns/select?name=origin" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/select?name=Origin" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "index": 0, "name": "Origin" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Origin" } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-select-step.json b/docs/rest-api-vectors/patterns-select-step.json new file mode 100644 index 00000000..6c104f82 --- /dev/null +++ b/docs/rest-api-vectors/patterns-select-step.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsselect", + "says": "`+1` or `-1`: the next or previous pattern that is not hidden, wrapping, from wherever the panel is now.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=0" } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?step=%2B1" }, "expect": { "reply": { "status": 200, "body": { "index": 1 } } } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?step=-1" }, "expect": { "reply": { "status": 200, "body": { "index": 0 } } } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?step=-1" }, "expect": { "reply": { "status": 200, "body": { "index": "" } } } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?step=%2B1" }, "expect": { "reply": { "status": 200, "body": { "index": 0 } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-select.json b/docs/rest-api-vectors/patterns-select.json new file mode 100644 index 00000000..0f147d12 --- /dev/null +++ b/docs/rest-api-vectors/patterns-select.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsselect", + "says": "Queued and applied in `loop()`, for the same reason as sleep: activating a module reads FATFS and runs the relocator.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=0" } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Origin", "activeIsModule": false } } }, + { "send": { "http": "GET /api/patterns/select?index=1" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "index": 1, "name": "" } } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 0 } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "activeIsModule": true } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 1 } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-thumb.json b/docs/rest-api-vectors/patterns-thumb.json new file mode 100644 index 00000000..ab8dd6f2 --- /dev/null +++ b/docs/rest-api-vectors/patterns-thumb.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "`404` until the pattern has been played once; presets (`origin`) get one the same way.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=0" } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/file?slug=origin&ext=thumb" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "wait": 3000 } }, + { "send": { "http": "GET /api/patterns/select?index=1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "activeIsModule": true } } }, + { "send": { "http": "GET /api/patterns/file?slug=origin&ext=thumb" }, "expect": { "reply": { "status": 200 } } } + ] +} diff --git a/docs/rest-api-vectors/state-params-clicks.json b/docs/rest-api-vectors/state-params-clicks.json new file mode 100644 index 00000000..ef7fd256 --- /dev/null +++ b/docs/rest-api-vectors/state-params-clicks.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/rest-api.md#post-apiparams", + "says": "A legacy pattern — one that reads `knobDeltas` and nothing else — follows a held value too: the device turns the change in it into clicks, ten bus units per click, so `p1=750` moves such a pattern the same way the encoder would.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/params?p1=500" } }, + { "send": { "http": "POST /api/params?p1=750" } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [25, 0, 0, 0] } } } } + ] +} diff --git a/docs/rest-api-vectors/state-params-hand.json b/docs/rest-api-vectors/state-params-hand.json new file mode 100644 index 00000000..0c64b3ab --- /dev/null +++ b/docs/rest-api-vectors/state-params-hand.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/rest-api.md#post-apiparams", + "says": "A channel is **held** once written and released the moment somebody turns that knob, so an automated source can pin a look and hands always win it back.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/params?p2=300" }, "expect": { "reply": { "body": { "ok": true, "active": [false, true, false, false] } } } }, + { "send": { "wait": 1000 }, "expect": { "status": { "params": ["", 300, "", ""], "paramActive": [false, true, false, false] } } }, + { "send": { "hand": { "knob": 1, "turn": 1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, true, false, false] } } }, + { "send": { "hand": { "knob": 2, "turn": -1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } } + ] +} diff --git a/docs/rest-api-vectors/state-params-subset.json b/docs/rest-api-vectors/state-params-subset.json new file mode 100644 index 00000000..88c3abff --- /dev/null +++ b/docs/rest-api-vectors/state-params-subset.json @@ -0,0 +1,24 @@ +{ + "contract": "docs/rest-api.md#post-apiparams", + "says": "`p1`..`p4`, each `0..1000`, any subset. A request needs at least one `pN` or `dN`.", + "features": [], + "steps": [ + { + "send": { "http": { "request": "POST /api/params", "form": "p1=750&p3=250" } }, + "expect": { + "reply": { "status": 200, "body": { "ok": true, "params": [750, "", 250, ""], "active": [true, false, true, false] } }, + "status": { "params": [750, "", 250, ""], "paramActive": [true, false, true, false] } + } + }, + { "send": { "http": "POST /api/params" }, "expect": { "reply": { "body": { "ok": false, "error": "" } } } }, + { "send": { "http": "POST /api/params?d2=1" }, "expect": { "reply": { "status": 200, "body": { "ok": true } } } }, + { + "send": { "http": "POST /api/params?p2=1001" }, + "expect": { "reply": { "body": { "ok": false, "error": "" } }, "status": { "paramActive": [true, false, true, false] } } + }, + { + "send": { "http": "POST /api/params?p2=1000&p4=0" }, + "expect": { "reply": { "status": 200, "body": { "ok": true, "params": [750, 1000, 250, 0], "active": [true, true, true, true] } } } + } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-400.json b/docs/rest-api-vectors/state-sleep-400.json new file mode 100644 index 00000000..f4876ca5 --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-400.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "`400` with `{\"ok\":false,\"error\":\"on must be 1, 0, or toggle\"}` for anything else.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep?on=maybe" }, "expect": { "reply": { "status": 400, "body": { "ok": false, "error": "on must be 1, 0, or toggle" } } } }, + { "send": { "http": "POST /api/sleep?on=2" }, "expect": { "reply": { "status": 400, "body": { "ok": false, "error": "on must be 1, 0, or toggle" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-before.json b/docs/rest-api-vectors/state-sleep-before.json new file mode 100644 index 00000000..65f6e997 --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-before.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "`sleep` is the state **before** the transition — the request is only queued here.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep?on=1" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=0" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-form.json b/docs/rest-api-vectors/state-sleep-form.json new file mode 100644 index 00000000..56452b01 --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-form.json @@ -0,0 +1,21 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Request parameters are **query-string or form-encoded, never a JSON body** — `POST /api/sleep?on=1` and a form body with `on=1` are the same call.", + "features": [], + "steps": [ + { + "send": { "http": { "request": "POST /api/sleep", "form": "on=1" } }, + "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": true, "sleep": false } } } + }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { + "send": { "http": { "request": "POST /api/sleep", "form": "on=0" } }, + "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": false, "sleep": true } } } + }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { + "send": { "http": { "request": "POST /api/sleep", "form": "{\"on\":\"0\"}", "headers": { "Content-Type": "application/json" } } }, + "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": true, "sleep": false } } } + } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-on.json b/docs/rest-api-vectors/state-sleep-on.json new file mode 100644 index 00000000..05679d59 --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-on.json @@ -0,0 +1,25 @@ +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "`1` / `true` / `sleep` · `0` / `false` / `wake` · `toggle`. Default `1` when omitted.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep?on=true" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=0" }, "expect": { "reply": { "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep?on=sleep" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=false" }, "expect": { "reply": { "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep?on=1" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=wake" }, "expect": { "reply": { "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep?on=toggle" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=toggle" }, "expect": { "reply": { "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-wake.json b/docs/rest-api-vectors/state-sleep-wake.json new file mode 100644 index 00000000..1d30f9f5 --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-wake.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "Any physical knob turn or button press wakes the device, as does an incoming firmware image.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep" }, "expect": { "reply": { "body": { "ok": true, "requested": true } } } }, + { "send": { "wait": 1000 }, "expect": { "status": { "sleep": true } } }, + { "send": { "hand": { "knob": 3, "turn": 1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep" } }, + { "send": { "wait": 1000 }, "expect": { "status": { "sleep": true } } }, + { "send": { "hand": { "knob": 2, "down": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "hand": { "knob": 2, "down": false } } } + ] +} diff --git a/docs/rest-api-vectors/state-status.json b/docs/rest-api-vectors/state-status.json new file mode 100644 index 00000000..5abea2fc --- /dev/null +++ b/docs/rest-api-vectors/state-status.json @@ -0,0 +1,52 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "The numbers that explain a device when something is off.", + "features": [], + "steps": [ + { + "send": { "http": "GET /api/status" }, + "expect": { + "reply": { + "status": 200, + "body": { + "version": "", "build": "", "uptime": "", "resetReason": "", "panel": "", + "wifi": "", "ssid": "", "ip": "", "rssi": "", + "host": "", "hostAlias": "", "viaHotspot": "", + "network": { "disconnects": "", "retries": "", "reconnectMs": "", "namesReady": "", "announcements": "" }, + "hotspot": { "mode": "", "up": "", "ssid": "", "ip": "", "channel": "", "clients": "", "dns": "" }, + "heapInternal": "", "heapLargest": "", "heapPsram": "", + "fsMounted": "", "fsTotal": "", "fsUsed": "", "fsError": "", "flashId": "", + "patterns": "", "presets": "", "modules": "", + "active": "", "activeIsModule": "", "loading": "", "loadError": "", + "sleep": "", "brightness": "", + "knobs": ["", "", "", ""], + "params": ["", "", "", ""], + "paramActive": ["", "", "", ""], + "lanes": ["", "", "", ""], + "laneActive": ["", "", "", ""], + "consolePaused": "", "busy": "", + "frameUs": "", "presentUs": "", + "runtime": { "loops": "", "lastUs": "", "maxUs": "", "over50ms": "", "housekeepingMaxUs": "", "syncMaxUs": "" }, + "moduleMemory": { + "reserve": "", "serviceFree": "", "execLargest": "", "runtimeBytes": "", "runtimePeakBytes": "", + "runtimeLimit": "", "refusals": "", "loaderRetries": "", "loaderStackBytes": "", "loaderStackMin": "", + "codePolicy": "", + "resident": { "count": "", "bytes": "", "slots": "", "resumes": "", "evictions": "" } + }, + "netMaintenance": { "calls": "", "maxGapMs": "" }, + "loopCore": "", "httpCore": "", "loopStackMin": "", "netStackMin": "", + "loopSyncServed": "", "loopSyncMaxUs": "", + "loopAgeMs": "", "loopStalled": "", "loopSyncGaveUp": "", + "colorBits": "", "refreshHz": "", + "thumbs": { "captures": "", "reads": "", "writes": "", "captureMaxUs": "", "ioMaxUs": "" }, + "load": { + "total": "", "read": "", "relocate": "", "setup": "", "internal": "", "psram": "", + "code": "", "resumed": "", "resumeUs": "" + }, + "variant": "", "caps": "", "featureNav": "" + } + } + } + } + ] +} diff --git a/docs/rest-api.md b/docs/rest-api.md index 9aafd145..9c2bc8f7 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -35,7 +35,7 @@ Three properties of this server are not obvious from the endpoint list, and each Two things stayed true. **Installing patterns still evicts the running module for the duration of the upload batch** — the panel shows PAUSED and restores itself — which is what `status.consolePaused` reports now; treat it as "an install is in progress", not "somebody looked at the console". And a page load still momentarily competes with the render loop on this one-connection server, so sending a person to `http://patternflow.local/` is fine and expected; **polling HTML pages remains a bug** — automated clients keep to `/api/*`. -**Some writes report the state before they take effect.** Stopping a DMA engine, reclocking the CPU, reading FATFS and running the ELF relocator are not things a handler does inside an open HTTP transaction — the firmware queues them and `loop()` performs them. `POST /api/sleep` and `GET /api/patterns/select` therefore return the *previous* state. Set your own state optimistically and let the next poll confirm it; do not parse the reply's state field as the new value. (Since 3.9.1 the same rule holds from the other side: handlers run on the network core, and the few that must touch what the frame is using are executed on the loop task at the frame boundary — `loopSyncServed` in status counts them.) +**Some writes report the state before they take effect.** Stopping a DMA engine, reclocking the CPU, reading FATFS and running the ELF relocator are not things a handler does inside an open HTTP transaction — the firmware queues them and `loop()` performs them. `POST /api/sleep` therefore returns the *previous* state. `GET /api/patterns/select` answers with the pattern it queued, and `/api/patterns` and `/api/status` name the old one until the next frame. Set your own state optimistically and let the next poll confirm it; do not parse the reply's state field as the new value. (Since 3.9.1 the same rule holds from the other side: handlers run on the network core, and the few that must touch what the frame is using are executed on the loop task at the frame boundary — `loopSyncServed` in status counts them.) ## Device state @@ -107,7 +107,7 @@ The numbers that explain a device when something is off. Requires `PF_STATUS_HTT | `thumbs` | Since 1.5: boot counters `captures` (cache refreshed), `reads` (disk image adopted), `writes` (save completed). `captureMaxUs` is the maximum loop-side capture duration; `ioMaxUs` is the maximum background file-operation wall time, including scheduling/filesystem contention. Both are µs. The cache and its temporary I/O snapshot use PSRAM only. | | `load` | The last module load, in µs: `total`, `read` (flash), `relocate`, `setup` (the pattern's own). Since 1.4 also where its sections landed, in bytes: `internal` and `psram`. A big `internal` is the number to look at when the console dies the moment a pattern is chosen — the loader now sends data sections over 16 KB, or any that would leave under 24 KB of internal heap, to PSRAM (`PF_MODULE_DATA_INTERNAL_MAX`, `PF_MODULE_INTERNAL_RESERVE`). Since 1.5 `code` says where the module's code runs from, `"psram"` or `"internal"`: in PSRAM it is counted in `psram` and takes nothing from the internal heap, and `"internal"` on a module means PSRAM could not hold it or `moduleMemory.codePolicy` is `0` - which it becomes, until reboot, if code placed in PSRAM ever fails to read back on this unit. Since 1.5 data sections go to PSRAM first as well, whatever their size, so `internal` is `0` on a module that loaded normally. Since 1.5 `load` describes the module on the panel: `resumed` is `true` when it came back from residency rather than from a load - the timings are then its own, from when it first loaded, and nothing was read or set up this time - and `resumeUs` is what coming back took; `false` and `0` after a fresh load. With no module on the panel (a preset, or a load that failed) `internal` and `psram` are `0`, `code` reads `"internal"`, `resumed` is `false` and `resumeUs` `0`; the timings are left as the last module had them - except after a failed load, where `read`, and `relocate` if the load got that far, describe the attempt that failed. | | `variant` | Which firmware this is: `"core"`, or a variant's own name. What the site's variant list matches, and what stops the update banner offering a core build on top of someone's chosen firmware. | -| `caps` | What this build can do. **Probe this rather than assuming a feature exists.** The default build reports `["patterns","params","sleep"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc` and `audio`, Performance adds `weather`, `mqtt` and `shows`. `patterns` and `params` are on every build. | +| `caps` | What this build can do. **Probe this rather than assuming a feature exists.** The default build reports `["patterns","params","sleep"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc`, `audio`, `audio-in` and `midi`, Performance adds `clock`, `weather`, `mqtt` and `shows`. `patterns` and `params` are on every build. | | `mqttRole` | `"off"`, `"publisher"` or `"subscriber"`. Decides whether the device obeys knob and pattern topics — see [Knobs](#knobs-and-parameters). | | `featureNav` | `[path, label, one-line description]` per console page the loaded features serve, e.g. `[["/audio-in","Audio","The panel hears the room…"]]`. What the console header and home screen build their feature links from; empty on the default build. | @@ -162,7 +162,8 @@ Erases the core dump and drops the record; `crash` is absent from the next statu Write the absolute parameter bus — the four channels a pattern reads as set-points beside the physical encoders. -`p1`..`p4`, each `0..1000`, any subset; at least one required. A channel is +`p1`..`p4`, each `0..1000`, any subset. A request needs at least one `pN` +or `dN`. A channel is **held** once written and released the moment somebody turns that knob, so an automated source can pin a look and hands always win it back. Patterns see it only if they declare `ABSOLUTE_READY` and were built at module ABI 2. @@ -592,8 +593,8 @@ A client that has to key on something stable should use the hostname and require | | HTTP | OSC | MIDI | MQTT | |---|---|---|---|---| | Read device state | **yes**, everything | announce burst + heartbeat | pattern changes only (Program Change) | knob / pattern / sleep topics, in Publisher role | -| Read knob positions | **yes**, `/api/mqtt`, any role | on change | on change, relative (CC 24–27) | in Publisher role | -| Write knobs | **no** | **yes**, deltas | **yes**, absolute (CC 20–23) and deltas (CC 24–27) | **yes**, Subscriber role only | +| Read knob positions | **yes**, `/api/status` | on change | on change, relative (CC 24–27) | in Publisher role | +| Write knobs | **yes**, absolute (`POST /api/params`) and deltas (`d1`..`d4`) | **yes**, deltas | **yes**, absolute (CC 20–23) and deltas (CC 24–27) | **yes**, Subscriber role only | | Switch pattern | **yes** | yes | yes, Program Change | Subscriber role only | | Sleep / wake | **yes** | no | no | **yes**, either role | | Install / delete patterns | **yes** | no | no | no | @@ -601,13 +602,13 @@ A client that has to key on something stable should use the hostname and require | Latency | poll-bound, seconds | sub-frame | sub-frame | broker-bound | | Needs infrastructure | nothing | nothing | an RTP-MIDI driver on the host (built into macOS/iOS; rtpMIDI on Windows) | a broker | -In short: HTTP is the management and state transport, OSC and MIDI are the low-latency performance transports (MIDI is the one a DAW already speaks, and the only one with absolute knob values — [`midi-spec.md`](midi-spec.md)), MQTT is the one that reaches into home automation and mirrors one panel onto another. A home-automation client wants HTTP for everything except knob writes, and MQTT for those. +In short: HTTP is the management and state transport, OSC and MIDI are the low-latency performance transports (MIDI is the one a DAW already speaks, and of the two the one with absolute knob values — [`midi-spec.md`](midi-spec.md)), MQTT is the one that reaches into home automation and mirrors one panel onto another. A home-automation client can do all of it over HTTP, knob writes included. ## Conventions for future additions - JSON lives under `/api/`; anything else on this server is a page for a person. - Parameters are query-string or form-encoded. There is no JSON request body anywhere, and adding one would need a body parser the firmware does not have. -- Errors are `{"ok":false,"error":""}` with a `4xx`/`5xx` status. Success bodies vary; the ones that report a mutation start with `"ok":true`. +- Errors are `{"ok":false,"error":""}` with a `4xx`/`5xx` status. A path no route serves is the one exception: its `404` is plain text. Success bodies vary; the ones that report a mutation start with `"ok":true`. - A route whose handler has to run on the loop task answers `503 {"ok":false,"error":"render loop is not answering"}` when the loop has stopped coming round (`loopStalled` in status) — the pattern list, select, install, delete, format, the knob settings, and a feature's routes that reconfigure what its loop hook is ticking. Nothing was changed. Retrying helps only once `loopStalled` reads `false` again, which a pattern stuck in `draw()` never lets it: that panel has to be restarted (`POST /api/wifi/reboot` still answers). A request made in the first twenty seconds after the loop stops waits out the rest of them for that reply; later ones get it at once. A new handler that uses `PFLoopSync::run()` answers a `false` the same way, with `PatternflowHttp::sendLoopStalled()`. - A handler must never touch FATFS, the ELF loader, the DMA engine or the CPU clock. Queue the work and let `loop()` do it; the reply then reports the pre-transition state, which callers already expect. - A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404. Core endpoints are gated by a core `PF_*_ENABLED` flag and must leave the sketch compiling at `0`. @@ -615,7 +616,7 @@ In short: HTTP is the management and state transport, OSC and MIDI are the low-l ## Version history -- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted); `PUT /update` takes a raw image, and an upload survives a stall of up to two minutes; `GET /api/wifi` gains `join` (what became of the last network asked for) and `GET /api/hotspot` gains `seen` (the names in range); a network added on the hotspot with no station link is tried at once; console pages are cached by build (`?v=` immutable, bare URLs `no-cache` with an `ETag` and `304`, another build's `v` redirected with `302`), the chrome by its CRC (`?h=`), only for a `Host` that can only be the panel; `GET /favicon.ico` answers `204`; status gains `build`, `viaHotspot` and `busy`; `load` gains `code` (where the module's code runs from); pattern names in `GET /api/patterns` and `/api/patterns/select` are JSON-escaped, so a quote or backslash in a title no longer makes the reply unparseable; a multipart request that stops mid-form is dropped - the connection is closed with no reply - instead of spinning the server into its watchdog, and leaves none of its fields behind for later requests; `moduleMemory` gains `codePolicy`; status gains `loopStackMin`; the hotspot password is JSON-escaped in `/api/hotspot`; a `POST` that is not multipart to `/api/patterns` or `/update` is answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a raw `PUT` sees its own query string; status gains `crash` (the core dump's summary, and the pattern and phase the reset interrupted) and `DELETE /api/crash` clears it; status gains `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, and a route that needs the render loop answers `503` (`render loop is not answering`) when a pattern never returns from `draw()`, where it used to take the whole server down with it; a module that has run stays loaded when another pattern takes over and resumes when picked again - `load` gains `resumed` and `resumeUs`, `moduleMemory` gains `resident`, and a module's data sections go to PSRAM first; `POST /api/audio-in` applies a band when any of its suffixed fields is present (it took one of five before, and answered `ok` to the rest without changing anything), and refuses a bad `band` before applying anything, with `ok:false` in the body; `POST /api/audio-in/reset` takes `band=N` and names the band in its answer, and a full reset now includes `attack`; `hzRange` is printed to two decimals; a microphone that failed to start or stopped answering (`source` beginning `synth`) no longer drives the knobs from the test signal; `/api/audio-in` ignores a value that is not a finite number (`nan`, `inf`) where it used to store it and print `nan` back, which no JSON parser reads, and a band whose low edge is in the top analysis bin reads 0 instead of putting `nan` into the poll and into `audioIn` in status; the microphone section documents what was already there - `smoothing`, `attack`, `meta`, `lutSet`, `lut`, the suffixed field names, `env`, `ext`, the `frame` post, `synth (mic stalled)`, that `spec` has 64 buckets, not 40, and that a body sent without the form `Content-Type` is not read as arguments. +- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted); `PUT /update` takes a raw image, and an upload survives a stall of up to two minutes; `GET /api/wifi` gains `join` (what became of the last network asked for) and `GET /api/hotspot` gains `seen` (the names in range); a network added on the hotspot with no station link is tried at once; console pages are cached by build (`?v=` immutable, bare URLs `no-cache` with an `ETag` and `304`, another build's `v` redirected with `302`), the chrome by its CRC (`?h=`), only for a `Host` that can only be the panel; `GET /favicon.ico` answers `204`; status gains `build`, `viaHotspot` and `busy`; `load` gains `code` (where the module's code runs from); pattern names in `GET /api/patterns` and `/api/patterns/select` are JSON-escaped, so a quote or backslash in a title no longer makes the reply unparseable; a multipart request that stops mid-form is dropped - the connection is closed with no reply - instead of spinning the server into its watchdog, and leaves none of its fields behind for later requests; `moduleMemory` gains `codePolicy`; status gains `loopStackMin`; the hotspot password is JSON-escaped in `/api/hotspot`; a `POST` that is not multipart to `/api/patterns` or `/update` is answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a raw `PUT` sees its own query string; status gains `crash` (the core dump's summary, and the pattern and phase the reset interrupted) and `DELETE /api/crash` clears it; status gains `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, and a route that needs the render loop answers `503` (`render loop is not answering`) when a pattern never returns from `draw()`, where it used to take the whole server down with it; a module that has run stays loaded when another pattern takes over and resumes when picked again - `load` gains `resumed` and `resumeUs`, `moduleMemory` gains `resident`, and a module's data sections go to PSRAM first; `POST /api/audio-in` applies a band when any of its suffixed fields is present (it took one of five before, and answered `ok` to the rest without changing anything), and refuses a bad `band` before applying anything, with `ok:false` in the body; `POST /api/audio-in/reset` takes `band=N` and names the band in its answer, and a full reset now includes `attack`; `hzRange` is printed to two decimals; a microphone that failed to start or stopped answering (`source` beginning `synth`) no longer drives the knobs from the test signal; `/api/audio-in` ignores a value that is not a finite number (`nan`, `inf`) where it used to store it and print `nan` back, which no JSON parser reads, and a band whose low edge is in the top analysis bin reads 0 instead of putting `nan` into the poll and into `audioIn` in status; the microphone section documents what was already there - `smoothing`, `attack`, `meta`, `lutSet`, `lut`, the suffixed field names, `env`, `ext`, the `frame` post, `synth (mic stalled)`, that `spec` has 64 buckets, not 40, and that a body sent without the form `Content-Type` is not read as arguments; where this file said two things, the route's own section now stands: select answers with the pattern it queued, HTTP writes knobs and reads them from `/api/status`, `d1`..`d4` alone are a whole `POST /api/params`, `caps` names what each edition adds, and only a path no route serves gets a plain-text `404`. - **1.4** (2026-09-06) — `GET /api/patterns/file` gains `ext=thumb`; `GET /api/display` takes `brightness` and status reports it; status gains `resetReason` and `load.internal`/`load.psram`; console pages are served gzip-compressed (`Content-Encoding: gzip`); the page sender no longer truncates on a slow link; the server no longer trips the Core-0 watchdog on a request that stalls mid-header. - **1.3** (2026-09-04) — `GET`/`POST /api/clock` (Utility edition) and the `clock` block in status; `caps` gains `"clock"`. - **1.2** (2026-09-03) — the server is serviced on Core 0 (the one-connection rule stands; the render-pays rule is history); status gains `httpCore`, `netStackMin`, `loopSyncServed`/`loopSyncMaxUs`; `POST /api/params` documents `d1`..`d4` and how a held value reaches a legacy pattern; `GET /api/patterns/select` gains `step`; `GET`/`POST /api/audio` (Audio-React) are documented; `featureNav`'s microphone label is *Audio*. diff --git a/docs/rfc-any-resolution.md b/docs/rfc-any-resolution.md new file mode 100644 index 00000000..69453e13 --- /dev/null +++ b/docs/rfc-any-resolution.md @@ -0,0 +1,298 @@ +# RFC: any resolution, the firmware path + +*A panel of another size without a rebuild. Written 2026-10-15.* + +> **Status, 15 October 2026.** Draft. Nothing in it is built. It moves to +> proposed when the cleanup's gate holds +> ([rfc-cleanup.md §2.12](rfc-cleanup.md#212-order-and-what-done-means)). +> Facts are from `dev` at `adbe923`. What a step plan has not merged yet is +> named by its plan and its k. + +This document is two things on purpose. **Part 1 is the maintainer, in his +own words.** **Part 2 is the specification** — precise and mechanical, +written so a contributor (or an AI agent handed this file) can implement +against it without asking what was meant. + +--- + +# Part 1 — Why + +--- + +# Part 2 — The specification + +## 2.1 What changes and what stays + +One ESP32-S3 drives one chain of classic HUB75 modules, as it does today. What +changes is when the geometry is chosen. Today another panel means another +build of `config.h`; here the device reads it from NVS at boot, and a console +page sets it. With nothing saved, the device runs `config.h`'s geometry, one +128×64 module, and draws the same frames. A pattern still declares its own grid +with `setFrame` on the device and `// @matrix` on the site, by the rule the two +share (`core_canvas.h:137-163`, `matrix.ts:127`). + +## 2.2 Where the size lives + +[The panel geometry record](investigations/2026-10-panel-geometry.md) lists +every place with the command behind each count. Of the 36 occurrences of +`PANEL_RES_W` and `PANEL_RES_H` that §2.7 counts: + +| where | uses | after this RFC | +| --- | --- | --- | +| `src/core_canvas.h:38-39` | 2 | read the geometry; the buffer is allocated (§2.3) | +| `src/core_module_loader.h:601-602, 1145` | 4 | read the geometry; `hostAPI` is filled at boot | +| `src/core_status_http.h:282, 284` | 2 | read the geometry | +| `src/core_tables.h:52, 57, 70-76` | 9 | read the geometry | +| `features/clock/core_clock_http.h:116, 118` | 2 | read the geometry | +| `features/weather/preset_weather.h:492-493` | 2 | stay: a layout drawn at the default size | +| `abi/pf_module.h:47, 71-72, 175-176, 281`, `abi/pf_abi.h:116` | 7, two comments | stay: a module's size is its build's | +| `src/core_display.h:18`, `src/core_mem.h:17`, `src/core_tables.h:11, 24-26` | 8, comment | reworded where they call the macro the panel | + +Nineteen read the geometry. Nine stay compile-time, because in the firmware +the macro is now the default and in a module it is the size the module was +built for (`pf_module.h:71-72`). + +## 2.3 The runtime geometry + +`src/core_geometry.h` holds one value, read once at boot: + +| field | compile-time default | NVS key, namespace `pf_panel` | +| --- | --- | --- | +| module width and height | `PANEL_PHYS_W`, `PANEL_PHYS_H` | `mw`, `mh` | +| chain | `PANEL_CHAIN` | `chain` | +| driver profile | `PANEL_PROFILE` | `prof` | +| canvas width and height | module width × chain, module height | none | + +The canvas follows the rule the two `#error`s hold today (`config.h:110-115`). +`PFGeometry::load()` runs before `initDisplay()` (`patternflow.ino:482`), and +a missing key or a value outside §2.8's bound gives the default. `initDisplay()` +passes the module size, the chain (`core_display.h:20`) and the driver the +profile names (`core_display.h:25`). + +What stays compile-time. Both ABI structs carry `panel_w` and `panel_h` +(`pf_abi.h:82-83`, `117-118`), frozen at version 1, and a module takes its size +from its build. The presets that size arrays by the macro (`preset_0629_2.h`, +`preset_0713.h`) are modules too: only Origin and the calibration card compile +in (`pattern_registry.h:44-48`), and the other 33 reach a device in +`basics.zip`. Those two and weather's compact +layout draw at the macro's size, and the core calls `setFrame(PANEL_RES_W, +PANEL_RES_H)` before they draw. It centres them on another panel and is the +straight path on the default one (`core_canvas.h:142`). `env:firmware64x2` +keeps working, as a build whose default is the chain. + +What becomes an allocation. The canvas, a global of W × H × 3 bytes +(`core_canvas.h:40-42`, 24,576 at 128×64), is allocated at boot in internal +RAM, where the blit reads it today. `PFThumbs::W` and `H` +(`core_thumbs.h:44-47`) become values, and a thumbnail of another size is +already refused and taken again (`core_thumbs.h:149-152`). + +Touches `src/core_geometry.h` (new), `core_display.h`, `core_canvas.h`, +`core_thumbs.h`, `core_tables.h`, `core_module_loader.h`, `core_status_http.h`, +`patternflow.ino` and `features/clock/core_clock_http.h`. Five PRs: the +geometry and the driver, the canvas and thumbnails, the tables with the loader +and status, the compiled-in patterns (all core), and the clock (a feature). + +## 2.4 Modules + +A `.pfm` is ESP32-S3 machine code for one size, and it stays one build per +size. `build_module.py` takes `--panel WxH`, default `128x64`, in place of the +pair typed at `:51-52`, for the flags (`:258-259`) and the sidecar +(`:323-324`). `make_pack.py` takes it for the manifest's `panel` (`:199`). +`basics.zip` stays the 128×64 pack. + +The cache key gains the size, as §2.7 asks. `module_cache` gets a `panel` +column in its primary key (`schema.ts:727`), `"128x64"` on every row it holds. +SQLite cannot change a primary key in place, so the migration in `web/drizzle/` +rebuilds the table. Every lookup and upsert in `moduleCache.ts` (lines 102, +117, 159, 198, 217 and 624) and the bake job (`builds`, `schema.ts:619`) carry +the size, and the deck and pack downloads take one, `128x64` by default. No +page reads a device's `/api/status` today, so the size comes from the person. + +The cheaper way, beside it. The web derives the key's second column from the +published `builder_rev` and the size: `builder_rev` itself at 128×64, a digest +of the two at any other, and the worker writes rows under the same value. No +migration and one PR, and `builder_rev` stops meaning the toolchain alone, +which `schema.ts:733-737` says it does. + +What the device shows for a module built at another size. Both sides refuse +(`core_module_loader.h:1143-1150`, `pf_module.h:312`), and the panel shows +PATTERN FAILED with the loader's reason (`patternflow.ino:907-945`), which +`/api/status` carries in `loadError`. After a geometry change every module from +the old size does this. So the device reads `panel_w` and `panel_h` from the +sidecar it already reads for `absoluteReady` (`pattern_registry.h:65-67`), +skips such a module while browsing as it skips a `hidden` one (`:60-64`), and +lists it with its size on the Patterns page. The reason names both sizes. + +Touches `build_module.py`, `make_pack.py`, `pattern_registry.h`, +`core_module_loader.h`, `console/patterns.html`, `schema.ts`, `moduleCache.ts`, +the worker, a migration, the downloads and the pack list (`packs.ts:54`). Six +PRs with the column, five the cheaper way: the toolchain, the skip and reason +(core), the Patterns page (core), the key (site, two), the downloads (site). + +## 2.5 The console page + +The panel is the device, so the page is core (hard rule 7): +`console/panel.html`, spliced into `src/panel_index.h` by `console_pages.py` +(`PAGES`, `:112`), and `src/core_panel_http.h` serving `/panel` and +`/api/panel` the way `/knobs` is served (`core_knobs_http.h:121-123`). Its +values sit in NVS under `pf_panel`, as the knobs' sit under `pf_knobs` +(`core_encoders.h:100-124`). The driver allocates its rows once, in `begin()`, +so a saved geometry takes effect at the next boot. The page offers modules of +64×32, 64×64, 128×32 and 128×64, chains of one to four and the six profiles +(`config.h:161-166`), inside §2.8's bound only, and then the restart. + +The way back, in three cases. When `begin()` fails, the device today stops in +`while (1)` before Wi-Fi comes up (`core_display.h:71-74`). Here it erases +`pf_panel` and restarts, and stops only when the default fails. When the panel +stays dark or scrambled but the device runs, the console still answers and +`/panel` puts the default back. With no network, holding K4's button through +power-on erases `pf_panel`. Nothing reads a button at power-on today. + +On the wire. `/api/status`'s `panel` reads the geometry +(`core_status_http.h:282-285`). `/api/panel` answers with the saved geometry, +the running one and the defaults. `rest-api.md` changes at lines 77, 274, 284 +and 499 and gains a section for `/api/panel`. OSC, MIDI, MQTT and the audio +socket name no size, and they do not change. + +Touches `console/panel.html`, `src/panel_index.h` (generated), +`src/core_panel_http.h`, `console_pages.py`, `patternflow.ino`, +`docs/rest-api.md`, `docs/panel-compatibility.md` and the `config.h` comments +that say to edit `PANEL_PROFILE`. Three PRs: the page and its API (core), the +button at power-on (core), the docs. + +## 2.6 The site + +`PatternRuntime` takes a width and a height (`harness.ts:486`), and +`// @matrix` takes 8 to 512 a side (`matrix.ts:46-47`). The parameterised +harness §2.7 asks for is there. What remains is `DeviceSim`, which builds its +runtime at `PANEL_W` × `PANEL_H` (`deviceSim.ts:167`, `panelScreens.ts:42-43`) +and takes a width and a height, defaulting to those. The guide's stage, the 3D +panel (`LedMatrixTexture.ts:20-21`, `vec2(128.0, 64.0)` five times) and the +guide's preview (`Extras.tsx:185, 248`) draw the stock device and stay at +128×64. Pattern Lab's C++ export writes the macro a module build fills +(`cppPrompt.ts:68`). + +`shared/constants.json` holds the three geometries and the default under +`panel` (step 3, k7). This RFC adds the module sizes the page offers and the +bound beside them. `core_shared.h` and `constants.ts` carry both once step 3's +k8 and k9 have `config.h` and `panelScreens.ts` read them. If those land in +another shape, the page and `DeviceSim` take the two by hand. + +Touches `web/src/lib/guide/deviceSim.ts` and its test, and +`shared/constants.json` with its two generated files. Two PRs, site and shared. + +## 2.7 The virtual panel at other sizes + +Step 4 builds the virtual panel as `DeviceSim` behind sockets, with its width +and height in one place (step 4, k2 and D1). `start()` takes a size and +`main.ts` takes `--size 64x64`, default 128×64. `/frame.png` is that size, +`/api/status` reports it, and the REST transport (`transports/rest-state.ts`) +answers `/api/panel`, keeping a posted value until the next `start()`. REST +comes first (answer 3), and the other transports name no size. + +`check:virtual-panel` (step 4, k8) runs the preset list at 128×64 and again at +64×64. Every JS preset reads `display.width` and `display.height`, so the +second run tests the site's engine at another size, not the device's modules. +It fails on a thrown error, not on a picture. + +Touches `tools/virtual-panel/panel.ts`, `main.ts`, `transports/rest-state.ts`, +`web/scripts/drift/virtual-panel.ts` and a vector in `docs/rest-api-vectors/`. +Two PRs, site, after step 4's k2, k4 and k8, and where its seam (D5) puts them. + +## 2.8 The limits + +Classic HUB75 only, tiers 1 and 2 in +[panel-compatibility.md §9](panel-compatibility.md#9-why-hub75e-guarantees-nothing-and-the-three-tiers). +S-PWM panels (ICN2053, FM6353, MBI5051 and the rest) stay dark at any +geometry. They need a grey-scale clock the driver cannot make (upstream issue +642, closed wontfix; `config.h:153-160`), and they are built for a receiving +card, which is an output of the player in [rfc-linux-player.md §2.3](rfc-linux-player.md#a-receiving-card). + +One chain, in a row, two rows lit at once +(`ESP32-HUB75-MatrixPanel-I2S-DMA.h:67, 750-751`): a module is 32 or 64 tall. +Stacked modules and 1/8 or 1/4 scan modules need the remap in +`ESP32-HUB75-VirtualMatrixPanel_T.hpp`, which nothing includes. + +Memory. The row buffers come from internal DMA RAM (`.h:195`), width × depth × +2 bytes a row (`.h:165-168`), one per row pair per buffer, double-buffered +(`core_display.h:22`): 2 × W × H × depth bytes. With the canvas's 3 × W × H, +worked as arithmetic at depth 8: + +| canvas | modules | row buffers | canvas | against 128×64 | +| --- | --- | --- | --- | --- | +| 64×32 | one 64×32 | 32,768 B | 6,144 B | −116,736 B | +| 64×64, 128×32 | one module, or two 64×32 | 65,536 B | 12,288 B | −77,824 B | +| 128×64, 256×32 | one 128×64, two 64×64, four 64×32 | 131,072 B | 24,576 B | 0 | +| 192×64 | three 64×64 | 196,608 B | 36,864 B | +77,824 B | +| 256×64 | two 128×64 | 262,144 B | 49,152 B | +155,648 B | +| 256×64 at depth 6 | two 128×64 | 196,608 B | 49,152 B | +90,112 B | + +The budget on record is 98,708 bytes of internal heap free after the network +services, measured on the stock panel on 2026-08-18 +([core 2 heap](investigations/2026-08-core2-heap-and-the-5s-stall.md)). Module +code moved to PSRAM after that (`platformio.ini:15-17`), and no later figure is +on record. 192×64 would leave about 20,900 bytes. The core 3 build had 15,320, +and it was the build whose patterns would not install and whose console +stopped answering under a heavy pattern. 256×64 does not fit at depth 8. So the +bound is the stock's own memory, W × H at most 8,192 at depth 8, and anything +larger opens with a heap figure measured on a board. + +The blit and the refresh, also as arithmetic. The blit's time follows the pixel +count, 3,496 µs at 128×64 (`hub75/VENDORED.md:164`), so inside the bound it is +no longer than the stock's. Its fast pass needs depth 8 and an even width +(`ESP32-HUB75-MatrixPanel-I2S-DMA.cpp:979`), and every module width is even. +The scan must stay ahead of the blit, and the line between them is buffers per +row × words per row / pixel clock (`VENDORED.md:172`). Scan and blit both grow +with the row's width, so 109 µs against 96 per row pair at 128 wide keeps its +ratio at 64 or 256. At 12 buffers and 16 MHz the refresh is 16,000,000 / +(12 × W × H / 2): 325 Hz at 8,192 pixels, padded to 300 like the stock's. + +The menus. They draw portrait in a tile 64 wide (`core_ui_text.h:16, 69-76`), +and the info screens type their rows at y 26, 42 and 52 and at h − 28, h − 23 +and h − 13 (`patternflow.ino:924-945`). On a panel 64 wide the portrait screen +is 64 tall and those rows cross. On a panel 32 tall the tile shrinks to 32. +Every geometry but the two 128×64 ones needs the menus laid out from the screen +size, one core PR. + +## 2.9 The order of the work + +| k | what | ground | after | +| --- | --- | --- | --- | +| 1 | the geometry, the driver, the fallback when `begin()` fails | core | the gate | +| 2 | the canvas as an allocation, thumbnails | core | 1 | +| 3 | the tables, the loader's host API, `/api/status` | core | 2 | +| 4 | compiled-in patterns through `setFrame` | core | 2 | +| 5 | the clock's `w` and `h` | feature | 1 | +| 6 | the menus from the screen size | core | 1 | +| 7 | `shared/constants.json`: module sizes and the bound | shared | step 3's k8, k9 | +| 8 | the console page and `/api/panel` | core | 3, 6, 7 | +| 9 | the button at power-on | core | 1 | +| 10 | `build_module.py` and `make_pack.py` take a size | toolchain | — | +| 11 | the sidecar's size: skip and reason | core | 10 | +| 12 | the Patterns page shows a module's size | core | 11 | +| 13, 14 | the cache key, the migration and the worker | site | 10 | +| 15 | downloads and the pack list take a size | site | 14 | +| 16 | `DeviceSim` takes a size | site | 7 | +| 17, 18 | the virtual panel at a size, its check and vector | site | 16, step 4's k8 | +| 19 | `rest-api.md`, `panel-compatibility.md`, the comments in `config.h` | docs | 8 | + +Nineteen PRs, eighteen with the cheaper key. Nine are core, none touches the +frozen ABI, and each core PR runs `build.sh all` and shows the default's frames +unchanged. `builder_rev` hashes `core_module_loader.h` whole +(`builderRev.ts:44`), so k3, k10 and k11 each re-bake every cached module once. + +## 2.10 Open questions for the maintainer + +1. The bound: W × H up to 8,192 at depth 8, the stock's own memory, or 192×64 + measured on a board first? Default: the bound, and a larger size opens with + that measurement. +2. The way back without a network: K4's button held through power-on? + Default: yes. +3. The cache key: the column §2.7 asks for, with its migration, or the size + folded into the `builder_rev` the web derives? Default: the column. +4. Basics at another size: built by the worker when asked, or a zip per size + beside `basics.zip`? Default: the worker. Only 128×64 stays committed. +5. Origin on another panel: through `setFrame` at 128×64, or reading the + geometry? Default: `setFrame`, and Origin moves in a preset PR of its own. +6. The menus: laid out from the screen size before the page offers anything + but 128×64, or offered as they are? Default: the menus first. diff --git a/docs/rfc-cleanup-progress.md b/docs/rfc-cleanup-progress.md new file mode 100644 index 00000000..854ac76f --- /dev/null +++ b/docs/rfc-cleanup-progress.md @@ -0,0 +1,38 @@ +# Cleanup — progress + +Running log of the cleanup proposed in [rfc-cleanup.md](rfc-cleanup.md), +against the step order in §2.12. Updated as PRs land. Entries are added by +whoever merges; the table is the summary, the sections below are the +record. + +**Where it stands: step 0 done, step 1 next.** Baseline SHA `8226f7e`. + +| step | what | state | +| --- | --- | --- | +| 0 | Baseline: CI record, doc map, cleanup-window rule in `AGENTS.md` | done ([#11](https://github.com/Patternflow-lab/Patternflow/pull/11)) | +| 1 | Document structure: root, legacy guide, CHANGELOG split, ko/en parity list | planned ([three lanes](investigations/2026-10-cleanup-step1-plan.md)) | +| 2 | Drift checks: links, BOM triad, features, editions, presets, roadmap, changelog; the human-text lock and the voice lint | planned ([fourteen PRs](investigations/2026-10-cleanup-step2-plan.md)) | +| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | planned ([twenty-seven PRs](investigations/2026-10-cleanup-step3-plan.md)) | +| 4 | Test spine: contract vectors ×5, virtual panel | planned ([twenty-four PRs](investigations/2026-10-cleanup-step4-plan.md)) | +| 5 | RFC drafts: any-resolution, Linux player | planned ([four PRs](investigations/2026-10-cleanup-step5-plan.md)) | + +Gate for any-resolution (§2.12): not met. + +--- + +## Step 0 — baseline + +[#11](https://github.com/Patternflow-lab/Patternflow/pull/11), 14 October +2026. The cleanup starts from `8226f7ea5da15b0e0e55b15fa9ec7af136eef080` on +`dev`. The record is +[investigations/2026-10-cleanup-baseline.md](investigations/2026-10-cleanup-baseline.md). + +At that SHA `check:ci` passes its 25 suites, `check:panels` its 478 tests, +and `typecheck` is clean. `lint` has no errors and 12 warnings, listed in the +record for Step 3. `build.sh all` ran on GitHub Actions, because the container +cannot reach the PlatformIO registry, and all five compositions built. + +`npm run doc-map` in `web/` writes +[investigations/2026-10-doc-map.csv](investigations/2026-10-doc-map.csv). +1007 links out of 193 files, every one resolving, so the known-broken list is +empty. `AGENTS.md` carries the cleanup-window rule as hard rule 8. diff --git a/docs/rfc-cleanup.md b/docs/rfc-cleanup.md new file mode 100644 index 00000000..a147c62b --- /dev/null +++ b/docs/rfc-cleanup.md @@ -0,0 +1,386 @@ +# RFC: cleanup before expansion + +*Tidying the tree before any-resolution work starts. Written 2026-10-14.* + +> **Status, 14 October 2026.** Proposed. Nothing in this RFC changes what +> the firmware or the site does. It changes where things live, what CI +> checks, and what an agent is allowed to write. The gate at the end (§2.12) +> is what has to be true before the any-resolution RFCs open. + +This document is two things on purpose. **Part 1 is the maintainer, in his +own words.** **Part 2 is the specification** — precise and mechanical, +written so a contributor (or an AI agent handed this file) can implement +against it without asking what was meant. + +--- + +# Part 1 — Why + +패턴플로우를 확장하기 위한 밑작업과 다이어트 + +--- + +# Part 2 — The specification + +## 2.1 Rules that hold for the whole cleanup + +1. **No behaviour change.** A cleanup PR may move, rename, split, delete + dead code, add a check, or add a test. It may not change what a build + outputs. Every PR that touches `firmware/` or `web/src/` states in its + description which of the following it ran, and all must pass: + `firmware/bundles/build.sh all`, `npm run check:ci`, `npm run + check:panels`, `npm run typecheck`, `npm run lint`. +2. **Output equality is proven, not asserted.** For firmware, the + `.pfm` module cache keys (header SHA-256 + toolchain revision) must not + change unless the PR is explicitly about the cache. For the site, the + sandbox bundle (`npm run check:sandbox-sync`) and the console pages + (`console-sync.yml`) must stay byte-identical unless the PR is about + them. +3. **Human-written text is locked.** §2.9 lists the files. Agents may fix + markup, layout, CSS and link paths in them. Agents may not change a + word. +4. **Everything an agent writes reads like the maintainer wrote it.** §2.10. +5. **PR size.** Under 300 changed lines. One concern per PR. A PR that + needs more than that is two PRs. +6. **Hard rules in `AGENTS.md` still apply**: founders boards stay private, + the BOM triad stays in sync, the licence split is not touched, naming + is not touched, the power-input description is not touched, nothing + feature-shaped goes into `src/`. + +## 2.2 Step 0 — baseline + +Deliverables: + +- `docs/investigations/2026-10-cleanup-baseline.md`: the commit SHA the + cleanup starts from, and the output of every command in §2.1(1) at that + SHA. If any of them is red before the cleanup starts, that is the first + PR, and it is not a cleanup PR — it is a fix, reviewed as one. +- `scripts/doc-map.ts` (web workspace, reads the whole repo): for every + `.md` and every `.tsx`/`.ts`/`.h` that contains a Markdown or HTTP link + into the repo, emits `docs/investigations/2026-10-doc-map.csv` with + columns `from, to, kind (relative|absolute-blob|absolute-raw|site|console), + anchor, resolves (yes|no)`. The console pages in + `firmware/patternflow/console/` and the site's redirect table are + included as link sources, because those are the links a file move + breaks silently. +- A one-line addition to `AGENTS.md` under its hard rules: *"During a + cleanup window (see docs/rfc-cleanup.md) no PR changes behaviour; moves + and renames carry a redirect or an updated link map."* + +Done when: the baseline file exists, the map CSV has zero rows with +`resolves = no` that are not already listed as known-broken in the +baseline file, and CI is green. + +## 2.3 Step 1 — document structure + +**Root.** The guides that stay at the repository root are decided by the +maintainer before this step starts (§2.13, question 1). The default +proposal: `README.md`, `BUILD_GUIDE.md`, `PATTERN_GUIDE.md`, +`FEATURE_GUIDE.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `AGENTS.md`, +`LICENSE*`. Everything else at root is a candidate to move under `docs/`. + +**README.** Keep the sections a first-time visitor needs to make, flash, +play, contribute and understand the licence. The narrative sections +("Where it began", "The community responded", "Story so far") are the +maintainer's writing. They are not rewritten. If the maintainer decides +they leave the README, they are moved **verbatim** to `docs/story.md` +with a one-line pointer left in the README. If he decides they stay, they +stay as they are. Agents do not decide this. + +**Legacy build guide.** `BUILD_GUIDE_v2.md` moves to +`docs/legacy/BUILD_GUIDE_v2.md`. Before the move, the doc map (§2.2) +lists every link into it, including the site's anchored links to §1, §2, +§4, §8 and §10. The PR that moves the file updates every one of those +links in the same PR, and adds the old path to `web/next.config` redirects +if any external link is known to point at it. + +**CHANGELOG.** Split by version into `docs/releases/vX.Y.Z.md`, one file +per release, text copied as written. The root `CHANGELOG.md` keeps the +three most recent releases in full and links the rest. `docs/RELEASING.md` is +updated so a release adds a file rather than prepending to the root. + +**Korean/English parity.** `scripts/doc-map.ts` gains a second output, +`docs/investigations/2026-10-doc-parity.csv`: every bilingual pair the +repo maintains (`*.md` / `*.ko.md`, console page pairs, `features-data` +`detail`/`detailKo`), and which side is missing or shorter by more than a +third. No translation is done in this step; the list is the deliverable. + +Done when: root contains only the agreed files, every moved file has +every inbound link updated in the same PR, `docs-links.yml` is green, and +the parity CSV exists. + +## 2.4 Step 2 — drift checks + +Each check is a script under `web/scripts/`, wired into `npm run check:ci`, +and fails CI on drift. Each prints the two sides it compared and the diff, +so a failure is readable without running anything locally. + +| script | left side | right side | what counts as drift | +| --- | --- | --- | --- | +| `check:links-absolute` | every `github.com/engmung/Patternflow/blob/main/...` and `raw.githubusercontent.com/...` URL in the repo | the tree at HEAD | path or anchor that does not exist | +| `check:bom` | `hardware/bom/bom_v3.9.csv` | the parts table in `BUILD_GUIDE.md` | a designator, part or quantity present on one side only | +| `check:bom-schematic` | `hardware/bom/bom_v3.9.csv` | the schematic PDF's text layer (`hardware/pcb/*.pdf`) | a designator in the BOM that the PDF does not mention, or the reverse | +| `check:features` | `web/src/app/features/features-data.ts` ids | directories under `firmware/patternflow/features/` | an id without a directory, or a directory without an id | +| `check:editions` | `web/src/app/editions/editions-data.ts` | `firmware/bundles/*/` and `shelf.sh` output | an edition without a bundle, a bundle without a shelf entry | +| `check:presets` | the preset registry the site renders | `firmware/patternflow/presets/` after the feature cut list | a preset the site lists that no edition can compile, or the reverse | +| `check:roadmap` | `web/src/app/roadmap/roadmap-data.ts` items with an issue link | GitHub issue state via `gh api` (skipped offline) | `done` with an open issue, `planned` with a closed one | + +`check:bom-schematic` may land as warn-only for one release if the PDF +text layer turns out not to carry designators; the PR says so and opens +an issue. + +Done when: all seven scripts exist, run in CI, and pass at HEAD. A check +that fails at HEAD is not disabled; the drift it found is fixed first, in +its own PR. + +## 2.5 Step 3 — code hygiene + +**Web.** +- `npm run lint` and `npm run typecheck` at zero warnings. Rules are not + relaxed to get there. +- Legacy paths and keys (`legacyDraft` keys in Pattern Lab, the `/variants` + redirect, any route kept "for old links") either go, or get a comment + that names the inbound link they exist for. A comment that says "legacy" + without saying what points at it is not enough. +- Dependency updates: minor and patch versions in one PR per package + family, `check:ci` green. Major versions (Next, React, Drizzle) are a + separate PR each, and the maintainer approves before it is opened. + +**Firmware.** +- `firmware/patternflow/src/hub75/` is a vendored fork of + ESP32-HUB75-MatrixPanel-DMA. Produce `src/hub75/UPSTREAM.md` with the + upstream commit it was taken from and `src/hub75/patternflow.patch` + with the exact local changes (`blitRGB888` and whatever else). Then + merge upstream fixes that do not touch the patched regions, one upstream + commit per PR, `build.sh all` green and a flash-and-look on the + reference panel noted in the PR by the maintainer. +- The five remaining `TODO`/`FIXME` markers become issues or get resolved. + No new ones are added during the cleanup. + +**Shared constants.** Values that both the firmware and the web harness +carry by hand — knob scale, detent count, encoder semantics, panel +geometry names — move to one source file, `shared/constants.json`, and +both sides generate from it (`firmware/patternflow/src/core_shared.h` and +`web/src/lib/shared/constants.ts`, both marked generated, both checked by +`check:shared` for staleness). The 24-vs-20 detent incident in the +firmware/web harness is the reason this step exists. + +Done when: lint and typecheck are clean, every legacy path has a named +reason or is gone, `UPSTREAM.md` and the patch file exist, and +`check:shared` is in CI. + +## 2.6 Step 4 — the test spine + +**Contract vectors.** `docs/pfst-v2-vectors/` is the model. Each of the +other contracts gets the same: a directory of inputs and expected outputs, +and a smoke script that replays them. + +| contract | vectors | script | +| --- | --- | --- | +| `docs/rest-api.md` | `docs/rest-api-vectors/` — request, expected status, expected body shape | `check:rest-vectors` | +| `docs/osc-spec.md` | `docs/osc-vectors/` — OSC packet bytes, expected parameter effect | `check:osc-vectors` | +| `docs/midi-spec.md` | `docs/midi-vectors/` — MIDI bytes, expected knob/pattern effect | `check:midi-vectors` | +| `docs/mqtt-spec.md` | `docs/mqtt-vectors/` — topic + payload, expected state | `check:mqtt-vectors` | +| `docs/audio-ws-spec.md` | `docs/audio-ws-vectors/` — frames, expected feature values | `check:audio-vectors` | + +Vectors are derived from the spec text, not from current behaviour. Where +the firmware disagrees with its own spec, the vector encodes the spec and +the PR opens an issue; the firmware is not changed in this step. + +**Virtual panel.** `tools/virtual-panel/`: a Node process that implements +the device side of the five contracts above over the same ports and +paths the firmware uses, renders to an in-memory 128×64 canvas (and, when +the harness is parameterised in §2.7, to any size), and exposes +`GET /frame.png` for assertions. It uses the web harness +(`web/src/lib/harness/`) for pattern execution so the two never drift. It +is a test fixture, not a product: no flashing, no OTA, no hotspot. + +The maintainer decides which contract the virtual panel speaks first +(§2.13, question 3). The default order: REST, then MQTT, then OSC, MIDI, +audio. + +Done when: five vector suites run in CI against the virtual panel and pass +for the contracts it implements, and the virtual panel runs the full +preset list from `firmware/patternflow/presets/` without a thrown error. + +## 2.7 Step 5 — make room for expansion + +No implementation. Two RFC drafts, Part 2 only (Part 1 is the +maintainer's), each in the format of this file: + +- `docs/rfc-any-resolution.md`: the firmware path. Inventory of the 25 + `PANEL_RES_W/H` references in `src/`, the four in `features/`, the seven + in `abi/`; a proposed runtime geometry struct with compile-time + defaults; the module cache key extended with `W×H`; the console page + for choosing geometry without reflashing; the harness matrix + parameterised; what the virtual panel needs to render other sizes. + Includes the limits as limits: classic HUB75 drivers only, S-PWM panels + stay unsupported, the ESP32-S3 DMA framebuffer bound. +- `docs/rfc-linux-player.md`: the player path. The JS harness as the + engine; output adapters for `rpi-rgb-led-matrix`, a Colorlight-class + receiving card over Ethernet, and HDMI; input from USB encoders, MIDI, + OSC and the web knobs; what of the five contracts the player speaks; + what it cannot do (no `.pfm` modules, no browser flash). + +Done when: both files exist, each has every section a reader needs to +estimate the work, and each ends with an open-questions list for the +maintainer. + +## 2.8 Do not touch + +- `web/src/app/compliance/` and the compliance page text — it is printed + on the box. +- Preset file names under `firmware/patternflow/presets/`. +- `hardware/case/encloser.stl` and its name. +- Links baked into console pages, except through the doc map and a + same-PR update. +- `hardware/founders/` (private) and anything that would commit it. +- `LICENSE`, `LICENSE-SUMMARY.md`, and the hardware/firmware/pattern + licence split. +- The power-input description anywhere it appears. +- `.github/workflows/firmware-release.yml` and the release asset names. + +## 2.9 Human-written text lock + +These files and regions hold the maintainer's own writing. Agents may +change markup, layout, CSS, link paths and image paths in them. Agents +may not change, add, remove or reorder a sentence, and do not fix typos — +a typo is reported in the PR description or an issue. + +- `web/content/journal/` — every file, every language. +- `docs/manifesto.md`. +- `web/src/app/roadmap/roadmap-data.ts` — the `detail` and `detailKo` + strings. +- `README.md` — "Where it began", "The community responded", "Story so + far". +- Part 1 of every RFC under `docs/`. +- `docs/releases/*.md` once split — moved verbatim, then locked. +- Any file the maintainer adds to `docs/.human-written` (one path per + line; the lock script reads it). + +`scripts/human-lock.ts` (`check:human-lock`) fails a PR that changes +non-markup characters in any locked region. It compares the plain text of +each locked file or region before and after, with Markdown syntax and +whitespace stripped. + +Moving a locked file is allowed only with the maintainer's approval on the +PR, and only verbatim. + +## 2.10 Writing voice + +Append to `AGENTS.md`: + +> ## Writing voice (hard rule) +> +> Everything an agent writes that a person will read — docs, README +> sections, release notes, console page copy, PR descriptions, comments — +> must read as if the maintainer wrote it. Not "polished". Written. +> +> Voice reference: `docs/voice.md` describes the project voice. Read it before writing anything longer than a sentence. Match its +> register (Korean: 해요체 or 합니다체 as the file already uses; English: +> plain, first person where the project already uses it), its sentence +> length, and its habit of naming the specific thing — the part number, +> the detent count, the date — instead of the category. +> +> Do not: +> - Open with what the document is about ("This document describes…", +> "이 문서는 …을 설명합니다"). Start with the thing. +> - Close with a summary that repeats the body, or with "In conclusion" / +> "결론적으로". +> - Turn prose into bullet lists, or bold phrases for emphasis. Bullets are +> for lists of things. +> - Use these words or their Korean equivalents: seamless, robust, +> leverage, comprehensive, streamline, empower, cutting-edge, ensure, +> utilize, delve, "it's important to note", "here's", 효율적으로, +> 원활하게, 강력한, 최적화된, 혁신적인, 다양한, 전반적으로, 참고로, +> 살펴보겠습니다. +> - Write in threes ("fast, simple, and reliable"). One reason is a +> reason; three is a pattern. +> - Join clauses with em dashes (—). End the sentence and start another. +> - Hedge every claim. Say it, or leave it out. +> - Add emoji, decorative headers, "Note:" callouts, or a "Generated by" +> line. +> - Mention that an AI, an agent or a tool wrote or helped write the text. +> Ever. +> - Reformat an existing human-written paragraph into another structure. +> +> Do: +> - Prefer one specific example over a general statement. +> - Vary sentence length. A short one can stand alone. +> - Keep the project's own terms (core, feature, edition, bundle, preset, +> module) exactly as the existing docs use them. +> - For PR descriptions and commits: imperative first line, one line of +> why, nothing else. No "This PR introduces…". +> - When unsure of tone, write less. +> +> Any new or rewritten text over 20 lines goes through +> `scripts/voice-lint.ts` (banned phrases, triplets, opener and closer +> patterns) before the PR is opened, and a person reads it before merge. + +`docs/voice.md` is written by an agent at the maintainer's request (§2.13, +answer 6), and the maintainer reads it before merge. `scripts/voice-lint.ts` (`check:voice`) reads the banned list from +`docs/voice-banned.txt` so the maintainer can add to it without touching +the script. + +## 2.11 PR protocol + +- Branch names: `cleanup/-`. +- Labels: `cleanup`, plus `step-0` … `step-5`. +- Each PR description has three lines: what moved or changed, which §2.1 + commands ran, and which doc-map rows it touched (or "none"). +- Batching: at most five open cleanup PRs at a time. The maintainer + reviews in one sitting a week; agents do not open new PRs while five are + waiting. +- A PR that fails a check added by an earlier step is fixed, not + rebased around. +- Agents do not merge. Agents do not approve. + +## 2.12 Order, and what "done" means + +Steps run in order 0, 1, 2, 3, 4, with 5 allowed to start once step 2 is +merged. Inside a step, PRs are independent unless the step says otherwise. + +The cleanup is done — and `docs/rfc-any-resolution.md` may move from +draft to proposed — when all of these hold at HEAD: + +1. Every command in §2.1(1) passes. +2. Every script in §2.4, §2.5 (`check:shared`), §2.6 and §2.9 + (`check:human-lock`) runs in CI and passes. +3. The virtual panel speaks at least REST and MQTT and renders the full + preset list. +4. The doc map has zero unresolved rows. +5. Root contains only the files agreed under §2.3. +6. Both RFC drafts from §2.7 exist. + +## 2.13 Open questions for the maintainer + +1. Which files stay at the repository root (§2.3)? Default list given. +2. Do the README's narrative sections stay, or move verbatim to + `docs/story.md`? +3. Which contract does the virtual panel speak first (§2.6)? Default: + REST. +4. Which major dependency updates, if any, are in scope this window + (§2.5)? +5. Any file to add to `docs/.human-written` beyond the list in §2.9? +6. The three to five excerpts for `docs/voice.md`, and additions to + `docs/voice-banned.txt`. + +### Answers (maintainer, 2026-10-14) + +1. The root keeps `README.md`, `BUILD_GUIDE.md`, `PATTERN_GUIDE.md`, + `FEATURE_GUIDE.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `AGENTS.md`, + `CLAUDE.md`, `CITATION.cff`, `LICENSE`, `LICENSE-MIT`, `LICENSE-CC-BY-SA` + and the dotfiles. `CODE_OF_CONDUCT.md`, `SECURITY.md` and `SUPPORT.md` + move to `.github/`, where GitHub still finds them. `AUDIO_GUIDE.md` moves + to `docs/`. `BUILD_GUIDE_v2.md` moves to `docs/legacy/` as §2.3 says. A + first-time visitor should be able to read the root at a glance. +2. The narrative sections stay in the README as they are. +3. REST first. +4. None. Major updates wait until the cleanup is done. +5. Nothing beyond §2.9. +6. An agent writes `docs/voice.md`. It does not quote the journal. It + describes the voice the README and the build guide already use: short, + specific, finished, nothing that reads as generated. One page at most. + It is the first prose PR of step 1, and no other step 1 PR that writes + prose opens before it is merged. `docs/voice-banned.txt` starts with + the list in §2.10. diff --git a/docs/rfc-linux-player.md b/docs/rfc-linux-player.md new file mode 100644 index 00000000..d0e02a01 --- /dev/null +++ b/docs/rfc-linux-player.md @@ -0,0 +1,298 @@ +# RFC: the Linux player + +*Patterns on a Linux computer, with no ESP32-S3 in between. Written 2026-10-15.* + +> **Status, 15 October 2026.** Draft. Nothing in this RFC changes what the +> firmware or the site does. It sets out a program that does not exist yet, +> the player path of [rfc-cleanup.md](rfc-cleanup.md) §2.7. + +This document is two things on purpose. **Part 1 is the maintainer, in his +own words.** **Part 2 is the specification** — precise and mechanical, +written so a contributor (or an AI agent handed this file) can implement +against it without asking what was meant. + +--- + +# Part 1 — Why + +--- + +# Part 2 — The specification + +## 2.1 What it is + +The player is a Linux process that plays Patternflow patterns with the site's +engine and puts the frames on an LED panel or a screen. A Raspberry Pi with a +HUB75 HAT is the first machine it is written for. It is not firmware and not +an edition: an edition is a firmware build +([EDITIONS.md](EDITIONS.md#the-three-words)), and nothing here compiles for +the ESP32-S3. +The firmware path, any size on one ESP32-S3, is [rfc-any-resolution.md](rfc-any-resolution.md). + +Facts are from `dev` at `adbe923`. Where the player leans on work that is only +planned, the plan and its k are named and the paths are in backticks. "Size" is +the canvas, width × height. A path that starts `player/` is inside the player's +own folder, wherever that lives (§2.7). Each section ends with its rows in +§2.8, the table of files, lines and PRs. + +## 2.2 The engine + +`PatternRuntime` (`web/src/lib/pattern/harness.ts:463`) imports nothing and +touches no DOM. It compiles a pattern with `new Function` (`:443-454`), and +`draw` writes an RGBA buffer of width × height × 4 bytes, sized by the +constructor (`:486`, 128 and 64 by default). It already runs outside a +browser: `check:flatten` renders frames with it under Node 20 in CI +(`web/scripts/lab-flatten-smoke.ts:57-69`). + +Around it, `DeviceSim` (`web/src/lib/guide/deviceSim.ts:163`) plays the +firmware's rules for the guide's 3D board: the parameter bus, the lanes and +their holds, sleep, select, and the device's screens. The player is `DeviceSim` +with an output where the guide has a mesh, calling `tick(dt)` (`:412`) and +handing `frame` (`:165`) on. Step 4 builds its virtual panel the same way, so +that a rule `DeviceSim` has is not written again +([the step 4 plan](investigations/2026-10-cleanup-step4-plan.md), D1). + +The player starts with the 34 patterns a board lists after the Basics pack, +Origin and the 33 slugs of `web/public/packs/basics.json`'s `order`, each run +from its JS twin (`deviceSim.ts:92-104`). The other 14 JS presets and every +community pattern play as JS too. The community keeps a pattern as plain JS +(`web/src/lib/community/server/schema.ts:95`) with an optional C++ port +(`:102`), and the feed's cards carry the JS (`server/serialize.ts:10`), so the +player plays patterns no board runs until someone ports them. + +The size comes from the output (§2.3). At 128×64 nothing in `web/` changes. +At any other size `DeviceSim` is in the way, fixed through `PANEL_W` and +`PANEL_H` (`web/src/lib/guide/panelScreens.ts:42-43`), and so are the typed +sizes the [panel geometry record](investigations/2026-10-panel-geometry.md#the-site) +lists. Step 4 asks for the size in one place (its D1), the firmware path +proposes the change, and the player waits for it. 46 of the 48 JS presets take +the size only from what `draw` is given. + +A pattern can declare its frame with `// @matrix`, 8 to 512 on a side +(`web/src/lib/pattern/matrix.ts:46-47`). `DeviceSim` never reads it, so a +community pattern drawn for 64×128 runs there at 128×64. The device fits a +declared frame in `setFrame` (`firmware/patternflow/src/core_canvas.h:137-163`): +equal draws straight through, swapped rotates, anything else is letterboxed. +`matrixFit` (`matrix.ts:131`) names the same cases and nothing calls it. +`DeviceSim` learns the rule before the player plays community patterns. No JS +preset carries `// @matrix`, so the guide shows nothing new (rows 1, 8 and 9). + +## 2.3 The outputs + +An output is a file in `player/outputs/` with `open(options)`, which returns +its width and height, `write(rgba)` and `close()`. Until `DeviceSim` takes a +size, the player refuses an output that is not 128×64 and says what it got. + +### rpi-rgb-led-matrix + +[rpi-rgb-led-matrix](https://github.com/hzeller/rpi-rgb-led-matrix) drives +HUB75 panels from a Pi's GPIO header, bare or through a HAT. Its README covers +every Pi up to the Pi 4, and says of the Pi 5 both that support has been added +and that it is not official as of November 2025. A program that uses it starts +as root, and the library drops privileges once the hardware is set up. It +exits while the on-board sound module, `snd_bcm2835`, is loaded, so the +install turns sound off. + +The library is GPL-2.0 or later. An addon would link it and bring the GPL into +a tree whose code is MIT (`LICENSE:4`, hard rule 3), and the Node.js bindings +its README lists are addons. The proposal is a second program on a pipe, about +100 lines of C++ kept outside this tree under the GPL (question 2), that reads +raw RGB frames on stdin and shows each one. A 128×64 frame is 24,576 bytes, so +60 frames a second is 1,474,560 bytes a second (arithmetic). The player starts +it with the library's own `--led-rows`, `--led-cols`, `--led-chain` and +`--led-parallel` and takes the size from them. The child starts as root and +drops. The player never runs as root (row 5). + +### A receiving card + +The S-PWM panels Patternflow cannot light (ICN2053, FM6353, MBI5051 and the +rest, `firmware/patternflow/config.h:153-160`) are sold for a receiving card, +and [panel-compatibility.md](panel-compatibility.md) tells a buyer to walk away +from them. A receiving card turns that around: it scans the panel the way an +S-PWM panel expects and takes its pixels over Ethernet. This is the output for +those panels, and for signs larger than one ESP32-S3 drives. + +The reference card is the Colorlight 5A-75B (question 6). Colorlight does not +publish its protocol. People have worked it out from packet captures, and +[haraldkubota/colorlight](https://github.com/haraldkubota/colorlight) (MIT) +sends a 5A-75B plain Ethernet frames, no IP, out of a named interface. Linux +sends such a frame from an `AF_PACKET` socket, which needs `CAP_NET_RAW`. +Node's `dgram` and `net` stop at UDP and TCP, so the output needs an addon or +a helper program given the capability with `setcap`. A protocol learned from +captures can change with a card's firmware (row 11). + +### HDMI + +The screen output writes the Linux framebuffer, `/dev/fb0`, with plain file +writes on a console with no desktop running. Its size and depth are in +`/sys/class/graphics/fb0/virtual_size` and `bits_per_pixel`. The output +reports the size it is given, 128×64 by default, and scales it by a whole +number, nearest pixel, centred: 128×64 on 1920×1080 is ×15, 1920×960 with 60 +rows of black above and below. A browser in kiosk mode would be the site, and +a page cannot open the UDP ports §2.4 needs (row 2). + +## 2.4 The inputs + +Every input ends in a call the guide already makes, so a knob on the player +behaves as it does on a board: a hand is `turn` (`deviceSim.ts:359`), a button +`press` and `release` (`:372`, `:381`), a remote value `applyRemoteParam` +(`:501`). + +USB encoders. The player reads a USB knob box's `/dev/input/by-id/*-event-*` +node as a file: 24-byte events on 64-bit Raspberry Pi OS, a rotation as a +relative step (`EV_REL`) or, on some single knobs, a key per step. +`player/knobs.json` maps a device's axis or key to K1 to K4 and their buttons, +with steps per detent. A board's `PEC11R-4220F-S0024` has 24 detents a turn, +`DETENTS_PER_TURN` in `shared/constants.json`. The player reads the generated +`web/src/lib/shared/constants.ts`, which the site does not import yet +(`harness.ts:11` still types 24). `DeviceSim` takes a direction and an edge +count per knob (`setKnobSettings`, `:522`), as `/api/knobs` does on a board. + +MIDI. A USB controller appears as an ALSA raw device, +`/dev/snd/midiCD`, and reads as a byte stream with no addon. +RTP-MIDI is the player listening on UDP 5004 and 5005 as the panel does +([midi-spec.md](midi-spec.md) line 28), not `rtpmidid`, which hands a session +to the ALSA sequencer, and the sequencer needs an addon. Both go through the +panel's map (`midi-spec.md:35-45`): CC 20 to 23 absolute, CC 24 to 27 +relative, notes 60 to 63 the buttons, Program Change the pattern. Step 4's k15 +writes the receive side (`tools/virtual-panel/transports/midi.ts`). + +OSC. UDP 9001 in, replies to 9000 ([osc-spec.md](osc-spec.md) lines 13-14): +`/patternflow/ping`, `/patternflow/knob/N/delta` and +`/patternflow/pattern/index` (lines 41-48). Step 4's k13 writes it +(`tools/virtual-panel/transports/osc.ts`). + +The web knobs. The console's four sliders, 0 to 100 +(`firmware/patternflow/console/home.html:112-115`), post `pN` as a form body +to `POST /api/params`, one request in flight at a time (`:383-390`, +[rest-api.md](rest-api.md) line 160). The player answers that route and +serves one page with the same four sliders. It does not serve `home.html` +whole: most of what that page reads, Wi-Fi and storage among it, has nothing +behind it on a player (rows 3, 4, 6 and 7). + +## 2.5 The contracts + +The panel speaks five: [rest-api.md](rest-api.md), [osc-spec.md](osc-spec.md), +[midi-spec.md](midi-spec.md), [mqtt-spec.md](mqtt-spec.md) and +[audio-ws-spec.md](audio-ws-spec.md). The player speaks them in this order. + +1. REST first, as for the virtual panel (rfc-cleanup.md §2.13, answer 3). It + carries the web knobs, and a client finds a player with `GET /api/status` + the way it finds a panel. +2. OSC, then MIDI, the inputs rfc-cleanup.md §2.7 names. The virtual panel + puts MQTT here (question 3). +3. MQTT, as a broker's client with the panel's topics and roles + (`mqtt-spec.md:16`), so a player can follow a show channel beside panels. +4. The audio WebSocket on 81, for lanes from the browser extension and the + phone app in `tools/`. + +REST is five routes: `GET /api/status` (`rest-api.md:42`), `POST /api/params` +(`:160`), `POST /api/sleep` (`:189`), `GET /api/patterns` (`:232`) and +`GET /api/patterns/select` (`:251`). Status keeps what a client reads to play +(`active`, `patterns`, `sleep`, `knobs`, `params`, `paramActive`, `lanes`, +`laneActive`, and `panel` as the output's size) and leaves out the fields that +describe an ESP32, such as heap and flash. The spec asks a client to tolerate +fields it does not know (`:5`), not fields that are missing (question 8). HTTP +on 80 and the audio socket on 81 sit below 1024, which Linux keeps from an +unprivileged process (step 4's finding 3). The systemd unit grants +`CAP_NET_BIND_SERVICE` through `AmbientCapabilities=`, and a port flag moves +either one, as in the virtual panel's D3. + +Step 4 plans `tools/virtual-panel/`: `DeviceSim` behind sockets, one transport +per file in `tools/virtual-panel/transports/`, each handed the `DeviceSim`, a +clock, the HTTP routes and its ports (its D5). The player imports those +transports and writes none of its own. It does not take `/vp/` (D6) or the +manual clock and `advance(ms)` (D4): it runs on the wall clock, and the virtual +panel is a test fixture (rfc-cleanup.md §2.6). If step 4 moves the transports +or changes their seam, the player follows. If step 4 does not land, the player +writes each transport itself, and §2.8 grows by one PR for each of step 4's k4, +k5, k9, k10, k13, k15 and k19 (rows 3, 6, 7 and 10). + +## 2.6 What it cannot do + +- Load a `.pfm`. A module is ESP32-S3 machine code for the device's relocator + (`firmware/patternflow/src/core_module_loader.h`). One with no JS twin, like + a module in a pack built outside the repo, does not play (`deviceSim.ts:97`). +- Be flashed from the browser. The Flash Patternflow button installs an + ESP32-S3 image (`web/public/flash/manifest.json`). +- Run a C++ feature. Weather, the clock, shows and the microphone live in + `firmware/patternflow/features/`. The audio socket carries lanes a phone or + a browser worked out, which is not the microphone. +- Do the board's own jobs: Wi-Fi setup, OTA, the crash record, + `/api/display`'s white balance and gamma, the calibration pattern. + +## 2.7 Where it lives and how it ships + +Two builds on the site already run Patternflow on a Raspberry Pi: day's, with +a 64×32 matrix (`web/src/components/sections/InsideGlobe/builds.ts:130`), and +Philipppp's Pi 4 with a HUB75 HAT and firmware of its own (`:333`). The player +starts from neither, but the hardware is already in people's hands. + +Two homes are open (question 1). In this repository it is +`tools/linux-player/`, MIT like the rest of `tools/` (`LICENSE:4`). It imports +`web/src/lib/` by path as step 4's virtual panel does, with a `tsconfig.json` +that extends the site's (step 4's k2), so a change to `DeviceSim` that breaks +the player fails `typecheck` in the same PR. In a repository of its own, as the +Home Assistant integration has been since 2026-09-03, it cannot import +`web/src/lib/` or step 4's transports by path. It needs the engine as a package +or copies of `harness.ts`, `deviceSim.ts` and their imports, and copies drift: +the 24-vs-20 detent incident is why step 3 has a shared constants file at all +(rfc-cleanup.md §2.5). The C++ program for the matrix lives outside this tree +either way. + +It ships as source with a `package.json` of its own, so a Pi never installs the +site's dependencies: `npm ci` on Raspberry Pi OS, Node 20 as CI runs +(`.github/workflows/web-ci.yml:36`), and a systemd unit that starts it at boot. +There is no disk image (row 12). Its own home adds two PRs, the package or the +copies and the check that keeps them in step. + +## 2.8 The order of the work + +| # | what | files | lines | PRs | where | +| --- | --- | --- | --- | --- | --- | +| 1 | the loop, `DeviceSim`, a `null` output for CI | `player/main.ts`, `player/engine.ts`, `player/outputs/null.ts` | 200 | 1 | player | +| 2 | HDMI | `player/outputs/fb.ts` | 100 | 1 | player | +| 3 | REST and the slider page | `player/rest.ts`, `player/knobs.html` | 180 | 1 | player | +| 4 | USB encoders | `player/inputs/evdev.ts`, `player/knobs.json` | 150 | 1 | player | +| 5 | the matrix | `player/outputs/matrix.ts`; the C++ program in its own home | 120 | 1 | player | +| 6 | OSC | `player/transports.ts` | 60 | 1 | player | +| 7 | MIDI | `player/inputs/rawmidi.ts`, `player/transports.ts` | 140 | 1 | player | +| 8 | `// @matrix` in `DeviceSim` | `web/src/lib/guide/deviceSim.ts`, `deviceSim.test.ts` | 80 | 1 | site | +| 9 | community patterns | `player/community.ts` | 120 | 1 | player | +| 10 | MQTT, then the audio socket | `player/transports.ts` | 60 each | 2 | player | +| 11 | the receiving card | `player/outputs/card.ts`, the raw-socket helper | 250 | 2 | player | +| 12 | the systemd unit and the install page | `player/patternflow-player.service`, `player/README.md` | 120 | 1 | player | + +Fourteen PRs, each under rfc-cleanup.md §2.1's 300 lines with one concern. The +`null` output fails on a thrown error, so CI runs the player with no panel. No +row touches the core, the frozen ABI or a feature, so hard rule 7 does not come +into it. After row 4 a Pi with a screen and a USB knob box plays the Basics, +and row 5 puts it on a HAT. Rows 3, 6, 7 and 10 wait for step 4's k2 and the +transport each imports (k4 and k5, k13, k15, then k9, k10 and k19). Row 11 +waits for someone with a card. Any size but 128×64 waits for the site to take +its size from one place, which the firmware path counts instead. + +## 2.9 Open questions for the maintainer + +Each has the default this draft uses until there is an answer. + +1. Where does the player live: here as `tools/linux-player/`, or in a + repository of its own, like the Home Assistant integration (§2.7)? + Default: the draft sets out both and picks neither. +2. Where does the C++ program for rpi-rgb-led-matrix live, and who keeps it? + It is GPL, and this tree's code is MIT. Default: a repository of its own. +3. Is REST, OSC, MIDI, MQTT, audio the right order, when the virtual panel's + is REST, MQTT, OSC, MIDI, audio (§2.5)? Default: yes. +4. Does the player ship at 128×64 only, until the site takes its size from one + place (§2.2)? Default: yes. +5. Where do community patterns come from: the feed on patternflow.work, which + needs the network and a running community, or a deck exported to a file? + Default: the file first, the feed after. +6. Which receiving card is the reference, and who has one on the bench? + Default: the Colorlight 5A-75B, and row 11 waits for someone who has one. +7. Encoders on the Pi's own header, beside a HAT? Philipppp's Pi 4 has four + knobs by its HAT, wired in a way the site does not say. Default: USB only. +8. What does `/api/status` say on a player, whose ESP32 fields have nothing + behind them? Default: those fields are left out, and none is added. +9. Does the player move to proposed with the firmware path at the cleanup's + gate (rfc-cleanup.md §2.12)? Default: no. It stays a draft until you say. diff --git a/docs/voice-banned.txt b/docs/voice-banned.txt new file mode 100644 index 00000000..e0a9a4c6 --- /dev/null +++ b/docs/voice-banned.txt @@ -0,0 +1,34 @@ +# Words and phrases that do not go into anything a person reads here. +# Why: docs/voice.md. The rule: docs/rfc-cleanup.md §2.10. +# +# One entry per line, written as it would appear in a sentence. Case does not +# matter. An English entry matches whole words, so "here's" does not catch +# "there's", and covers the word's other forms ("ensure" catches "ensures" +# and "ensuring"). A Korean entry matches anywhere in the text. A line that +# starts with # is a comment. +# +# check:voice (scripts/voice-lint.ts) reads this file. Adding a word needs no +# change to the script. + +seamless +robust +leverage +comprehensive +streamline +empower +cutting-edge +ensure +utilize +delve +it's important to note +here's + +효율적으로 +원활하게 +강력한 +최적화된 +혁신적인 +다양한 +전반적으로 +참고로 +살펴보겠습니다 diff --git a/docs/voice.md b/docs/voice.md new file mode 100644 index 00000000..b4da5b23 --- /dev/null +++ b/docs/voice.md @@ -0,0 +1,105 @@ +# Writing voice + +The README and the build guide set the voice for English here. Read a page of +[README.md](../README.md) and of [BUILD_GUIDE.md](../BUILD_GUIDE.md) before +writing anything longer than a sentence. Where the two differ, write like the +README: it has no dash between clauses and no emoji callout, and the build +guide still has both. Korean has a standard of its own, at the end. + +## Name the thing + +Not "the power input" but "`J4`, the screw terminal". Not "a lot of LEDs" +but "8,192 points of light". A number carries its unit, and when it helps, +the thing it is measured against: + +> it holds the panel to a 2.4 A budget, the high-power USB-A rating, so a +> white-out frame lands at roughly half brightness instead, while an ordinary +> bright pattern (~1.9 A) never notices. + +A part is named as the BOM names it (`PEC11R-4220F-S0024`), and a knob gets +its place the first time: "K2, the top-left knob". + +## Say what happens + +The guides say what the reader will see, and what it means when it looks +wrong: "The dialog usually ends without asking for it, and nothing failed: +close it." A limit is stated as one, even after a fix: "1/120 and slow motion +still band". "Usually" appears where it is true and the reader can act on it, +not as a cushion under every sentence. + +## Short, and finished + +Sentences carry one point each. A long one is followed by a short one, and a +short one can open a section by itself: "Two licenses, in two separate +files." A step starts with what to do and then says why: "Buy the LED panel +the BOM links. It's verified, and the case is measured around its screw +holes." A paragraph covers one thing and stops when it is said. + +## Who is speaking + +"You" is the reader. "We" is everyone who makes Patternflow: "Every +Patternflow plays every pattern we make." "I" is SeungHun Lee, only on a page +that already speaks as him, and only for something he does: "I'll add your +pin to the build map." New text never gives him a memory or a feeling he did +not write himself. + +## Words with fixed meanings + +[EDITIONS.md](EDITIONS.md#the-three-words) defines the words for firmware +builds, and prose uses them as defined there. A bundle is a composition's +folder and nothing more; "addon" and "variant" stay only where they name the +wire. Brand spelling is hard rule 4 in [AGENTS.md](../AGENTS.md). + +## Side by side + +A sentence that reads as generated, then the one the README has instead: + +> Make sure to select the correct USB port, as using the wrong one may cause +> problems during setup. +> +> Use its left port, labelled `USB` (antenna up, ports toward you): the right +> one flashes too, but the Wi-Fi step never appears there. + +## Korean + +Korean keeps the ending its file already uses (해요체 or 합니다체) and reads +as if it was written in Korean first. Everything above holds in Korean too. +Neither the journal nor the web guide is the model. The journal is a diary, +and the guide's Korean was carried over from its English sentence by +sentence. The marks of carried-over Korean, as two published Korean editing +skills list them (DaleSeo's +[humanizer](https://github.com/DaleSeo/korean-skills/tree/main/skills/humanizer) +and epoko77-ai's [Humanize KR](https://github.com/epoko77-ai/im-not-ai)): + +- English prepositions turned into particles: `~에 대해`, `~를 통해`, + `~에 있어서`, `~에 의해`. The plain particle or the verb says it. +- An English verb on a subject that is not a person. "드라이버 IC가 + 결정해요" is "the driver IC decides". +- `가지고 있다` for have, and `~되어지다` for a passive that `~되다` + already is. +- `~할 수 있다` where the sentence can simply say the thing. +- A noun doing a verb's work: `확인을 진행해요` for `확인해요`. +- Pronouns and plurals the sentence does not need: `그`, `그것`, `이러한`, + `-들`. +- A comma wherever English would put one. Korean uses fewer + ([KatFishNet](https://aclanthology.org/2025.acl-long.1030/), ACL 2025). + +Two of the guide's lines in English and in Korean, then the same thing +written in Korean: + +> Its driver IC decides, not “HUB75E” in the title. +> +> 제목의 ‘HUB75E’가 아니라 드라이버 IC가 결정해요. +> +> 패널이 켜질지는 제목의 ‘HUB75E’가 아니라 드라이버 IC에 달려 있어요. + +> Take $100 as the floor, not the estimate +> +> 100달러는 예상치가 아니라 바닥이에요. +> +> 100달러는 아무 문제 없이 끝났을 때 드는 돈이에요. 보통은 더 들어요. + +Words that never go in are in [voice-banned.txt](voice-banned.txt), one per +line. The maintainer adds to it, and `check:voice` reads it from there once +it lands ([rfc-cleanup.md §2.10](rfc-cleanup.md#210-writing-voice)). A +sentence that needs one of them to stand up is a sentence to cut. diff --git a/firmware/README.md b/firmware/README.md index c7c29014..6f5e310f 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -156,10 +156,13 @@ Library Manager: > not the plain device. > The HUB75 library is also **vendored** under `patternflow/src/hub75/`, and that -> copy is the one that compiles — it carries Patternflow's additions -> (`resumeDMAoutput()` for sleep, the brightness hook the power clamp uses). The -> installed/cloned copy only needs to satisfy headers, so its exact version does -> not matter. See `src/hub75/VENDORED.md`. +> copy is the one that compiles. [`UPSTREAM.md`](patternflow/src/hub75/UPSTREAM.md) +> there names the upstream tag and commit it was copied from, and each upstream +> commit taken since. Every line Patternflow changed is in `patternflow.patch` +> beside it, and [`VENDORED.md`](patternflow/src/hub75/VENDORED.md) says why. +> After an edit under `src/hub75/`, rebuild the patch with the command in +> `UPSTREAM.md` instead of editing it. The installed/cloned copy only needs to +> satisfy headers, so its exact version does not matter. OSC uses the ESP32 Arduino core's built-in `WiFi` and `WiFiUdp` libraries, so it does not require an extra OSC library. The MQTT client (`pubsubclient`) and the HUB75 driver are vendored under `patternflow/src/`. @@ -945,7 +948,7 @@ A remembered pattern that is gone, or that no longer loads, falls back to Origin ## Audio-react, OSC, MIDI, MQTT -None of these is in the core. They are features, and each ships in an edition you install from [the shelf](https://patternflow.work/editions): audio-react, OSC and network MIDI in **Audio**, MQTT in **Performance**. What a client sends is a written contract in `docs/` — [`audio-ws-spec.md`](../docs/audio-ws-spec.md), [`osc-spec.md`](../docs/osc-spec.md), [`midi-spec.md`](../docs/midi-spec.md), [`mqtt-spec.md`](../docs/mqtt-spec.md) — and the guides are [`AUDIO_GUIDE.md`](../AUDIO_GUIDE.md) (sound in, MIDI, OSC) and [`docs/midi-ableton.md`](../docs/midi-ableton.md). The ready-made clients are the audio extension and phone app in [`tools/`](../tools/README.md) and the Max for Live bridge in [`integrations/ableton/`](../integrations/ableton/). +None of these is in the core. They are features, and each ships in an edition you install from [the shelf](https://patternflow.work/editions): audio-react, OSC and network MIDI in **Audio**, MQTT in **Performance**. What a client sends is a written contract in `docs/` — [`audio-ws-spec.md`](../docs/audio-ws-spec.md), [`osc-spec.md`](../docs/osc-spec.md), [`midi-spec.md`](../docs/midi-spec.md), [`mqtt-spec.md`](../docs/mqtt-spec.md) — and the guides are [`AUDIO_GUIDE.md`](../docs/AUDIO_GUIDE.md) (sound in, MIDI, OSC) and [`docs/midi-ableton.md`](../docs/midi-ableton.md). The ready-made clients are the audio extension and phone app in [`tools/`](../tools/README.md) and the Max for Live bridge in [`integrations/ableton/`](../integrations/ableton/). From a pattern's point of view every one of them is the user turning the four encoders: audio bands, OSC deltas and MIDI CCs arrive as `knobDeltas` (or pin a lane on the absolute parameter bus), so an encoder-driven pattern reacts with no transport-specific code. diff --git a/firmware/patternflow/config.h b/firmware/patternflow/config.h index c396dce9..af335c10 100644 --- a/firmware/patternflow/config.h +++ b/firmware/patternflow/config.h @@ -78,33 +78,37 @@ constexpr float EULER = 2.71828182845904523536f; // pattern with PFCanvas::setFrame(). See README.md. // Running a non-stock panel? Please report how it went, working or not: // https://github.com/engmung/Patternflow/issues/224 -#define PANEL_GEOM_128x64_SINGLE 1 // one 128×64 module (stock) -#define PANEL_GEOM_64x64_SINGLE 2 // one 64×64 module -#define PANEL_GEOM_64x64_CHAIN2 3 // two 64×64 modules daisy-chained → 128×64 +// +// The ids and the sizes come from shared/constants.json, which the site +// reads too. +#include "src/core_shared.h" +#define PANEL_GEOM_128x64_SINGLE PF_SHARED_PANEL_GEOM_128x64_SINGLE // one 128×64 module (stock) +#define PANEL_GEOM_64x64_SINGLE PF_SHARED_PANEL_GEOM_64x64_SINGLE // one 64×64 module +#define PANEL_GEOM_64x64_CHAIN2 PF_SHARED_PANEL_GEOM_64x64_CHAIN2 // two 64×64 modules daisy-chained → 128×64 // #define PANEL_GEOMETRY PANEL_GEOM_64x64_CHAIN2 #ifndef PANEL_GEOMETRY -#define PANEL_GEOMETRY PANEL_GEOM_128x64_SINGLE +#define PANEL_GEOMETRY PF_SHARED_PANEL_GEOM_DEFAULT #endif #if PANEL_GEOMETRY == PANEL_GEOM_64x64_SINGLE - #define PANEL_PHYS_W 64 - #define PANEL_PHYS_H 64 - #define PANEL_CHAIN 1 - #define PANEL_RES_W 64 - #define PANEL_RES_H 64 + #define PANEL_PHYS_W PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_W + #define PANEL_PHYS_H PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_H + #define PANEL_CHAIN PF_SHARED_PANEL_GEOM_64x64_SINGLE_CHAIN + #define PANEL_RES_W PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_W + #define PANEL_RES_H PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_H #elif PANEL_GEOMETRY == PANEL_GEOM_64x64_CHAIN2 - #define PANEL_PHYS_W 64 - #define PANEL_PHYS_H 64 - #define PANEL_CHAIN 2 - #define PANEL_RES_W 128 - #define PANEL_RES_H 64 + #define PANEL_PHYS_W PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_W + #define PANEL_PHYS_H PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_H + #define PANEL_CHAIN PF_SHARED_PANEL_GEOM_64x64_CHAIN2_CHAIN + #define PANEL_RES_W PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_W + #define PANEL_RES_H PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_H #else - #define PANEL_PHYS_W 128 - #define PANEL_PHYS_H 64 - #define PANEL_CHAIN 1 - #define PANEL_RES_W 128 - #define PANEL_RES_H 64 + #define PANEL_PHYS_W PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_W + #define PANEL_PHYS_H PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_H + #define PANEL_CHAIN PF_SHARED_PANEL_GEOM_128x64_SINGLE_CHAIN + #define PANEL_RES_W PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_W + #define PANEL_RES_H PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_H #endif #if PANEL_RES_W != (PANEL_PHYS_W * PANEL_CHAIN) diff --git a/firmware/patternflow/console/midi.html b/firmware/patternflow/console/midi.html index 08f51d1b..b5a249a6 100644 --- a/firmware/patternflow/console/midi.html +++ b/firmware/patternflow/console/midi.html @@ -42,8 +42,8 @@ .sens .top b{font-size:13px;font-weight:600} .sens .top label{margin-left:auto;font-size:12px;color:var(--muted);display:flex;gap:6px;align-items:center;cursor:pointer} .sens .top label input{accent-color:var(--led)} -/* The readout column is in its own font's ch: the longest reading is 29 - (1/16 at 101.6 turns), plus room for a fallback font's wider × and ·. +/* The readout column is in its own font's ch: the longest reading is 28 + (1/16 at 84.7 turns), plus room for a fallback font's wider × and ·. Narrower, it spilled past the page edge; on a phone it goes under the slider instead. */ .g{display:grid;grid-template-columns:28px minmax(0,1fr) 32ch;align-items:center;gap:12px;padding:5px 0; @@ -201,9 +201,9 @@

The map

return mul>1?0:GAINS.length-1; } function gainText(g){return g[0]>1?('\u00d7'+g[0]):(g[1]>1?('1/'+g[1]):'1 : 1')} -// 20 detents a turn; 127 steps end to end. +// 24 detents a turn (PEC11R-4220F-S0024); 127 steps end to end. function rowText(g,abs){ - var perTurn=20*g[0]/g[1]; + var perTurn=24*g[0]/g[1]; var t=gainText(g)+' \u00b7 '+(Math.round(perTurn*10)/10)+'/turn'; if(abs)t+=' \u00b7 '+(Math.round(127/perTurn*10)/10)+' turns'; return t; diff --git a/firmware/patternflow/features/midi/core_midi.h b/firmware/patternflow/features/midi/core_midi.h index c9cf381e..1efac19b 100644 --- a/firmware/patternflow/features/midi/core_midi.h +++ b/firmware/patternflow/features/midi/core_midi.h @@ -66,8 +66,8 @@ inline bool runtimeEnabled = true; // Outbound sensitivity: detents per relative-CC step. A DAW moves a mapped // parameter a fixed amount per step, and one detent per step turned out to be // a lot of parameter per wrist on the first Live session - the panel's -// encoders have 20 detents a turn, so at 1 a full turn is 20 steps and at 4 -// it is 5. Remainders accumulate, so slow turns still arrive. Runtime, via +// encoders have 24 detents a turn, so at 1 a full turn is 24 steps and at 4 +// it is 6. Remainders accumulate, so slow turns still arrive. Runtime, via // POST /api/midi?outDiv=N, persisted; PF_MIDI_OUT_DIVISOR is the default. #ifndef PF_MIDI_OUT_DIVISOR #define PF_MIDI_OUT_DIVISOR 1 diff --git a/firmware/patternflow/features/midi/midi_index.h b/firmware/patternflow/features/midi/midi_index.h index 2e3961dc..b49c28fa 100644 --- a/firmware/patternflow/features/midi/midi_index.h +++ b/firmware/patternflow/features/midi/midi_index.h @@ -52,8 +52,8 @@ cursor:pointer;text-align:left;font-family:inherit;color:inherit} .sens .top b{font-size:13px;font-weight:600} .sens .top label{margin-left:auto;font-size:12px;color:var(--muted);display:flex;gap:6px;align-items:center;cursor:pointer} .sens .top label input{accent-color:var(--led)} -/* The readout column is in its own font's ch: the longest reading is 29 - (1/16 at 101.6 turns), plus room for a fallback font's wider × and ·. +/* The readout column is in its own font's ch: the longest reading is 28 + (1/16 at 84.7 turns), plus room for a fallback font's wider × and ·. Narrower, it spilled past the page edge; on a phone it goes under the slider instead. */ .g{display:grid;grid-template-columns:28px minmax(0,1fr) 32ch;align-items:center;gap:12px;padding:5px 0; @@ -211,9 +211,9 @@ function gainIndex(mul,div){ return mul>1?0:GAINS.length-1; } function gainText(g){return g[0]>1?('\u00d7'+g[0]):(g[1]>1?('1/'+g[1]):'1 : 1')} -// 20 detents a turn; 127 steps end to end. +// 24 detents a turn (PEC11R-4220F-S0024); 127 steps end to end. function rowText(g,abs){ - var perTurn=20*g[0]/g[1]; + var perTurn=24*g[0]/g[1]; var t=gainText(g)+' \u00b7 '+(Math.round(perTurn*10)/10)+'/turn'; if(abs)t+=' \u00b7 '+(Math.round(127/perTurn*10)/10)+' turns'; return t; @@ -297,448 +297,449 @@ PF.status(function(s){ )HTML"; // GENERATED by firmware/toolchain/console_pages.py build — do not edit. -// gzip -9 of MIDI_INDEX_HTML (18973 bytes → 7026 bytes), served with Content-Encoding: gzip. +// gzip -9 of MIDI_INDEX_HTML (18993 bytes → 7047 bytes), served with Content-Encoding: gzip. static const uint8_t MIDI_INDEX_HTML_GZ[] PROGMEM = { - 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0xc5,0x5c,0xcd,0x72,0xdb,0xc8, - 0x76,0xde,0xeb,0x29,0xda,0xf4,0x1d,0x81,0x18,0x83,0x20,0x41,0x4b,0xb2,0x0c,0x0a, - 0x72,0x64,0xc9,0x9e,0x38,0x1e,0xcf,0xb8,0x2c,0x4f,0x4d,0xd5,0x38,0x5e,0x80,0x44, - 0x93,0xc4,0x15,0x08,0x20,0x40,0x53,0xb2,0x2e,0xcd,0xaa,0xbb,0x4a,0x65,0x9d,0xa4, - 0x2a,0x6f,0x90,0x7d,0x56,0xd9,0xdf,0xbc,0xc9,0x3c,0x49,0xbe,0x73,0xba,0x01,0x82, - 0x3f,0xb2,0x35,0x93,0xa9,0x4a,0xcd,0x98,0x22,0x1a,0xdd,0xa7,0x4f,0x9f,0xdf,0xaf, - 0x4f,0x03,0x3c,0x79,0x10,0x65,0x23,0x75,0x9b,0x4b,0x31,0x55,0xb3,0xe4,0x74,0xef, - 0x84,0xfe,0x88,0x24,0x4c,0x27,0x41,0x4b,0xa6,0x2d,0x6a,0x90,0x61,0x74,0x7a,0x32, - 0x93,0x2a,0x14,0xa3,0x69,0x58,0x94,0x52,0x05,0xad,0xb9,0x1a,0x77,0x8e,0xe9,0x66, - 0x39,0x2a,0xe2,0x5c,0x89,0xb2,0x18,0x05,0xad,0x6e,0x3e,0xee,0x8c,0xb2,0xb4,0xcc, - 0x12,0xe9,0xfe,0xb9,0x7c,0x36,0x0d,0xe4,0x78,0x3c,0x8e,0x0e,0x7b,0x87,0xad,0xd3, - 0x93,0xae,0xee,0x78,0x6a,0x06,0x9c,0xc6,0xe3,0xf6,0x83,0x9b,0x38,0x8d,0xb2,0x1b, - 0xf7,0xed,0x4b,0xbb,0x3d,0x9e,0xa7,0x23,0x15,0x67,0x69,0xdb,0x5e,0x5c,0x87,0x85, - 0xf8,0x39,0xd0,0xf7,0x9c,0x9f,0x02,0xab,0x1b,0xe6,0x71,0xb7,0x54,0xa1,0x9a,0x97, - 0x96,0xf3,0x7d,0xf0,0xe1,0xa3,0xf3,0x26,0xe8,0xcb,0xa7,0xce,0xa5,0xf3,0x8b,0xf3, - 0x36,0xf8,0x19,0xc3,0x83,0x05,0xdd,0x96,0xbe,0x95,0xc4,0xd7,0xd2,0x72,0xa2,0xb8, - 0x50,0xb7,0xfe,0x03,0xcf,0xb9,0xf6,0xd3,0x79,0x92,0x38,0x13,0xa9,0xfc,0xef,0x9c, - 0x38,0x1d,0x27,0xf1,0x64,0xaa,0xfc,0x1f,0xe6,0xb3,0xa1,0x2c,0x9c,0x69,0x96,0x44, - 0xfe,0x8f,0xc3,0x3f,0xcb,0x91,0x72,0xca,0xf0,0xd6,0xaf,0x39,0x50,0xce,0x95,0x23, - 0xed,0x85,0xdc,0xdf,0x6f,0x4b,0x57,0xc9,0x4f,0xea,0x3c,0x4b,0x95,0x4c,0x55,0xa0, - 0xec,0xa5,0x33,0x9c,0x97,0x8d,0xae,0x43,0x27,0xb7,0x17,0xd5,0x95,0x18,0x83,0xf9, - 0xa1,0x1b,0xc5,0x65,0x38,0x4c,0x64,0x14,0x3c,0xf0,0x96,0xcd,0xab,0xde,0x20,0x77, - 0xd5,0x54,0xa6,0xed,0xb1,0x33,0xb6,0x07,0x85,0x54,0xf3,0x22,0x15,0xf9,0xd2,0xc9, - 0xb3,0x24,0x59,0x51,0x2c,0x9d,0x99,0x33,0x72,0x32,0x2d,0x05,0xe5,0x8c,0x9d,0x49, - 0xe0,0x0d,0xea,0x19,0xde,0x61,0x86,0xc9,0xfe,0xfe,0x83,0x31,0x78,0x1b,0x07,0x9e, - 0x33,0x4a,0x64,0x58,0xbc,0x8f,0x67,0x32,0x9b,0xab,0xb6,0xb2,0x9d,0x76,0xe9,0x8e, - 0xc2,0x24,0x79,0x56,0xb6,0x6d,0xff,0xbb,0x76,0x69,0xdb,0x66,0xc6,0x8a,0x7a,0x64, - 0x2f,0x46,0xed,0xc8,0x21,0xa9,0x60,0x2d,0x75,0xb3,0xa4,0x66,0x16,0x95,0xb4,0x97, - 0x66,0xcc,0x6b,0xe7,0xb5,0x6d,0x2f,0xeb,0x99,0x5f,0x63,0xe6,0x71,0xd0,0x1b,0x6c, - 0xcc,0x38,0x00,0x37,0x6d,0x15,0xc0,0x24,0xaa,0xb6,0x77,0xce,0x0c,0xe3,0xb2,0xfd, - 0xfd,0xcc,0x1d,0xc7,0x45,0xa9,0x82,0x00,0x82,0x78,0x86,0xe1,0x3e,0x98,0x37,0xcb, - 0x86,0xba,0xb2,0xdc,0x6f,0xa8,0x7c,0xb2,0x4d,0x79,0xe9,0x80,0xe8,0xaa,0x4f,0x6a, - 0x2f,0x66,0x41,0x3a,0x18,0x7f,0xfe,0x0c,0x52,0x4b,0x27,0xcd,0x6e,0xfc,0x77,0x4b, - 0xf4,0x61,0xb3,0x58,0x75,0x1b,0x61,0x25,0xfb,0xfb,0xdf,0xbb,0xf9,0xbc,0x9c,0xe2, - 0x62,0x70,0xf9,0x0c,0x97,0xa3,0xf6,0xa5,0xed,0xff,0xf2,0xec,0x17,0x17,0xa3,0xc0, - 0xc7,0x2f,0xc1,0x5b,0x97,0x84,0xde,0xfe,0xc9,0x79,0xe3,0x5c,0x80,0xd8,0x4d,0xa8, - 0x46,0xd3,0xcb,0x0d,0x4a,0x33,0x7b,0xf1,0xd6,0xd5,0xe4,0xc1,0xf7,0x2f,0x2e,0xb8, - 0x69,0xbf,0x09,0xde,0x84,0x6a,0xea,0xce,0x62,0xdc,0x76,0xde,0xd8,0x3c,0x34,0x56, - 0x2f,0xb3,0xe2,0x42,0x5e,0xc7,0x23,0xb9,0x1a,0x0c,0xfd,0x65,0x41,0xf6,0xf9,0xf3, - 0x62,0x59,0x29,0x3a,0x95,0x37,0xe2,0x6d,0x91,0xcd,0xe2,0x52,0xae,0xd4,0x71,0xeb, - 0xa4,0x5a,0xd1,0xd3,0x15,0x4b,0x7d,0xf9,0x78,0xa5,0x98,0xd2,0x5e,0x94,0x50,0x77, - 0x3b,0x73,0x87,0xf3,0x38,0x89,0xf6,0xf7,0x4b,0xfd,0x25,0x08,0x4c,0x8b,0x0d,0xf1, - 0x4f,0x5d,0x12,0x67,0xdb,0x76,0x6e,0x49,0xe3,0x4b,0x7b,0xd0,0xd0,0x46,0x43,0xc6, - 0x55,0xb7,0x41,0xda,0x7e,0x51,0x14,0x59,0xd1,0xb6,0x94,0xee,0x64,0xd1,0x42,0x32, - 0xd7,0x5c,0x7d,0xfe,0xfc,0x54,0x1e,0x80,0xca,0x72,0xb9,0x32,0xbb,0xef,0xda,0x73, - 0x32,0x49,0xb3,0x94,0xb1,0x84,0xb8,0xb8,0x65,0xc3,0xba,0x0a,0x7b,0x41,0x5e,0x5d, - 0xb8,0xd9,0x95,0xad,0xa6,0x45,0x76,0x23,0xf4,0x44,0x85,0x91,0x63,0x6d,0xf5,0x6c, - 0x1d,0xe4,0x59,0xcf,0x0a,0xfe,0x03,0xa5,0x14,0x08,0x18,0xc4,0xe6,0xb2,0x61,0x73, - 0x17,0xb4,0x7e,0x50,0xc4,0xe7,0x25,0xfc,0x3c,0x1f,0x6b,0x1d,0x05,0xe5,0xe0,0x7b, - 0x77,0x9c,0x15,0x2f,0x42,0xb0,0x51,0x4f,0x3e,0x86,0x7d,0xa2,0x27,0x31,0xbe,0xb4, - 0xdb,0x76,0x1d,0x6f,0xf6,0x74,0xe0,0x4a,0xc3,0x99,0x0c,0x5a,0xd7,0xb1,0xbc,0xc9, - 0xb3,0x42,0xb5,0xc4,0xc8,0xf8,0x74,0xeb,0x26,0x8e,0xd4,0x34,0x88,0x58,0x81,0x1d, - 0xbe,0x40,0xa4,0x88,0x55,0x1c,0x26,0x9d,0x12,0xbe,0x24,0x03,0x8f,0xa2,0x9c,0x8a, - 0x55,0x22,0x4f,0xdf,0x86,0x4a,0xc9,0x02,0x71,0x04,0x4b,0xeb,0x88,0x37,0xaf,0x2e, - 0x5e,0x9d,0x74,0xf5,0x1d,0xc4,0x41,0x75,0x4b,0x7f,0xfd,0x22,0xcb,0xd4,0xa2,0xd3, - 0x19,0x15,0x32,0x9c,0xf9,0x0f,0x7b,0xe7,0xbd,0xe7,0xbd,0xa7,0x83,0x4e,0x27,0x4e, - 0xaf,0xfc,0x87,0x2f,0x2e,0x5e,0x3c,0xb9,0x78,0x8e,0xab,0xd9,0x5c,0xc9,0xc8,0x7f, - 0x78,0x7c,0x76,0xdc,0x7f,0xd2,0xc7,0xf5,0x38,0x8c,0x53,0xe5,0x3f,0x3c,0x3c,0x3b, - 0x3c,0x3c,0x38,0x1a,0xec,0x75,0x3a,0xc5,0x3c,0x91,0xfe,0xc3,0xfe,0x41,0xdf,0xf3, - 0x8e,0x07,0xfa,0xb2,0x53,0x66,0x63,0xf4,0xf1,0x9e,0x7b,0x4f,0xbd,0x03,0xb4,0x25, - 0x44,0xe1,0xe5,0xcb,0xc3,0xf3,0xfe,0x0b,0x5c,0x65,0x20,0x7f,0xf8,0xe4,0xf9,0xf1, - 0x93,0x97,0xb8,0xb8,0x09,0x8b,0xd4,0x7f,0x78,0xf1,0xf4,0xac,0xf7,0xf8,0x25,0x51, - 0xcb,0xc3,0x54,0x26,0x18,0xfa,0xd8,0xf3,0xbc,0x1e,0x35,0x94,0x61,0x5a,0xfa,0xd6, - 0x2b,0x88,0xa0,0xb0,0x9c,0x79,0xcc,0xd7,0x9d,0x52,0x16,0xf1,0xd8,0x29,0x6f,0x4b, - 0x25,0x67,0x9d,0x79,0xec,0xac,0x1a,0x69,0xc8,0x2c,0x4b,0x33,0xdf,0xfa,0x07,0xa9, - 0x9e,0x17,0x60,0xb6,0x14,0x6f,0x70,0xcd,0x63,0xe9,0x46,0x99,0x87,0x23,0xe9,0x5c, - 0xbe,0xa4,0xc6,0xce,0x3b,0x39,0x99,0x27,0x61,0xe1,0xbc,0x91,0x69,0x92,0x39,0xf5, - 0xed,0xe5,0xde,0xb7,0x8b,0x61,0xf6,0xa9,0x53,0xc6,0x7f,0x89,0xd3,0x89,0x3f,0xcc, - 0x8a,0x48,0x16,0x1d,0xb4,0x2c,0xf7,0x86,0x59,0x74,0xbb,0x98,0x85,0xc5,0x24,0x4e, - 0xfd,0xde,0x60,0x18,0x8e,0xae,0x26,0x45,0x36,0x4f,0x23,0x1f,0xbe,0xd1,0x36,0xb2, - 0xb4,0x07,0xa3,0x2c,0xc9,0x0a,0xd3,0x04,0x71,0xda,0x83,0x31,0x74,0x08,0xd1,0xcd, - 0xe2,0xe4,0xd6,0x34,0x13,0xcb,0xf6,0x60,0x2f,0x89,0x53,0xd9,0x99,0x4a,0x8e,0xf6, - 0x9e,0x7b,0x38,0xe8,0xdc,0xc8,0xe1,0x55,0x8c,0xbe,0x34,0xa0,0x9c,0x41,0x43,0x53, - 0x62,0x21,0x4c,0x49,0xcd,0x71,0x58,0xca,0x68,0xb9,0xe7,0xde,0x14,0x61,0x0e,0x26, - 0x3e,0x69,0x23,0xf0,0x8f,0x8e,0x7b,0xf9,0xa7,0x41,0xc5,0x94,0x08,0xe7,0x2a,0x1b, - 0xe4,0x61,0x14,0xd1,0xc0,0xc7,0xfd,0xfc,0x93,0xe8,0xe3,0xbe,0x38,0x3a,0xc8,0xc1, - 0x3f,0x25,0x48,0x59,0x2c,0x10,0xeb,0xf3,0x84,0x32,0x49,0x22,0x3f,0x0d,0x40,0x78, - 0x92,0x76,0x62,0xc8,0xb2,0xf4,0x47,0x92,0x24,0x3d,0x98,0x84,0xb9,0x7f,0x0c,0xa2, - 0x86,0x0c,0xd6,0xae,0x54,0x36,0xf3,0x3d,0x50,0x1b,0xec,0xd5,0xf2,0xd0,0x6d,0xa0, - 0x8d,0x34,0x1a,0x47,0x42,0x2f,0x8c,0x0c,0xc0,0x06,0x93,0x11,0x8c,0x4b,0xf3,0xf7, - 0x04,0x83,0xcc,0x12,0xe9,0xeb,0x96,0xd0,0x60,0x1e,0xf6,0xc0,0x10,0x2d,0xc2,0x28, - 0x46,0x6c,0xf3,0x98,0x57,0x6f,0xa1,0xc5,0x10,0xff,0x45,0xfa,0xde,0x21,0x86,0xf2, - 0xe5,0x8d,0x26,0x75,0xd4,0xeb,0xd5,0x6b,0x1e,0xd0,0x3a,0x7c,0x0f,0xb3,0x96,0xf3, - 0xe1,0x62,0x5b,0xd8,0xa4,0x59,0xa3,0x04,0x4d,0x0c,0xf4,0xd7,0x94,0xc4,0x56,0x0d, - 0xae,0x4b,0xc9,0x2e,0x6a,0x34,0xdc,0xa1,0xd8,0xdf,0x3f,0x62,0x5e,0xfa,0x8b,0x8d, - 0xe1,0x9b,0xbc,0x24,0x92,0x3c,0xae,0x43,0x06,0x44,0x82,0x77,0x7b,0x4f,0xe5,0x6c, - 0x40,0x21,0xa3,0xa3,0x0a,0xe8,0x1a,0x51,0x60,0xe6,0xcf,0xf3,0x5c,0x16,0x23,0x68, - 0x71,0xb0,0xd7,0x9c,0x9d,0x7d,0xcc,0x5e,0x69,0xb0,0x27,0xbc,0x1e,0x4d,0xea,0x22, - 0xcf,0x44,0x0b,0x2d,0x99,0xdd,0x72,0xde,0x16,0x26,0x3b,0x90,0x5d,0xeb,0xdf,0x83, - 0xd6,0x85,0x77,0x54,0x53,0x13,0xc3,0x5a,0xf7,0xc3,0x24,0x1b,0x5d,0x35,0x85,0x72, - 0xb0,0xbd,0xaa,0x6a,0x54,0x5e,0xd9,0x3c,0x91,0x03,0x83,0xcd,0x61,0xfd,0x0d,0x59, - 0x9a,0xd5,0xac,0x5b,0xf6,0xc1,0xa1,0x21,0xe5,0x42,0xba,0x46,0xd7,0xcd,0x41,0x88, - 0xc3,0x75,0x87,0xf1,0x78,0x57,0x0f,0xbd,0xde,0x8c,0xc4,0x0b,0xc8,0xe4,0x1e,0xa1, - 0xfb,0xbc,0x1c,0xae,0x5b,0x32,0x99,0xad,0xb7,0x72,0x06,0xd6,0x1f,0x5f,0x57,0x76, - 0x4c,0x0d,0x64,0xd7,0x86,0x3e,0xdf,0xdf,0x21,0x57,0x0e,0x60,0xf0,0xce,0xdf,0x61, - 0x49,0x7a,0xf5,0x9a,0x39,0x01,0x63,0x48,0x7d,0xc6,0x16,0x9d,0xd1,0x14,0x39,0x70, - 0xb1,0xcb,0xe6,0xdc,0xd1,0x34,0x43,0x44,0xaf,0x57,0x32,0x29,0xe2,0x68,0xb5,0x12, - 0xba,0xea,0xc0,0x35,0x71,0x4b,0x49,0x12,0xc8,0x7c,0x86,0x80,0xe8,0x8d,0x0b,0x81, - 0x7f,0x3c,0x78,0x43,0xa1,0xff,0x37,0x73,0xa1,0x70,0xa1,0xbd,0x7c,0x34,0x2f,0x4a, - 0xf0,0x9a,0x67,0x31,0x47,0x04,0xb6,0x64,0x8e,0x14,0x7e,0x22,0xc7,0x6a,0x2d,0xa2, - 0xc5,0xe9,0x14,0xb1,0x57,0x19,0x39,0x98,0x2b,0xe6,0xcd,0x9f,0x66,0xd7,0x08,0x37, - 0x3b,0xd4,0x59,0xcb,0x69,0x34,0xbd,0xc3,0x24,0x4c,0x58,0x40,0x18,0x9e,0x86,0xc0, - 0xd7,0xa0,0x0b,0xb4,0x40,0xc6,0x47,0xfe,0x01,0x36,0x57,0xbd,0x98,0xca,0x97,0x2c, - 0xfb,0xf1,0x9d,0xb1,0xa3,0x8a,0x62,0x7d,0xf6,0x10,0x50,0xc9,0x57,0xd1,0xfd,0x37, - 0xdb,0x38,0xc5,0x1f,0x99,0x96,0xcd,0xe8,0xe1,0x71,0xd4,0xe5,0x66,0xe1,0xa2,0xe1, - 0xee,0xc8,0x3b,0x44,0x58,0x20,0x7a,0xb5,0xea,0x9b,0xc3,0xc4,0x70,0xf1,0xe5,0xe5, - 0xac,0x75,0x4e,0xc2,0xa1,0x4c,0x2a,0x2e,0x48,0x5b,0x3e,0xe7,0x84,0xaf,0xaf,0x67, - 0xcb,0x99,0x10,0x38,0x76,0xa5,0x87,0x75,0xdb,0xd8,0x9e,0x5b,0xc4,0x69,0x3e,0x57, - 0x8b,0x70,0x44,0xfd,0xb7,0x94,0xba,0xdc,0xeb,0x7e,0x2b,0xde,0x4f,0xa5,0x40,0xb2, - 0x8c,0x80,0xe3,0x84,0xb6,0x6a,0x11,0x97,0x18,0x27,0x62,0x55,0x8a,0xec,0x06,0xd8, - 0x0d,0xdc,0x5a,0x25,0xb6,0x71,0xbe,0x00,0x76,0x13,0x49,0x96,0x4e,0x64,0xa9,0x78, - 0x0c,0x2c,0x95,0x3a,0xf7,0x9f,0xee,0x09,0x21,0xda,0x5e,0xd7,0x3b,0x12,0xa1,0x42, - 0xcc,0xf4,0xdc,0x23,0x41,0xa8,0xad,0xb4,0x1d,0x91,0x27,0xf3,0x52,0x00,0xe1,0xcc, - 0x40,0xa8,0x10,0xa1,0x18,0x63,0xb3,0x41,0xd6,0x5f,0xd1,0x45,0x6a,0x92,0x85,0xf8, - 0x9f,0xff,0x10,0x61,0x1a,0x89,0xbf,0xfd,0xb7,0x4b,0xa4,0x7e,0x08,0x01,0x02,0x6f, - 0xb0,0xe1,0x02,0x13,0xf0,0xdd,0x38,0x01,0xb7,0x22,0x0f,0x31,0x2b,0x71,0x90,0x87, - 0x13,0x29,0x64,0x34,0x91,0x03,0x01,0xc4,0x17,0x8a,0x7c,0x9a,0xa5,0x92,0x7a,0x4e, - 0x32,0x59,0x0a,0xb8,0x14,0xe8,0xa1,0x1f,0x11,0x2a,0x13,0xa6,0x0e,0x8b,0x55,0x14, - 0xd1,0xc4,0xb7,0xdd,0x3d,0x77,0xb2,0xe1,0xe0,0x3b,0xfd,0xba,0x8f,0xe0,0x24,0x00, - 0xda,0x91,0xdc,0xdb,0x3d,0x07,0x1e,0x6e,0x8b,0xc7,0xfd,0xd1,0xf4,0xae,0x14,0xcd, - 0x7a,0xac,0x7c,0xf7,0x90,0x42,0xf3,0x7d,0x63,0x16,0x74,0x36,0x11,0xee,0xd5,0xee, - 0x80,0x34,0xd1,0xfa,0xfb,0x40,0x5b,0xed,0x00,0xc9,0x6b,0x22,0x3f,0x9a,0x54,0xee, - 0xf5,0x7a,0xdf,0xac,0xb2,0xee,0x1d,0xfa,0x1d,0xa8,0x6c,0x3e,0x9a,0x76,0x42,0x4e, - 0xa5,0x3e,0x22,0x4c,0xe7,0x56,0x4f,0x57,0x2c,0x76,0xd8,0x5c,0x23,0xb0,0x14,0x64, - 0xcd,0x83,0x9b,0x29,0xd6,0xc9,0x79,0x54,0xfa,0xd8,0x06,0x01,0xeb,0x90,0x81,0x11, - 0xc0,0x5d,0x77,0x9e,0x3f,0xcf,0x4b,0x15,0x8f,0x6f,0x3b,0x06,0x1f,0xfb,0x3c,0xa2, - 0x33,0x94,0xea,0x46,0xca,0xb4,0x46,0x30,0xf7,0x10,0x47,0x6f,0x27,0x18,0xa8,0x96, - 0xd9,0xd7,0x19,0x4f,0x1c,0xb0,0x4b,0xfe,0xdd,0x4c,0x46,0x71,0xd8,0x5e,0x81,0xaf, - 0x03,0x02,0x5f,0xf6,0x02,0xea,0xbd,0xaf,0x46,0x07,0xb0,0xb1,0x0e,0xb1,0xd7,0x5b, - 0x6a,0xa1,0xf0,0x40,0xdd,0xdf,0xef,0x6f,0x06,0xda,0x25,0x16,0x7f,0x95,0x66,0xc3, - 0xf2,0x3e,0xe6,0x53,0xc8,0x5c,0x86,0xaa,0x7d,0xa0,0xe7,0xd9,0x99,0x0d,0x75,0x3c, - 0x22,0x8a,0x5f,0x02,0x16,0x26,0x01,0x7e,0x2d,0x5d,0xd0,0xda,0xe8,0x9f,0x09,0x57, - 0x44,0x55,0xb8,0xe9,0xe2,0xf7,0xcb,0xfc,0xfe,0x0a,0xae,0xa7,0x1b,0x86,0xc5,0xc2, - 0x84,0xe0,0x83,0x5d,0xe8,0x52,0x27,0xbe,0x86,0x08,0x18,0xd7,0x66,0x65,0xcc,0xd6, - 0x59,0x48,0x08,0x30,0xbe,0x96,0x4d,0x72,0x22,0x5e,0xd4,0xf7,0xc3,0x21,0x44,0x03, - 0x43,0x1d,0x70,0x18,0xed,0x0d,0x88,0x00,0x70,0xbf,0x4e,0x1a,0xbd,0x3b,0xb0,0xac, - 0xb6,0x8c,0xc3,0xde,0x37,0x35,0xd5,0xeb,0xfb,0xc8,0xe4,0xf1,0xba,0xaa,0x34,0x68, - 0x1b,0xc7,0x12,0xb8,0xe1,0x3e,0x78,0x7d,0x2d,0x18,0x1c,0x91,0xd5,0x56,0xc3,0x4d, - 0x42,0x60,0x90,0xcc,0xd9,0xd3,0xab,0x3d,0x63,0x35,0xf3,0xce,0xe4,0xac,0x87,0xeb, - 0x68,0xae,0x31,0xf6,0x00,0xb6,0x6c,0x4c,0x5f,0x67,0xc8,0x3a,0xfb,0xdf,0x63,0x85, - 0x0c,0x2c,0x2a,0x16,0xb1,0x17,0x10,0x4f,0x77,0x69,0xcc,0x18,0xd9,0xd6,0x26,0x6a, - 0xef,0xcb,0xd0,0x66,0x6d,0x03,0xd1,0x5f,0x09,0x4f,0xb3,0xef,0x8f,0xb3,0xd1,0xbc, - 0x5c,0x20,0xdd,0x50,0x92,0x45,0x60,0x41,0xa6,0xbd,0x03,0x74,0x6c,0x0c,0xf4,0x21, - 0xf9,0x91,0xa4,0x1a,0x9c,0x2c,0x76,0xc7,0x4b,0x1d,0xe9,0xca,0x75,0x2d,0xd1,0x47, - 0x87,0xa2,0x97,0x4f,0x1f,0x77,0x2a,0x6d,0xc3,0x3f,0x8f,0x75,0xde,0x0f,0xaf,0xa5, - 0x43,0x85,0x83,0x89,0x54,0x8e,0x3b,0x99,0x66,0xa5,0x5a,0x6c,0xcb,0x7a,0x25,0xd3, - 0xa6,0xd6,0xb9,0x61,0x4b,0x18,0x3b,0x52,0x36,0xe6,0xb8,0x2b,0x04,0x68,0xe8,0xb5, - 0xd3,0xb6,0x9b,0x02,0x30,0x8a,0xda,0x86,0x22,0x9a,0xe3,0xfb,0x22,0x51,0xd6,0xc5, - 0xa6,0xb6,0x49,0x09,0xbc,0xfe,0xdf,0x4f,0x66,0x65,0xc5,0x4c,0x48,0x63,0x51,0x23, - 0xce,0xbb,0x81,0xe9,0xd6,0x2a,0x6b,0x84,0x29,0x47,0x57,0xdb,0xdb,0x8d,0xe3,0xdd, - 0x08,0xe9,0xab,0xce,0xb5,0xe5,0xa9,0x4c,0xff,0x6b,0xc0,0xc9,0x4d,0x33,0x25,0x17, - 0x5f,0x00,0x72,0xeb,0xb9,0xeb,0x98,0x73,0x17,0x6f,0xec,0x76,0x6c,0xc7,0x88,0x16, - 0xb0,0x57,0x24,0x9d,0x28,0xe2,0xbf,0xbf,0x67,0xe7,0x6c,0x74,0x35,0x2b,0x27,0x8b, - 0xcd,0x4d,0xd7,0x3d,0xa9,0x51,0x40,0xa9,0x18,0x3b,0xba,0x2b,0x10,0x81,0xbe,0x9b, - 0x8f,0x3b,0xb2,0x58,0x77,0x41,0x96,0x4a,0x75,0x33,0x5b,0x87,0x33,0xbc,0xa5,0xfc, - 0x30,0x8d,0xa3,0x48,0xa6,0x1f,0x6b,0xc5,0x91,0x95,0x3c,0x88,0x67,0x54,0x5a,0x0b, - 0x53,0x55,0x43,0xd1,0xd1,0xb4,0xc8,0x66,0x92,0xf1,0x66,0x9c,0x8b,0x32,0xbc,0x05, - 0x42,0x9c,0xca,0xd4,0x00,0x3f,0x98,0xb9,0xa0,0x92,0x64,0x09,0xb4,0x58,0x02,0x20, - 0x42,0x6f,0x03,0x8d,0x4a,0x09,0x1c,0x5e,0x87,0xc9,0x1c,0x08,0x30,0x8a,0x67,0x8c, - 0xf4,0xe8,0x50,0x82,0xd9,0x19,0x8f,0x49,0xe8,0xe2,0x61,0xa9,0x9c,0xcd,0x36,0xde, - 0xe9,0x6e,0xb5,0x46,0xc9,0x76,0x47,0x9d,0xfb,0xeb,0x6d,0x2f,0x29,0x2e,0x4a,0x1a, - 0x15,0xa8,0xfb,0x6c,0x64,0x21,0x3f,0x60,0x8e,0xfb,0x6e,0x3d,0x9a,0x01,0x85,0x62, - 0xf4,0x3d,0x6b,0x3e,0xd5,0x54,0x51,0x9d,0x29,0xbe,0xee,0x03,0xd5,0x32,0x30,0x2a, - 0xda,0xd8,0x77,0x7d,0x3d,0x91,0x6c,0x02,0x47,0xac,0x72,0x97,0x05,0x28,0x3a,0xd4, - 0x68,0xc2,0xd7,0x95,0xcf,0x27,0x61,0x5e,0x4a,0xbf,0xfa,0xb2,0x41,0x1f,0x23,0xa7, - 0x8b,0x9d,0xbb,0xde,0xc6,0x26,0x72,0x03,0xd1,0x6c,0xd5,0x80,0x8e,0xef,0x5d,0x03, - 0x32,0x8e,0x5b,0x09,0xfe,0xc0,0x08,0xfe,0xfe,0x72,0x57,0xd1,0xa2,0x19,0x52,0x7e, - 0xcb,0xe0,0x01,0x42,0xa1,0x8a,0x81,0xb0,0xcd,0x52,0x61,0x4d,0x3b,0xbd,0x50,0x45, - 0x6b,0xc5,0x8c,0xdf,0x19,0x2d,0x76,0x00,0x7c,0x83,0x98,0xbc,0x6f,0xea,0x42,0x4d, - 0xa1,0xe3,0x01,0xa3,0xd5,0x71,0x86,0x40,0x55,0x34,0xc3,0xcb,0xe3,0xfe,0x46,0x4d, - 0xa7,0x99,0xf4,0xee,0xf2,0x85,0xdf,0x57,0xcf,0xa9,0x92,0x7c,0xb8,0xd8,0x25,0x11, - 0x72,0xd7,0x0f,0x51,0xa8,0xc2,0x0e,0xa2,0xc1,0x4c,0x06,0x7c,0x6c,0xf7,0x71,0x55, - 0x54,0x7f,0x79,0xf0,0xe2,0xe5,0x8b,0xa3,0x81,0xb9,0xee,0x53,0x15,0xfc,0xe5,0xf9, - 0xcb,0x33,0x34,0x0c,0x27,0x8d,0xbb,0xa6,0xea,0x5d,0xdf,0xe4,0x12,0xbc,0x77,0xe6, - 0x1d,0x73,0xf9,0xdc,0x94,0xe0,0x8f,0x9e,0x1f,0x1d,0x1e,0x1e,0xaf,0x4a,0xf0,0x4f, - 0xcf,0x9e,0x1e,0x1c,0xd3,0x68,0x9d,0xd0,0x1e,0xbe,0xe8,0x5d,0x3c,0x3d,0x3f,0x1f, - 0x54,0x15,0xf9,0x8b,0xa7,0x17,0xde,0x79,0x7f,0xbd,0x22,0xff,0xe2,0xf8,0x45,0xff, - 0xe2,0x68,0x67,0x45,0xbe,0xff,0xf2,0xf8,0xec,0xf0,0xb0,0xae,0xc8,0x3f,0x3f,0x3e, - 0xf6,0xfa,0x3d,0x62,0x3c,0x2c,0xa2,0x06,0x67,0xe3,0x49,0xc5,0xd8,0x17,0xf6,0x42, - 0xa6,0xa0,0x75,0x67,0xe9,0x6a,0x69,0xa2,0xda,0x17,0x37,0x31,0x7d,0xde,0xc4,0x6c, - 0x21,0x60,0xae,0xed,0x2c,0xd7,0x70,0xed,0x7a,0xd9,0x67,0xbd,0xae,0x43,0x06,0xb4, - 0x86,0x62,0x57,0x81,0x00,0x3b,0xab,0x93,0xae,0x3e,0x0b,0x39,0xe9,0xea,0x83,0x63, - 0xaa,0xee,0x9f,0x9e,0x44,0xf1,0xb5,0x18,0x21,0xb2,0x97,0x41,0x8b,0x8c,0xb3,0x3a, - 0x57,0x96,0xc5,0xe9,0x09,0x15,0xf4,0xaa,0x7b,0x51,0xa6,0xf8,0xb0,0x18,0x4d,0xa7, - 0x27,0x53,0xef,0x54,0x1f,0xb2,0xe0,0xcb,0x5a,0xaf,0x72,0x3e,0x6c,0x89,0x38,0xc2, - 0x17,0xf4,0xee,0x54,0xdd,0xbb,0x86,0xe0,0xde,0xde,0x89,0x29,0x39,0x9f,0xee,0x09, - 0x71,0x32,0xed,0x9f,0x5e,0xca,0xb2,0xc4,0x25,0x7a,0xf4,0xb9,0xa9,0xc1,0x0c,0xe5, - 0x0c,0x4d,0x8b,0xbf,0x81,0xdd,0xfa,0xa2,0xa3,0x89,0x0f,0x4f,0x4f,0xf2,0x55,0x5b, - 0x4e,0xec,0xe5,0x44,0x65,0x9d,0xce,0xbc,0x34,0x2c,0xf1,0x17,0x9d,0x20,0x35,0xd3, - 0xa7,0x3f,0x5d,0x3e,0xaf,0x58,0xe4,0x45,0x98,0x5e,0x9d,0x35,0xde,0x41,0xca,0x7c, - 0x12,0x83,0x14,0xd4,0xb8,0x1f,0x59,0xe1,0xb8,0xc5,0xc7,0x0f,0xa3,0x0c,0x3a,0x95, - 0x4a,0x06,0x2d,0xe4,0xb1,0xd6,0x36,0x07,0xac,0x10,0x70,0xa7,0x8b,0x45,0xa0,0x80, - 0xa6,0x0e,0x8d,0x6f,0x9d,0x9e,0x63,0x28,0xec,0xbd,0x10,0x2a,0x83,0xc6,0xae,0x11, - 0x29,0x4e,0xba,0xdc,0x4d,0x53,0x01,0x1d,0xd6,0x23,0xcf,0x68,0xc6,0x88,0x06,0x40, - 0x0f,0x5a,0xd2,0x9d,0xb8,0xc2,0x7b,0xda,0x77,0xbd,0xa3,0x63,0xb7,0xe7,0xf6,0x1f, - 0x8b,0x5f,0xff,0xfa,0xef,0x02,0x46,0x86,0xf4,0x29,0xe8,0x10,0x94,0x28,0x0f,0x09, - 0xf8,0xa4,0x29,0x44,0x2f,0x49,0xa4,0x44,0x71,0x06,0x00,0x04,0xa5,0xca,0x51,0x3c, - 0x0b,0x93,0x96,0x80,0x55,0x27,0x32,0x9d,0xa8,0x69,0xd0,0xf2,0xf8,0xa1,0x00,0xb3, - 0xde,0xf5,0x85,0x18,0xe4,0xdf,0xaa,0x99,0x1b,0xce,0x61,0x76,0x80,0x0d,0x54,0x39, - 0x21,0xdd,0xcf,0x62,0x3a,0xc1,0x33,0xa6,0x00,0xc4,0xdd,0x3a,0xbd,0xc4,0x27,0x34, - 0xc5,0xfd,0x76,0x0f,0xd3,0x17,0xf5,0xb0,0x89,0x5e,0xa4,0x56,0x85,0x54,0xd3,0x18, - 0xd3,0xfd,0x54,0x4a,0x41,0xdf,0xc4,0xc8,0x88,0xeb,0x37,0x51,0xd4,0x78,0x58,0x93, - 0xe4,0xb3,0x6c,0x23,0x7a,0xfa,0xba,0x4e,0x68,0xa5,0xe6,0x2e,0xe9,0x79,0xd3,0x22, - 0x01,0xbc,0x34,0x15,0xfe,0x12,0x16,0x71,0xd8,0xa1,0xe7,0x1a,0x82,0x56,0x8e,0xd8, - 0xab,0x64,0x43,0x6c,0xb0,0x4b,0x33,0x86,0x50,0x67,0xeb,0xf4,0xe7,0x58,0x4d,0x45, - 0x58,0xb3,0x2f,0x48,0x36,0x91,0xd3,0x80,0x5b,0x59,0x4e,0x25,0x45,0xba,0x2e,0xb5, - 0x47,0x50,0x61,0x50,0x26,0x63,0xaa,0xbd,0x49,0xe4,0xab,0x5b,0x31,0x44,0x5a,0xe0, - 0xea,0x1d,0x01,0x35,0x6a,0x62,0x8e,0xa1,0xdf,0xa8,0x20,0xa4,0x46,0x5a,0x4f,0x33, - 0x51,0x48,0xa3,0x68,0x2e,0x1a,0xa6,0xa2,0x50,0x39,0x39,0xaa,0xc8,0x0a,0x71,0x36, - 0x8f,0xe2,0x8c,0xcf,0x46,0xc5,0xa5,0x54,0xf3,0xdc,0xd5,0x48,0xb0,0x62,0x69,0x1a, - 0x96,0x64,0x2a,0x84,0xc5,0x73,0xa5,0x6d,0x31,0x64,0x6d,0xf3,0x3c,0x6d,0x43,0xc8, - 0x17,0x27,0x72,0x76,0xfa,0xf3,0x34,0x83,0xc5,0xdc,0x56,0x46,0x45,0xe3,0x66,0xd2, - 0x17,0x67,0xe9,0x2d,0xe0,0xe6,0x49,0x17,0x3d,0x6c,0x57,0xd0,0x9a,0xa9,0xdc,0x89, - 0x26,0xa7,0xee,0x39,0x06,0xee,0xe4,0x65,0x56,0xf3,0xfa,0x2b,0x21,0xe8,0x05,0x95, - 0x22,0x89,0x4b,0xd8,0xa9,0x00,0x3f,0x27,0x04,0xd3,0x9b,0x87,0xbb,0x27,0x5d,0x6e, - 0x71,0x68,0x3d,0xc8,0x87,0xb4,0xfc,0xe1,0x2d,0x7d,0x2b,0x20,0x35,0x98,0x39,0xba, - 0xb8,0x26,0x08,0x9c,0x44,0xc9,0xb6,0x01,0x03,0x17,0x42,0x4b,0x91,0x3a,0x3d,0xd3, - 0x43,0xa0,0x30,0x85,0xeb,0x88,0xb5,0x4a,0x64,0xd8,0xf1,0xa3,0xe8,0x2e,0x07,0xa8, - 0xc7,0x9f,0x4f,0x43,0x2c,0x28,0x59,0x1b,0x3f,0x42,0xdb,0x7d,0xc7,0xbf,0x93,0x23, - 0x09,0xd3,0xa1,0x5a,0x2c,0x34,0x9e,0xaa,0x75,0x42,0xa9,0xda,0xa2,0x83,0x2f,0xf4, - 0x08,0x50,0xb7,0x8e,0x9f,0x5b,0xa1,0xf4,0x35,0x25,0x19,0xf1,0xeb,0x3f,0xff,0xab, - 0x39,0xff,0xde,0x8e,0xa8,0x3a,0x51,0x55,0x01,0xca,0xb8,0x4c,0x7d,0xcf,0x98,0x76, - 0x27,0x1c,0x96,0xad,0x75,0x47,0x42,0xe8,0x65,0xea,0x1a,0xfa,0x53,0xdc,0xad,0xdc, - 0x2e,0x3f,0x7d,0x5f,0x9b,0xf0,0x95,0x94,0xb4,0x63,0x10,0xbd,0x5f,0xff,0xfa,0x6f, - 0x5e,0xff,0x89,0xa8,0x8a,0x4a,0x02,0x00,0x50,0x70,0x5d,0x88,0xac,0x17,0x8b,0x8d, - 0x4a,0xe8,0xcd,0x25,0x6b,0x11,0x17,0x67,0x3f,0x73,0x89,0x9b,0x5a,0x48,0xe1,0x88, - 0xc1,0x40,0x35,0x71,0x1a,0xc2,0xe0,0x79,0x88,0x36,0x6b,0x3e,0x15,0x26,0x2b,0x8b, - 0x10,0x65,0x47,0x18,0x5b,0x07,0x04,0xaa,0x76,0x9f,0x01,0xf1,0x82,0x4d,0x56,0xfc, - 0x86,0x3f,0xdf,0xb5,0xc6,0x42,0x26,0x3b,0xd6,0xf8,0xce,0x54,0xc7,0xb0,0xf9,0xc1, - 0x52,0xd6,0xd7,0x79,0xc9,0x7c,0x3f,0xf2,0x44,0x57,0xfc,0xfa,0x2f,0xff,0xea,0xf1, - 0xa2,0xa8,0x9b,0x68,0x1f,0x1d,0x88,0xbf,0xfd,0x97,0x48,0x61,0xf1,0x2f,0xc1,0x0c, - 0xc5,0x16,0x72,0xe4,0x50,0xc1,0x43,0x72,0x81,0x51,0x09,0x99,0xa6,0x4c,0xc9,0x70, - 0x0b,0x7c,0xf9,0x94,0x27,0x31,0x36,0x37,0x80,0x64,0xe2,0x4d,0xf8,0xc9,0x11,0xef, - 0xa9,0x7a,0x7c,0x21,0x4b,0x20,0x51,0x2a,0xc5,0x8f,0xe6,0xd8,0x79,0xcd,0x78,0x7f, - 0xba,0xb5,0xa0,0x95,0x31,0x34,0x94,0x4a,0x67,0x11,0x3b,0x72,0x0e,0x60,0x21,0xaf, - 0x09,0x7c,0x93,0x1a,0xe0,0xcd,0xb7,0x9c,0x30,0x75,0x72,0x31,0x59,0x45,0x4b,0x80, - 0xb7,0xe2,0xc3,0xec,0x93,0x49,0xb6,0x40,0x63,0x08,0x9d,0xd4,0x26,0xa3,0x53,0x31, - 0xcb,0x20,0x8f,0x30,0xa1,0xb4,0x35,0xa7,0x2c,0x85,0x48,0x3a,0xa5,0xf8,0x6b,0xe8, - 0xec,0x36,0xf3,0x49,0x6b,0x1d,0x15,0x5c,0xb5,0x4e,0x5f,0x7b,0x55,0x42,0x6d,0x4e, - 0xcd,0xd5,0xf6,0x16,0x15,0x8c,0x83,0x56,0x8f,0xb3,0x10,0xf2,0x8f,0xd7,0x62,0xc9, - 0xe2,0x5b,0x4b,0x5b,0x5c,0xd0,0x3a,0x68,0x09,0x86,0x9d,0x57,0xd4,0x6d,0x9d,0x76, - 0xb1,0x99,0xab,0xef,0xc5,0x4d,0xff,0x0f,0xe2,0xc6,0xfb,0x43,0xb8,0x79,0xfc,0x07, - 0x71,0xd3,0xff,0x43,0xb8,0x39,0xf8,0x83,0xb8,0x79,0xfc,0x9b,0xb9,0xe1,0x63,0x0f, - 0x33,0xea,0x74,0x4c,0xc5,0x06,0x84,0xc7,0x63,0xed,0x8e,0x70,0x3c,0xf2,0x7e,0x0a, - 0x95,0x2b,0xc4,0x76,0xea,0x09,0x5f,0x78,0x6b,0x2d,0x63,0xaa,0x23,0x60,0x98,0x77, - 0x64,0xfa,0xd3,0x48,0xa2,0xb0,0x39,0xf5,0x4e,0x6f,0xaa,0x53,0xfc,0xd5,0xd7,0x73, - 0x7c,0x63,0x18,0x03,0x7c,0x33,0x90,0xbf,0x6e,0xaf,0x8d,0xda,0x5b,0x6b,0x68,0x3b, - 0xad,0x56,0xfa,0x7a,0x7d,0x05,0xe7,0xe7,0xa2,0x7f,0xb0,0x0e,0x3f,0x1b,0xa3,0x86, - 0x21,0xe4,0x78,0x12,0xe3,0x4e,0xbc,0xe3,0xee,0xb5,0x4e,0x1c,0x4d,0xc8,0x7a,0x7f, - 0x36,0xfa,0x5b,0x6c,0x1c,0xfe,0x7f,0xb0,0xf1,0x78,0x8b,0x8d,0xa3,0xff,0x0f,0x36, - 0x0e,0xb6,0xd8,0x78,0xf2,0xc7,0xb1,0x71,0x17,0x52,0xac,0x4d,0x48,0xe3,0xc6,0xef, - 0x29,0x1b,0x55,0x89,0xb4,0xa4,0x0c,0x49,0x68,0x89,0xb2,0x66,0x29,0x25,0x5f,0xcc, - 0x18,0xc6,0xdd,0xc2,0xc2,0xc3,0x42,0xd1,0xb9,0x30,0xd2,0x11,0x32,0x5e,0x8d,0x16, - 0xa9,0x8e,0x47,0xcd,0x34,0x8e,0x52,0xd8,0xa0,0xa2,0x60,0x61,0x78,0x78,0x25,0xa9, - 0x2e,0x2c,0x68,0x33,0x20,0xda,0x34,0x99,0x4f,0xd8,0x90,0xc0,0xa3,0x46,0x12,0xdf, - 0x23,0x19,0x20,0x45,0x61,0x3b,0x91,0x89,0xfd,0x70,0x96,0x0f,0x34,0x7e,0xa4,0x5b, - 0xef,0xab,0xc1,0x6f,0x30,0xd8,0x16,0xfa,0xe1,0x2d,0xcd,0x5f,0x1e,0x8f,0xae,0x80, - 0x2f,0x19,0x88,0x7d,0x09,0xb1,0x5c,0xde,0xc4,0x6a,0x34,0xad,0x91,0x8a,0xde,0x1e, - 0xd5,0xb9,0x1a,0x09,0xa8,0xf5,0xa5,0x44,0x45,0x49,0x5b,0xb3,0x43,0xc0,0x9f,0xc0, - 0x00,0xe3,0x90,0xc6,0xfe,0x69,0xa7,0x63,0x67,0xbf,0x19,0xbc,0xff,0x38,0x1e,0x1b, - 0x70,0xc3,0x8b,0xcb,0x20,0x67,0x82,0xeb,0x2c,0x5e,0x0d,0xbe,0x19,0xa1,0x33,0x48, - 0xf9,0xfa,0xa2,0x09,0x30,0x01,0x19,0xd4,0xab,0xe6,0x82,0x9d,0xb1,0x4c,0x85,0x3d, - 0xb7,0x9a,0x9e,0xbe,0x01,0x62,0x08,0x27,0x00,0x5a,0xf8,0x4e,0xd7,0xaf,0x52,0xd1, - 0x26,0x5c,0xc1,0x72,0xd7,0xa7,0x10,0xf5,0xbd,0x1f,0x21,0x9d,0xb6,0x06,0x60,0x74, - 0x97,0xba,0x99,0x9b,0x5d,0x50,0x6b,0x90,0x8d,0xd8,0x84,0x09,0x9c,0xf5,0xe1,0x60, - 0xb8,0xa4,0x26,0x46,0x75,0x1e,0xda,0x0e,0x1c,0x51,0x9d,0xfd,0xd5,0x00,0x8e,0x67, - 0x8b,0x01,0x73,0x23,0x31,0x4f,0x55,0x9c,0x00,0xdb,0x4d,0x69,0xcd,0xfc,0xcc,0x81, - 0x86,0x38,0x64,0xa9,0x7a,0x1f,0x71,0x11,0x63,0xeb,0xa1,0xb2,0xc2,0xaa,0x50,0xbb, - 0x3b,0x8b,0x23,0x03,0xd7,0xc5,0x4d,0x01,0x09,0xb3,0xf8,0x4a,0x42,0x35,0x66,0x72, - 0x60,0x3b,0xfd,0x7d,0x27,0xa7,0x07,0xc4,0xe9,0x93,0x9d,0x9c,0x56,0xa7,0x98,0x2b, - 0xec,0xb5,0xa3,0x1b,0xb3,0x49,0x9b,0xac,0x58,0x97,0xb5,0xd9,0xc6,0x81,0x80,0x4b, - 0xd2,0xdc,0x30,0xa3,0x5d,0xe9,0xce,0xb9,0x7f,0xa0,0x63,0x82,0x23,0x92,0xc1,0xd1, - 0x4a,0x4e,0xcf,0x35,0x8e,0xd4,0x84,0xdb,0xf8,0x16,0x88,0x9c,0xf6,0x0f,0x8e,0x98, - 0x52,0xad,0xe5,0x66,0x1a,0x27,0x92,0xbf,0xda,0xbb,0x87,0x70,0x67,0xc8,0xb1,0x4b, - 0xbc,0x4b,0x7a,0xae,0xf1,0x8e,0xd9,0xdf,0x16,0xd9,0x44,0xd0,0xe6,0x62,0x22,0x6b, - 0x4a,0x97,0x18,0x82,0xad,0x53,0xae,0xb7,0x41,0xb4,0xe1,0x89,0xd3,0x48,0x7e,0xaa, - 0xef,0x9b,0xfd,0x11,0xbd,0x02,0x80,0x61,0xeb,0x94,0xf1,0xb7,0xb2,0xaf,0x4d,0xb3, - 0x36,0xb0,0xd9,0x27,0x01,0xdd,0xc3,0xe9,0x1d,0x51,0xb2,0xbb,0x02,0x54,0x9f,0xbe, - 0x2f,0xc2,0xd1,0x15,0xc1,0x49,0xf6,0x01,0x46,0xce,0xb3,0x4c,0xf1,0xce,0x80,0x9c, - 0x91,0x40,0x79,0xe5,0x2a,0xb0,0x87,0x57,0xec,0xc2,0xa6,0xe7,0x6a,0xa8,0xe9,0x48, - 0x0f,0xc0,0xc0,0x88,0xd1,0x85,0x0d,0x29,0x15,0xe7,0xaa,0x48,0xba,0xe7,0xb3,0xa8, - 0xf3,0x06,0x68,0x18,0x58,0xf9,0x8a,0x1e,0x3e,0x09,0x8b,0x70,0x86,0x6c,0x0e,0x80, - 0xcc,0xcf,0x29,0x87,0xda,0xf2,0xb6,0x9d,0x4d,0x17,0x4f,0x4f,0xdf,0xbd,0x7f,0xdb, - 0xe1,0xd0,0xd0,0x3e,0xcb,0xf3,0x44,0xd2,0x57,0x9b,0x40,0xc1,0x09,0x4c,0xb8,0x90, - 0xe3,0xa0,0x35,0x55,0x2a,0x2f,0xfd,0x6e,0x77,0x82,0x4d,0xea,0x7c,0xe8,0x62,0x37, - 0xda,0x95,0xe9,0x64,0x36,0x4f,0x27,0xdd,0xc6,0x76,0xb3,0x3b,0x4c,0xb2,0x61,0x77, - 0x16,0xc6,0x69,0x37,0xca,0x46,0x65,0x17,0x26,0x1d,0x77,0x42,0xb3,0xdd,0x98,0x45, - 0xad,0xd3,0x92,0x36,0xd2,0x8e,0xde,0x08,0x0c,0x6f,0x0d,0xcc,0x08,0x4f,0xff,0xa8, - 0xa9,0xca,0x5c,0x8e,0x78,0x1e,0xbd,0x63,0x4e,0x15,0x84,0xa7,0x68,0x02,0xaa,0x4f, - 0xf0,0x42,0xf7,0x4c,0xd4,0xaa,0x5e,0xde,0xd8,0xab,0x9f,0xd1,0xfe,0x53,0x3b,0xae, - 0x1f,0x0c,0x07,0xc5,0xf9,0x0c,0x50,0xc8,0x05,0x7a,0x7f,0x91,0x48,0xfa,0xfa,0xfc, - 0xf6,0x55,0x84,0x1e,0xcb,0x3d,0x7a,0xca,0x9d,0xce,0x75,0x02,0x7e,0xcd,0x80,0xe2, - 0xc7,0x05,0xbd,0xa5,0x11,0x8c,0xc3,0xa4,0x94,0x4e,0x54,0x84,0x93,0x09,0x2c,0xc3, - 0x5c,0xd2,0xe6,0x80,0x37,0x2c,0x32,0x32,0x2d,0xf3,0x72,0xf8,0x33,0x3d,0xa5,0x5f, - 0x35,0x0c,0xf6,0xba,0x5d,0x0e,0x08,0x1c,0xb0,0x4c,0x29,0xb2,0xac,0x9f,0x28,0x82, - 0x39,0xd0,0x63,0x44,0xf3,0x94,0x2b,0x20,0x42,0x46,0xb1,0xd2,0x19,0x49,0x9f,0x48, - 0x89,0x9b,0x18,0x5b,0x0d,0xd8,0x27,0xf9,0x49,0x16,0x46,0x44,0xac,0x7e,0x18,0x09, - 0xeb,0xcf,0xf8,0x71,0xfd,0x71,0x5c,0xcc,0x6e,0xc2,0x42,0x1a,0xbf,0xc3,0xd6,0x91, - 0xaa,0x61,0x4c,0xd9,0x5d,0x09,0x00,0xba,0xe1,0x95,0xb4,0xaf,0xed,0xc5,0x6a,0x59, - 0x6f,0x5f,0xba,0xfc,0x16,0x4a,0x70,0xbd,0x24,0xe2,0x67,0x98,0xa8,0x9c,0x27,0x8a, - 0x78,0x2c,0xc3,0x38,0x12,0x43,0xec,0xc4,0x22,0x29,0x6a,0x81,0x67,0x89,0x8e,0x74, - 0xa3,0x70,0x4e,0x0e,0x1c,0x2b,0x5f,0x94,0x09,0xf8,0x43,0x16,0x11,0x6d,0x53,0xab, - 0xb1,0x1d,0x22,0x75,0xc5,0x4d,0x0c,0xff,0x6c,0xaa,0x4f,0x64,0xba,0x0b,0x3b,0x0d, - 0x36,0x87,0xef,0x24,0xc2,0xeb,0xb5,0xa4,0x47,0xb1,0xd4,0x94,0xe9,0xeb,0xc3,0x35, - 0xae,0x65,0xd0,0xe3,0x54,0x04,0x5c,0xa3,0x06,0xff,0x48,0xfb,0xaa,0xcd,0x86,0x5f, - 0x3a,0xd9,0xd5,0x7b,0xf9,0x49,0x39,0x34,0xb3,0xbd,0x80,0x2f,0x93,0xd2,0xa8,0x7a, - 0x1b,0x90,0x38,0x7e,0x7a,0xf7,0xfd,0xa5,0x0c,0x8b,0xd1,0xf4,0x2d,0x77,0x6e,0xdb, - 0x8e,0x4c,0x82,0x3f,0xb5,0xa9,0xf3,0xe7,0xcf,0x16,0xb8,0xb0,0xec,0x01,0xc6,0xc0, - 0xd9,0xda,0x34,0x8e,0x4e,0x5d,0xb5,0x43,0x95,0x36,0xd1,0xe0,0x17,0x28,0xae,0x1c, - 0xdd,0xf2,0xe1,0xea,0x23,0x77,0x5e,0x7b,0xa3,0x40,0xbf,0xe8,0x43,0x16,0x69,0x39, - 0x0b,0xb8,0xe1,0x34,0x8b,0x7c,0xeb,0xed,0x8f,0x97,0xef,0x2d,0x87,0x08,0xf8,0xf4, - 0xb1,0xdc,0xf1,0xce,0x81,0x21,0xb2,0x7a,0x83,0x60,0xfb,0xa5,0x17,0x5d,0xf3,0x19, - 0xb7,0x23,0x37,0xbb,0x0a,0x02,0x6d,0x42,0xf6,0x02,0x4a,0x2a,0xc3,0x5b,0x34,0x4a, - 0x7a,0x4d,0x01,0x8b,0x18,0x87,0x50,0x74,0x64,0x39,0x16,0x1a,0x2c,0x87,0x4e,0xe1, - 0x2b,0xcb,0x5e,0x32,0x85,0x9c,0xce,0x08,0x40,0x6f,0x00,0x52,0x5a,0x56,0xb6,0xa1, - 0x61,0x24,0x67,0x65,0x57,0xeb,0xe3,0x68,0x91,0x60,0x68,0x44,0xb6,0xdb,0x7c,0x19, - 0xc3,0x0c,0xb3,0xd2,0xcc,0xe8,0x67,0x55,0xb2,0xe2,0x2c,0xdb,0xe0,0x61,0x09,0x49, - 0x2d,0x1b,0xf6,0x06,0x8b,0xfe,0x7b,0xd2,0xd9,0x50,0xa5,0xec,0x47,0x46,0x6b,0xbc, - 0xc8,0x3f,0xb5,0x2d,0x5d,0xbe,0xb5,0x6c,0x57,0xef,0x94,0xe8,0x82,0x98,0xc0,0x84, - 0xf4,0xea,0x12,0x8f,0x62,0x95,0x2f,0xcc,0x81,0x7d,0xa9,0x96,0x15,0x85,0x1d,0x82, - 0x23,0x99,0xed,0xef,0x93,0xd8,0x1e,0x54,0x62,0xab,0x0d,0x5e,0x5f,0x6a,0xf6,0x60, - 0x98,0x3f,0xc2,0xe1,0x78,0x67,0xe5,0x9b,0x9d,0x14,0xd5,0x2e,0xf4,0xde,0x48,0x43, - 0x27,0x29,0xe8,0xb4,0xcf,0xa9,0xf7,0x4b,0x75,0x6d,0xc3,0xdc,0xe5,0xd3,0x29,0xb6, - 0x71,0xcf,0xf7,0xea,0x7c,0x1a,0x47,0x51,0x22,0x5d,0x41,0x2f,0x7b,0x00,0x59,0xaa, - 0xe2,0x96,0x7c,0xe8,0xc3,0x0c,0xce,0x14,0xe7,0x49,0x4c,0x86,0x8d,0xc8,0x14,0x97, - 0x59,0xf1,0x91,0x9e,0x20,0x4c,0x6e,0xd9,0xf1,0x63,0x0d,0x96,0xc4,0xa9,0xf0,0x5c, - 0x0e,0x3d,0xdf,0x9d,0xbd,0xfa,0xe1,0x32,0xf8,0xf0,0xe1,0xd8,0xf1,0x3e,0x3a,0x1f, - 0x0e,0xf8,0xf3,0x31,0x7f,0xf6,0xf9,0xd3,0x33,0x9f,0x7d,0xfe,0x7c,0xcc,0x9f,0x07, - 0xfc,0x79,0xc4,0x9f,0xc7,0xba,0x8f,0xbe,0xed,0x1d,0x7d,0xfc,0x38,0x58,0x69,0x64, - 0x02,0xab,0x78,0x45,0xa9,0xb2,0x0d,0xae,0x1c,0x70,0xc3,0xaa,0xa8,0xfc,0x20,0x0e, - 0x7a,0x83,0xf8,0x84,0xe7,0x77,0x75,0xed,0x7b,0x10,0x3f,0x7a,0x64,0x43,0xb0,0xdc, - 0xf6,0x21,0xfe,0xf8,0xa1,0xf7,0x11,0x36,0x89,0xb1,0xfb,0xfb,0x75,0x93,0x47,0x4d, - 0x44,0xca,0x18,0x52,0xdc,0xf0,0x16,0xf4,0x3c,0xf5,0x9e,0xf5,0xfc,0x26,0xcd,0x8e, - 0xb7,0x66,0x24,0xc4,0x12,0xa9,0xb4,0x3d,0xa9,0xdd,0x63,0x82,0x69,0x30,0xac,0x6d, - 0xfd,0xe3,0xbc,0xd7,0x8b,0x9e,0x58,0x8f,0xa8,0xc1,0xf6,0xdb,0x13,0xcc,0xc5,0xed, - 0x5e,0x97,0xda,0x3c,0xb4,0x59,0xbc,0xdd,0xb5,0x6c,0x56,0x6b,0xbf,0x57,0x2b,0x2c, - 0xe4,0xac,0x38,0x10,0x84,0xda,0xb4,0x8a,0x25,0xe1,0xb4,0x8c,0xfe,0x34,0x22,0x4a, - 0x91,0xdd,0xe8,0xb9,0x1d,0x60,0xbd,0x3a,0x8e,0x40,0xdb,0xef,0x31,0x3a,0xe8,0xf7, - 0xbe,0xa5,0x99,0xbb,0x34,0xd5,0xc0,0xdc,0x53,0x41,0x83,0xe1,0x47,0x96,0x20,0x16, - 0x87,0x4f,0x84,0xf5,0xa8,0xcd,0xef,0x5b,0xf1,0x93,0x28,0x6d,0x43,0xe0,0x5b,0xaf, - 0x67,0x77,0xf1,0xef,0x91,0xd5,0x25,0x6e,0x2c,0xa2,0x01,0x61,0xd2,0x54,0xea,0x51, - 0x70,0xc7,0x58,0x70,0xdc,0xdd,0x1a,0xaf,0x11,0xa6,0xd5,0x90,0xac,0x5a,0xf7,0x34, - 0x7e,0xde,0x14,0x81,0x6e,0x2b,0xbf,0xfd,0xd3,0x1c,0x40,0x5c,0xe3,0xa5,0xac,0x38, - 0x4b,0x92,0xb6,0xb5,0xeb,0xe9,0x4e,0x12,0x60,0x4d,0x2c,0x2c,0x0a,0xca,0x10,0x86, - 0xd2,0x59,0x51,0x84,0xb7,0x6e,0x5c,0xf2,0x5f,0xb4,0x3f,0xbb,0xf6,0x3f,0x5c,0x3b, - 0xfc,0xdf,0xc7,0xc6,0xa0,0x2a,0xde,0x90,0x0c,0x39,0x79,0x46,0x95,0xc4,0x90,0x34, - 0x82,0xc8,0x2d,0x54,0xfe,0x56,0x82,0xc5,0xd3,0xde,0x40,0x3b,0x3f,0x3b,0x7e,0xf3, - 0x95,0xc5,0x07,0xe8,0x44,0xa8,0x7a,0x26,0x9f,0x59,0xd9,0x78,0x6c,0xf9,0x6d,0x8c, - 0x84,0xb2,0xeb,0x73,0x1a,0xc2,0x10,0x90,0x55,0x4d,0x0b,0x51,0xd0,0xb2,0x6d,0x7a, - 0x93,0x12,0x1a,0x4e,0x91,0x92,0x75,0x54,0xaf,0x68,0x33,0xb4,0xfb,0x81,0xde,0xa5, - 0xb2,0xca,0xf9,0x90,0x46,0x82,0x12,0x05,0x88,0xd5,0x2c,0x57,0x96,0x6f,0xe9,0x41, - 0x9c,0xf3,0x65,0x18,0x21,0x4b,0x58,0xf4,0x57,0xb7,0xd2,0x2b,0x62,0x75,0x7f,0x7b, - 0xc1,0x6f,0x27,0x34,0xc8,0xf2,0x9b,0x11,0xc4,0xea,0xc0,0x8c,0xea,0x6c,0xae,0xc9, - 0x62,0xc8,0x45,0x99,0x94,0x93,0x9e,0xdc,0xe8,0x9e,0x6f,0x76,0x7f,0xcf,0x0e,0xc4, - 0x41,0x48,0x97,0xee,0xad,0x01,0x85,0x73,0x89,0xe8,0x45,0xcc,0x60,0x01,0x77,0x71, - 0x91,0x7e,0x81,0x89,0xf3,0x5a,0x82,0x70,0x80,0x0d,0x09,0xae,0x8e,0x60,0xf4,0x92, - 0x59,0x7e,0x77,0x30,0x57,0x17,0xb6,0x63,0x72,0x30,0x5e,0x1a,0xef,0xfd,0xa8,0xfe, - 0x29,0x81,0x51,0xc0,0x2e,0x11,0xa7,0x40,0xfd,0xcc,0x12,0xaf,0x94,0xc6,0x2e,0xf5, - 0x01,0x8c,0xc8,0xea,0x87,0xbb,0x5d,0x2d,0xf8,0x6a,0x6d,0x3b,0xd7,0x74,0xf7,0x82, - 0xcc,0x14,0x6d,0xeb,0x3c,0x4c,0x12,0xaa,0x83,0x5b,0x8f,0x74,0xd3,0x23,0xeb,0xd7, - 0xbf,0xfe,0xa7,0x05,0x93,0xf8,0xbe,0x36,0x89,0x2f,0xaf,0xa9,0x62,0xf6,0x15,0x1d, - 0xee,0x10,0x25,0x7d,0xb2,0x84,0x40,0x52,0xba,0xd8,0x74,0xd0,0x03,0x59,0x8c,0x75, - 0xaa,0x43,0xa3,0xae,0xf8,0x41,0xaa,0x9b,0xac,0xb8,0x12,0x95,0x62,0xeb,0x7d,0xaf, - 0x3e,0x26,0x2a,0x9b,0xe7,0x44,0xb4,0xca,0xf3,0xe6,0x31,0xcf,0x2d,0x55,0x8c,0x2b, - 0x79,0x33,0xcc,0xa1,0x14,0xc9,0x42,0x59,0x3b,0xb1,0x61,0x82,0xab,0xa3,0x30,0x16, - 0x23,0xbd,0x2c,0x4b,0xa7,0x04,0xda,0x22,0xc8,0x32,0x6b,0x08,0x67,0x6f,0xe7,0x52, - 0xbd,0x30,0xf2,0x10,0xe3,0x12,0xe6,0xe4,0x70,0x53,0xa7,0x5b,0xe7,0x88,0xac,0x41, - 0xf0,0x09,0x05,0xb6,0x49,0xac,0xf8,0xfa,0xc8,0xb2,0x6b,0x3f,0x59,0xa3,0xc4,0x67, - 0xe7,0xae,0x39,0x7d,0x0f,0xcc,0x30,0x74,0xa5,0x47,0xac,0xaa,0x79,0x69,0x5d,0x1b, - 0x93,0x62,0x82,0x38,0x07,0x6b,0x1d,0x0b,0x91,0xcd,0xe7,0x09,0xc9,0x8c,0x3e,0x7f, - 0x3e,0xec,0xf5,0x0e,0x28,0xd8,0x91,0xa3,0x8b,0xc6,0x4e,0xa0,0xa2,0x45,0xdb,0xb9, - 0x2d,0x05,0x8e,0xf4,0xb9,0x13,0x72,0x50,0xef,0x99,0x05,0x19,0x21,0x6e,0xa0,0x23, - 0x9b,0x84,0xb9,0x55,0x31,0x3e,0x4a,0xd5,0x36,0x27,0xc5,0xa7,0xcf,0x9f,0x39,0xc2, - 0x56,0xc1,0x45,0x71,0x43,0x15,0x13,0x10,0xab,0x31,0x47,0x36,0x57,0x54,0xd3,0x01, - 0xae,0xb0,0x80,0xc2,0x2b,0x76,0xf8,0x60,0x68,0x3d,0xd0,0x8c,0xa6,0xa0,0x81,0x56, - 0x48,0x0f,0x1e,0xd9,0x10,0x1a,0x1f,0xb0,0xdc,0xd5,0x17,0x1d,0xa9,0x7b,0x3d,0x29, - 0xb2,0x66,0x19,0x50,0x14,0xd6,0x33,0xcf,0x93,0xcf,0x9f,0x3d,0x9b,0xb2,0x75,0xa3, - 0xf5,0x22,0xbe,0xa6,0xd6,0x6a,0x48,0x99,0x04,0x75,0x0e,0x68,0x42,0xdb,0x12,0x29, - 0xbd,0x3c,0x39,0x18,0x94,0xc8,0xe3,0x35,0xb4,0x7c,0x50,0xed,0x61,0xec,0x32,0xf9, - 0x50,0x7e,0x34,0x26,0xb3,0x86,0x0d,0x4a,0xb4,0xf3,0x8c,0xf8,0x6b,0xe2,0x02,0x91, - 0x9b,0x04,0x3a,0xed,0xeb,0xf7,0xd2,0xdb,0x8d,0xe1,0xf6,0x47,0xdd,0x4b,0x37,0x01, - 0x3a,0x43,0xbe,0x3f,0xd0,0x91,0xcd,0x5a,0x02,0x42,0xf6,0xd9,0xb4,0x86,0xf5,0x14, - 0xcc,0x20,0x74,0x6f,0x9d,0xcb,0xfd,0xfd,0x07,0x8d,0x5d,0x96,0x59,0x06,0x2f,0x8e, - 0xe4,0x48,0xcc,0xba,0xec,0xb6,0x2b,0x34,0x38,0x73,0x56,0x3b,0xbd,0x99,0x06,0x2c, - 0x25,0x12,0x39,0x82,0x3f,0xad,0x28,0x36,0x80,0x85,0x9a,0x96,0x8d,0xa0,0x87,0x39, - 0x48,0x43,0xfa,0x9c,0x27,0x20,0xe2,0x15,0x37,0xbc,0x49,0x80,0x21,0xdc,0x9d,0x54, - 0x1f,0xf2,0x2e,0x47,0x3f,0xad,0xb7,0xbe,0xb9,0xd0,0xa0,0xea,0x80,0x91,0xd4,0x8a, - 0xf5,0xeb,0x40,0xab,0xf1,0x6d,0x56,0x7e,0xfe,0xfc,0xe1,0xe8,0xc0,0xa9,0xfe,0xff, - 0x68,0x83,0x3f,0xcd,0xd3,0x15,0xb1,0xba,0x29,0xbf,0xb8,0xf6,0x3b,0xfd,0x36,0x30, - 0x19,0x50,0xbb,0x7d,0xdd,0x05,0x6c,0x00,0x5c,0x20,0x4b,0xfe,0x86,0x02,0x60,0xef, - 0x1b,0xeb,0x0b,0x44,0xdc,0xeb,0x0d,0x25,0x10,0x95,0x6b,0xdf,0xfa,0xdb,0x7f,0x69, - 0x90,0xb2,0xd4,0x46,0x5b,0x55,0x5d,0xb7,0x5c,0xbd,0xb2,0xda,0xa6,0xa3,0x93,0x01, - 0xd7,0xc2,0x7b,0xb0,0x4a,0x9b,0x84,0x53,0x1a,0xce,0x92,0xa5,0x5c,0xc1,0x08,0xd6, - 0x37,0x18,0x8c,0xf7,0x29,0xaa,0x18,0xc0,0x6f,0x1c,0xce,0xb7,0x68,0xd0,0xd2,0xb1, - 0xb4,0x74,0x4b,0xa9,0xc3,0x62,0x5c,0xe8,0xb3,0x15,0xec,0x3c,0xae,0x78,0x33,0x67, - 0x2f,0x07,0x7b,0x0d,0x27,0xfb,0x8d,0x73,0xd0,0xa0,0xf5,0x39,0x8a,0xb5,0x63,0xcf, - 0xcd,0x69,0x8c,0xa1,0x60,0x16,0x2e,0x2f,0x35,0xa7,0x69,0x56,0x03,0x54,0x31,0x97, - 0xb4,0xfb,0xa2,0x29,0x2b,0xc1,0xe8,0x57,0xe8,0xaf,0x57,0xbe,0x0a,0x13,0xd4,0xfe, - 0x33,0xd8,0xe1,0x5e,0xd7,0x70,0x2a,0xe6,0xd5,0x00,0xe2,0x85,0x8e,0x06,0x3e,0x5d, - 0x2e,0xfd,0x85,0x8e,0x02,0x3e,0xa1,0x53,0xb0,0x5f,0x9f,0x48,0xd2,0x4b,0x72,0x9c, - 0xe7,0x1b,0x40,0xb5,0x5a,0xc1,0x12,0x2b,0xd0,0x80,0x2e,0x2f,0x32,0x95,0x11,0x02, - 0xac,0x5e,0x4f,0xe7,0x5f,0x63,0x68,0xd7,0x8c,0x35,0x7e,0x71,0x21,0x61,0xbb,0x95, - 0x09,0x56,0xcc,0xc0,0xb1,0xb9,0xe0,0xba,0x1a,0xc2,0xab,0xad,0x02,0xcc,0x96,0x37, - 0x55,0xeb,0x66,0x59,0xe8,0xf5,0xfe,0x56,0x36,0xe8,0x77,0x0a,0x4c,0xa4,0xba,0x5e, - 0xda,0xcb,0x6a,0x2a,0xc2,0x9a,0xb6,0x06,0x9f,0xfc,0x75,0x39,0xa8,0x78,0xdd,0xd6, - 0xce,0x7a,0xe9,0x66,0x97,0xc4,0x57,0xfc,0x55,0xf1,0x8c,0xf7,0x03,0xc1,0xd7,0x15, - 0x50,0xaf,0xfd,0xc1,0xf6,0xe2,0x73,0x0e,0x0b,0x41,0x73,0x8e,0x89,0x54,0x67,0x4a, - 0x15,0xf1,0x10,0x39,0xb7,0x6d,0xe9,0xf3,0x41,0x18,0xd8,0x23,0x4f,0xd3,0xd1,0x95, - 0x0e,0x67,0x5b,0x90,0xcf,0x6a,0x3d,0xc3,0xfd,0x5e,0x5b,0x8f,0x34,0x69,0xbd,0x11, - 0xe9,0x7b,0x4f,0xfb,0x77,0xa8,0x1d,0x4e,0x68,0xb3,0xed,0xb2,0x97,0xee,0x92,0x8d, - 0xf1,0x89,0xd4,0x6f,0x9a,0xeb,0x33,0xcb,0xc3,0x34,0x3d,0x78,0xc7,0x7a,0xab,0x39, - 0x63,0xc0,0x3d,0xfd,0x0d,0x98,0xd6,0xb1,0xb2,0x6a,0xaa,0x26,0x1a,0xd9,0x61,0x32, - 0xf5,0xa6,0x9c,0x4c,0xc6,0xf4,0xe7,0x47,0xc7,0xb8,0xbb,0x7e,0x64,0x2a,0x68,0xfe, - 0xde,0x87,0x84,0x95,0x20,0xc0,0xa7,0xea,0x42,0x8e,0x43,0x6c,0xa6,0x91,0xe1,0xf4, - 0x0f,0x51,0x6c,0xc1,0x1e,0x17,0x12,0x9d,0xe9,0x04,0x58,0xd7,0x1e,0x98,0xf3,0xcd, - 0xf8,0x47,0x77,0x2d,0xdb,0x99,0x3a,0xd3,0x67,0x96,0xae,0xbb,0xd1,0x63,0x15,0xb1, - 0x41,0x81,0x58,0x17,0x3f,0x15,0x25,0xa3,0x6a,0x3d,0x2b,0xcc,0xb3,0x23,0xb6,0x18, - 0x2b,0xdc,0xdf,0xa7,0x4f,0x02,0x41,0xf6,0xda,0xe4,0x4e,0xd5,0xec,0xec,0x9a,0xca, - 0x4c,0x50,0x3f,0x85,0xb5,0x7b,0x8a,0x75,0x82,0x16,0xa4,0x6d,0x18,0x64,0x62,0xf4, - 0x50,0x9b,0x79,0x1c,0xa4,0xf9,0x5c,0x1b,0xd3,0xe6,0x6a,0x47,0xa3,0x9e,0x68,0x95, - 0xf4,0x7b,0x39,0xd2,0xa7,0xda,0x78,0xcc,0xa7,0x01,0xb9,0xae,0xe3,0xe9,0xca,0x21, - 0x75,0x54,0xe1,0x90,0x90,0xad,0x7e,0x2e,0xd0,0xa9,0xb0,0x28,0x51,0xaa,0x9e,0xa4, - 0xa2,0x07,0x48,0xa6,0x71,0xee,0x70,0x5d,0xd2,0x1c,0x34,0x4d,0xa4,0x73,0x9f,0xc7, - 0xe6,0xdd,0x3d,0x84,0x62,0xfe,0xfd,0x90,0x66,0x0d,0xcd,0x03,0xf4,0x73,0x36,0x8b, - 0x39,0x76,0xb5,0xa7,0x24,0xdb,0xc5,0xec,0x3f,0x5d,0x3e,0xd7,0x25,0x6c,0xf9,0x29, - 0xa6,0x47,0x4a,0xb8,0x84,0x12,0x53,0xe9,0x9b,0x7f,0x5f,0x04,0x13,0x67,0x40,0xb3, - 0xe8,0xa5,0x77,0x25,0xa6,0xc6,0xfa,0xe3,0xfb,0xef,0xe8,0x28,0x70,0x74,0xe5,0x98, - 0xd3,0x3f,0xfa,0x5d,0x0e,0x22,0xc7,0xcc,0x96,0x99,0xae,0x3e,0x86,0x54,0xbd,0x89, - 0x7f,0x2a,0x87,0x82,0x1f,0x22,0x75,0xc5,0x6b,0x29,0x73,0xd3,0x19,0xd8,0x5d,0x96, - 0x53,0x3d,0x1d,0x55,0x9d,0x20,0x3b,0xaa,0x78,0x33,0x66,0x96,0x4c,0x74,0x92,0xf1, - 0xb2,0xcc,0x4f,0xb0,0x34,0x7f,0x12,0xc5,0x80,0x87,0x79,0x50,0xba,0x66,0x82,0x1a, - 0x45,0x03,0x27,0xb8,0x5a,0xc6,0xc1,0x83,0x79,0xb5,0xdb,0x9c,0x9b,0x2a,0x4a,0x7d, - 0x5d,0x17,0x93,0xed,0x45,0xa3,0xb0,0xcc,0xd1,0x16,0x33,0x36,0x7e,0x0e,0xa6,0x0d, - 0x11,0xf6,0xec,0x65,0x5d,0xa6,0x98,0xbb,0xb3,0x0c,0x19,0x98,0xdc,0x75,0x73,0x07, - 0x3d,0x5f,0xa1,0x5e,0x88,0xaf,0x6a,0x53,0x55,0x1b,0xfd,0xce,0x0a,0xe5,0xf6,0xd5, - 0x93,0x70,0xa6,0xea,0xc5,0xcb,0xae,0xaa,0x18,0x34,0xe0,0xa2,0xc8,0xf2,0x1c,0xac, - 0x71,0x35,0x83,0xe9,0x34,0x5a,0x41,0x29,0xd2,0xdf,0x56,0x7b,0x90,0xe1,0xd6,0x5e, - 0x4e,0x6d,0xdc,0x5b,0x01,0xe4,0xc6,0x02,0xf4,0x46,0x5d,0x07,0xb1,0xc6,0xaf,0xa5, - 0x74,0xf5,0xe3,0xba,0x5d,0xfd,0x63,0x50,0xff,0x0b,0x75,0x03,0x36,0xe6,0x1d,0x4a, - 0x00,0x00, + 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0xc5,0x5c,0xdd,0x72,0xdb,0x46, + 0x96,0xbe,0xd7,0x53,0xb4,0xe9,0x89,0x40,0xc4,0x20,0x48,0xd0,0x94,0x2c,0x83,0x02, + 0x3d,0xb2,0x64,0x65,0xbd,0xb6,0x13,0x97,0xe5,0x54,0xaa,0xe2,0xd5,0x05,0x48,0x34, + 0x49,0x8c,0x40,0x00,0x0b,0x80,0x92,0x35,0x14,0xab,0xe6,0x6a,0x6b,0xaf,0x67,0xb7, + 0x6a,0xdf,0x60,0xef,0xe7,0x6a,0xef,0x67,0xdf,0x24,0x4f,0xb2,0xdf,0x39,0xdd,0x00, + 0xc1,0x1f,0xd9,0x4a,0x36,0x55,0x53,0x89,0x29,0xa2,0xd1,0x7d,0xfa,0xf4,0xf9,0xfd, + 0xfa,0x34,0xc0,0xe3,0x47,0x41,0x32,0x2a,0x6e,0x53,0x29,0xa6,0xc5,0x2c,0x1a,0xec, + 0x1d,0xd3,0x1f,0x11,0xf9,0xf1,0xc4,0x6b,0xc8,0xb8,0x41,0x0d,0xd2,0x0f,0x06,0xc7, + 0x33,0x59,0xf8,0x62,0x34,0xf5,0xb3,0x5c,0x16,0x5e,0x63,0x5e,0x8c,0x5b,0x47,0x74, + 0x33,0x1f,0x65,0x61,0x5a,0x88,0x3c,0x1b,0x79,0x8d,0x76,0x3a,0x6e,0x8d,0x92,0x38, + 0x4f,0x22,0x69,0xff,0x29,0x7f,0x31,0xf5,0xe4,0x78,0x3c,0x0e,0x0e,0x3a,0x07,0x8d, + 0xc1,0x71,0x5b,0x75,0x1c,0xe8,0x01,0x83,0x70,0xdc,0x7c,0x74,0x13,0xc6,0x41,0x72, + 0x63,0xbf,0x3f,0x37,0x9b,0xe3,0x79,0x3c,0x2a,0xc2,0x24,0x6e,0x9a,0x8b,0x6b,0x3f, + 0x13,0x3f,0x79,0xea,0x9e,0xf5,0xa3,0x67,0xb4,0xfd,0x34,0x6c,0xe7,0x85,0x5f,0xcc, + 0x73,0xc3,0x7a,0xeb,0x7d,0xba,0xb4,0xde,0x79,0x5d,0xf9,0xdc,0xba,0xb0,0x7e,0xb6, + 0xde,0x7b,0x3f,0x61,0xb8,0xb7,0xa0,0xdb,0xd2,0x35,0xa2,0xf0,0x5a,0x1a,0x56,0x10, + 0x66,0xc5,0xad,0xfb,0xc8,0xb1,0xae,0xdd,0x78,0x1e,0x45,0xd6,0x44,0x16,0xee,0x77, + 0x56,0x18,0x8f,0xa3,0x70,0x32,0x2d,0xdc,0xef,0xe7,0xb3,0xa1,0xcc,0xac,0x69,0x12, + 0x05,0xee,0x0f,0xc3,0x3f,0xc9,0x51,0x61,0xe5,0xfe,0xad,0x5b,0x71,0x50,0x58,0x57, + 0x96,0x34,0x17,0x72,0x7f,0xbf,0x29,0xed,0x42,0x7e,0x2e,0x4e,0x93,0xb8,0x90,0x71, + 0xe1,0x15,0xe6,0xd2,0x1a,0xce,0xf3,0x5a,0xd7,0xa1,0x95,0x9a,0x8b,0xf2,0x4a,0x8c, + 0xc1,0xfc,0xd0,0x0e,0xc2,0xdc,0x1f,0x46,0x32,0xf0,0x1e,0x39,0xcb,0xfa,0x55,0xa7, + 0x9f,0xda,0xc5,0x54,0xc6,0xcd,0xb1,0x35,0x36,0xfb,0x99,0x2c,0xe6,0x59,0x2c,0xd2, + 0xa5,0x95,0x26,0x51,0xb4,0xa2,0x98,0x5b,0x33,0x6b,0x64,0x25,0x4a,0x0a,0x85,0x35, + 0xb6,0x26,0x9e,0xd3,0xaf,0x66,0xf8,0x80,0x19,0x26,0xfb,0xfb,0x8f,0xc6,0xe0,0x6d, + 0xec,0x39,0xd6,0x28,0x92,0x7e,0xf6,0x31,0x9c,0xc9,0x64,0x5e,0x34,0x0b,0xd3,0x6a, + 0xe6,0xf6,0xc8,0x8f,0xa2,0x17,0x79,0xd3,0x74,0xbf,0x6b,0xe6,0xa6,0xa9,0x67,0x2c, + 0xa9,0x07,0xe6,0x62,0xd4,0x0c,0x2c,0x92,0x0a,0xd6,0x52,0x35,0x4b,0x6a,0x66,0x51, + 0x49,0x73,0xa9,0xc7,0xbc,0xb1,0xde,0x98,0xe6,0xb2,0x9a,0xf9,0x0d,0x66,0x1e,0x7b, + 0x9d,0xfe,0xc6,0x8c,0x7d,0x70,0xd3,0x2c,0x3c,0x98,0x44,0xd9,0xf6,0xc1,0x9a,0x61, + 0x5c,0xb2,0xbf,0x9f,0xd8,0xe3,0x30,0xcb,0x0b,0xcf,0x83,0x20,0x5e,0x60,0xb8,0x0b, + 0xe6,0xf5,0xb2,0xa1,0xae,0x24,0x75,0x6b,0x2a,0x9f,0x6c,0x53,0x5e,0x5a,0x20,0xba, + 0xea,0x13,0x9b,0x8b,0x99,0x17,0xf7,0xc7,0x77,0x77,0x20,0xb5,0xb4,0xe2,0xe4,0xc6, + 0xfd,0xb0,0x44,0x1f,0x36,0x8b,0x55,0xb7,0x11,0x56,0xb2,0xbf,0xff,0xd6,0x4e,0xe7, + 0xf9,0x14,0x17,0xfd,0x8b,0x17,0xb8,0x1c,0x35,0x2f,0x4c,0xf7,0xe7,0x17,0x3f,0xdb, + 0x18,0x05,0x3e,0x7e,0xf6,0xde,0xdb,0x24,0xf4,0xe6,0x8f,0xd6,0x3b,0xeb,0x0c,0xc4, + 0x6e,0xfc,0x62,0x34,0xbd,0xd8,0xa0,0x34,0x33,0x17,0xef,0x6d,0x45,0x1e,0x7c,0xff, + 0x6c,0x83,0x9b,0xe6,0x3b,0xef,0x9d,0x5f,0x4c,0xed,0x59,0x88,0xdb,0xd6,0x3b,0x93, + 0x87,0x86,0xc5,0x79,0x92,0x9d,0xc9,0xeb,0x70,0x24,0x57,0x83,0xa1,0xbf,0xc4,0x4b, + 0xee,0xee,0x16,0xcb,0x52,0xd1,0xb1,0xbc,0x11,0xef,0xb3,0x64,0x16,0xe6,0x72,0xa5, + 0x8e,0x5b,0x2b,0x56,0x8a,0x9e,0xae,0x58,0xea,0xca,0xa7,0x2b,0xc5,0xe4,0xe6,0x22, + 0x87,0xba,0x9b,0x89,0x3d,0x9c,0x87,0x51,0xb0,0xbf,0x9f,0xab,0x2f,0x9e,0xa7,0x5b, + 0x4c,0x88,0x7f,0x6a,0x93,0x38,0x9b,0xa6,0x75,0x4b,0x1a,0x5f,0x9a,0xfd,0x9a,0x36, + 0x6a,0x32,0x2e,0xbb,0xf5,0xe3,0xe6,0xab,0x2c,0x4b,0xb2,0xa6,0x51,0xa8,0x4e,0x06, + 0x2d,0x24,0xb1,0xf5,0xd5,0xdd,0xdd,0x73,0xd9,0x03,0x95,0xe5,0x72,0x65,0x76,0xdf, + 0x35,0xe7,0x64,0x92,0x7a,0x29,0x63,0x09,0x71,0x71,0xcb,0x86,0x75,0x65,0xe6,0x82, + 0xbc,0x3a,0xb3,0x93,0x2b,0xb3,0x98,0x66,0xc9,0x8d,0x50,0x13,0x65,0x5a,0x8e,0x95, + 0xd5,0xb3,0x75,0x90,0x67,0xbd,0xc8,0xf8,0x0f,0x94,0x92,0x21,0x60,0x10,0x9b,0xcb, + 0x9a,0xcd,0x9d,0xd1,0xfa,0x41,0x11,0x9f,0x17,0xf0,0xf3,0x74,0xac,0x74,0xe4,0xe5, + 0xfd,0xb7,0xf6,0x38,0xc9,0x5e,0xf9,0x60,0xa3,0x9a,0x7c,0x0c,0xfb,0x44,0x4f,0x62, + 0x7c,0x69,0x36,0xcd,0x2a,0xde,0xec,0xa9,0xc0,0x15,0xfb,0x33,0xe9,0x35,0xae,0x43, + 0x79,0x93,0x26,0x59,0xd1,0x10,0x23,0xed,0xd3,0x8d,0x9b,0x30,0x28,0xa6,0x5e,0xc0, + 0x0a,0x6c,0xf1,0x05,0x22,0x45,0x58,0x84,0x7e,0xd4,0xca,0xe1,0x4b,0xd2,0x73,0x28, + 0xca,0x15,0x61,0x11,0xc9,0xc1,0x7b,0xbf,0x28,0x64,0x86,0x38,0x82,0xa5,0xb5,0xc4, + 0xbb,0xd7,0x67,0xaf,0x8f,0xdb,0xea,0x0e,0xe2,0x60,0x71,0x4b,0x7f,0xdd,0x2c,0x49, + 0x8a,0x45,0xab,0x35,0xca,0xa4,0x3f,0x73,0x1f,0x77,0x4e,0x3b,0x2f,0x3b,0xcf,0xfb, + 0xad,0x56,0x18,0x5f,0xb9,0x8f,0x5f,0x9d,0xbd,0x7a,0x76,0xf6,0x12,0x57,0xb3,0x79, + 0x21,0x03,0xf7,0xf1,0xd1,0xc9,0x51,0xf7,0x59,0x17,0xd7,0x63,0x3f,0x8c,0x0b,0xf7, + 0xf1,0xc1,0xc9,0xc1,0x41,0xef,0xb0,0xbf,0xd7,0x6a,0x65,0xf3,0x48,0xba,0x8f,0xbb, + 0xbd,0xae,0xe3,0x1c,0xf5,0xd5,0x65,0x2b,0x4f,0xc6,0xe8,0xe3,0xbc,0x74,0x9e,0x3b, + 0x3d,0xb4,0x45,0x44,0xe1,0xfc,0xfc,0xe0,0xb4,0xfb,0x0a,0x57,0x09,0xc8,0x1f,0x3c, + 0x7b,0x79,0xf4,0xec,0x1c,0x17,0x37,0x7e,0x16,0xbb,0x8f,0xcf,0x9e,0x9f,0x74,0x9e, + 0x9e,0x13,0xb5,0xd4,0x8f,0x65,0x84,0xa1,0x4f,0x1d,0xc7,0xe9,0x50,0x43,0xee,0xc7, + 0xb9,0x6b,0xbc,0x86,0x08,0x32,0xc3,0x9a,0x87,0x7c,0xdd,0xca,0x65,0x16,0x8e,0xad, + 0xfc,0x36,0x2f,0xe4,0xac,0x35,0x0f,0xad,0x55,0x23,0x0d,0x99,0x25,0x71,0xe2,0x1a, + 0xff,0x2c,0x8b,0x97,0x19,0x98,0xcd,0xc5,0x3b,0x5c,0xf3,0x58,0xba,0x91,0xa7,0xfe, + 0x48,0x5a,0x17,0xe7,0xd4,0xd8,0xfa,0x20,0x27,0xf3,0xc8,0xcf,0xac,0x77,0x32,0x8e, + 0x12,0xab,0xba,0xbd,0xdc,0xfb,0x76,0x31,0x4c,0x3e,0xb7,0xf2,0xf0,0xcf,0x61,0x3c, + 0x71,0x87,0x49,0x16,0xc8,0xac,0x85,0x96,0xe5,0xde,0x30,0x09,0x6e,0x17,0x33,0x3f, + 0x9b,0x84,0xb1,0xdb,0xe9,0x0f,0xfd,0xd1,0xd5,0x24,0x4b,0xe6,0x71,0xe0,0xc2,0x37, + 0x9a,0x5a,0x96,0x66,0x7f,0x94,0x44,0x49,0xa6,0x9b,0x20,0x4e,0xb3,0x3f,0x86,0x0e, + 0x21,0xba,0x59,0x18,0xdd,0xea,0x66,0x62,0xd9,0xec,0xef,0x45,0x61,0x2c,0x5b,0x53, + 0xc9,0xd1,0xde,0xb1,0x0f,0xfa,0xad,0x1b,0x39,0xbc,0x0a,0xd1,0x97,0x06,0xe4,0x33, + 0x68,0x68,0x4a,0x2c,0xf8,0x31,0xa9,0x39,0xf4,0x73,0x19,0x2c,0xf7,0xec,0x9b,0xcc, + 0x4f,0xc1,0xc4,0x67,0x65,0x04,0xee,0xe1,0x51,0x27,0xfd,0xdc,0x2f,0x99,0x12,0xfe, + 0xbc,0x48,0xfa,0xa9,0x1f,0x04,0x34,0xf0,0x69,0x37,0xfd,0x2c,0xba,0xb8,0x2f,0x0e, + 0x7b,0x29,0xf8,0xa7,0x04,0x29,0xb3,0x05,0x62,0x7d,0x1a,0x51,0x26,0x89,0xe4,0xe7, + 0x3e,0x08,0x4f,0xe2,0x56,0x08,0x59,0xe6,0xee,0x48,0x92,0xa4,0xfb,0x13,0x3f,0x75, + 0x8f,0x40,0x54,0x93,0xc1,0xda,0x8b,0x22,0x99,0xb9,0x0e,0xa8,0xf5,0xf7,0x2a,0x79, + 0xa8,0x36,0xd0,0x46,0x1a,0x0d,0x03,0xa1,0x16,0x46,0x06,0x60,0x82,0xc9,0x00,0xc6, + 0xa5,0xf8,0x7b,0x86,0x41,0x7a,0x89,0xf4,0x75,0x4b,0x68,0x30,0x0f,0xb3,0xaf,0x89, + 0x66,0x7e,0x10,0x22,0xb6,0x39,0xcc,0xab,0xb3,0x50,0x62,0x08,0xff,0x2c,0x5d,0xe7, + 0x00,0x43,0xf9,0xf2,0x46,0x91,0x3a,0xec,0x74,0xaa,0x35,0xf7,0x69,0x1d,0xae,0x83, + 0x59,0xf3,0xf9,0x70,0xb1,0x2d,0x6c,0xd2,0xac,0x56,0x82,0x22,0x06,0xfa,0x6b,0x4a, + 0x62,0xab,0x06,0xd7,0xb9,0x64,0x17,0xd5,0x1a,0x6e,0x51,0xec,0xef,0x1e,0x32,0x2f, + 0xdd,0xc5,0xc6,0xf0,0x4d,0x5e,0x22,0x49,0x1e,0xd7,0x22,0x03,0x22,0xc1,0xdb,0x9d, + 0xe7,0x72,0xd6,0xa7,0x90,0xd1,0x2a,0x32,0xe8,0x1a,0x51,0x60,0xe6,0xce,0xd3,0x54, + 0x66,0x23,0x68,0xb1,0xbf,0x57,0x9f,0x9d,0x7d,0xcc,0x5c,0x69,0xb0,0x23,0x9c,0x0e, + 0x4d,0x6a,0x23,0xcf,0x04,0x0b,0x25,0x99,0xdd,0x72,0xde,0x16,0x26,0x3b,0x90,0x59, + 0xe9,0xdf,0x81,0xd6,0x85,0x73,0x58,0x51,0x13,0xc3,0x4a,0xf7,0xc3,0x28,0x19,0x5d, + 0xd5,0x85,0xd2,0xdb,0x5e,0x55,0x39,0x2a,0x2d,0x6d,0x9e,0xc8,0x81,0xc1,0xfa,0xb0, + 0xee,0x86,0x2c,0xf5,0x6a,0xd6,0x2d,0xbb,0x77,0xa0,0x49,0xd9,0x90,0xae,0xd6,0x75, + 0x7d,0x10,0xe2,0x70,0xd5,0x61,0x3c,0xde,0xd5,0x43,0xad,0x37,0x21,0xf1,0x02,0x32, + 0xd9,0x87,0xe8,0x3e,0xcf,0x87,0xeb,0x96,0x4c,0x66,0xeb,0xac,0x9c,0x81,0xf5,0xc7, + 0xd7,0xa5,0x1d,0x53,0x03,0xd9,0xb5,0xa6,0xcf,0xf7,0x77,0xc8,0x95,0x03,0x18,0xbc, + 0xf3,0x37,0x58,0x92,0x5a,0xbd,0x62,0x4e,0xc0,0x18,0x62,0x97,0xb1,0x45,0x6b,0x34, + 0x45,0x0e,0x5c,0xec,0xb2,0x39,0x7b,0x34,0x4d,0x10,0xd1,0xab,0x95,0x4c,0xb2,0x30, + 0x58,0xad,0x84,0xae,0x5a,0x70,0x4d,0xdc,0x2a,0x24,0x09,0x64,0x3e,0x43,0x40,0x74, + 0xc6,0x99,0xc0,0x3f,0x1e,0xbc,0xa1,0xd0,0xff,0x9f,0xb9,0x50,0xb8,0x50,0x5e,0x3e, + 0x9a,0x67,0x39,0x78,0x4d,0x93,0x90,0x23,0x02,0x5b,0x32,0x47,0x0a,0x37,0x92,0xe3, + 0x62,0x2d,0xa2,0x85,0xf1,0x14,0xb1,0xb7,0xd0,0x72,0xd0,0x57,0xcc,0x9b,0x3b,0x4d, + 0xae,0x11,0x6e,0x76,0xa8,0xb3,0x92,0xd3,0x68,0x7a,0x8f,0x49,0xe8,0xb0,0x80,0x30, + 0x3c,0xf5,0x81,0xaf,0x41,0x17,0x68,0x81,0x8c,0x8f,0xfc,0x03,0x6c,0xae,0x7a,0x31, + 0x95,0x2f,0x59,0xf6,0xd3,0x7b,0x63,0x47,0x19,0xc5,0xba,0xec,0x21,0xa0,0x92,0xae, + 0xa2,0xfb,0xaf,0xb6,0x71,0x8a,0x3f,0x32,0xce,0xeb,0xd1,0xc3,0xe1,0xa8,0xcb,0xcd, + 0xc2,0x46,0xc3,0xfd,0x91,0x77,0x88,0xb0,0x40,0xf4,0x2a,0xd5,0xd7,0x87,0x89,0xe1, + 0xe2,0xcb,0xcb,0x59,0xeb,0x1c,0xf9,0x43,0x19,0x95,0x5c,0x90,0xb6,0x5c,0xce,0x09, + 0x5f,0x5f,0xcf,0x96,0x33,0x21,0x70,0xec,0x4a,0x0f,0xeb,0xb6,0xb1,0x3d,0xb7,0x08, + 0xe3,0x74,0x5e,0x2c,0xfc,0x11,0xf5,0xdf,0x52,0xea,0x72,0xaf,0xfd,0xad,0xf8,0x38, + 0x95,0x02,0xc9,0x32,0x00,0x8e,0x13,0xca,0xaa,0x45,0x98,0x63,0x9c,0x08,0x8b,0x5c, + 0x24,0x37,0xc0,0x6e,0xe0,0xd6,0xc8,0xb1,0x8d,0x73,0x05,0xb0,0x9b,0x88,0x92,0x78, + 0x22,0xf3,0x82,0xc7,0xc0,0x52,0xa9,0x73,0xf7,0x68,0x4f,0x08,0xd1,0x74,0xda,0xce, + 0xa1,0xf0,0x0b,0x71,0xd4,0xb3,0x9f,0x09,0x02,0x6d,0xb9,0x69,0x89,0x34,0x9a,0xe7, + 0x02,0x00,0x67,0x06,0x3a,0x99,0xf0,0xc5,0x18,0x7b,0x0d,0x32,0xfe,0x92,0x2c,0x32, + 0x93,0xcc,0xc4,0xff,0xfe,0x97,0xf0,0xe3,0x40,0xfc,0xfd,0x7f,0x6c,0xa2,0xf4,0xbd, + 0x0f,0x0c,0x78,0x83,0xfd,0x16,0x78,0x80,0xeb,0x86,0x11,0x98,0x15,0xa9,0x8f,0x49, + 0x89,0x81,0xd4,0x9f,0x48,0x21,0x83,0x89,0xec,0x0b,0x00,0x3e,0x5f,0xa4,0xd3,0x24, + 0x96,0xd4,0x73,0x92,0xc8,0x5c,0xc0,0xa3,0x40,0x0f,0xfd,0x88,0x50,0x1e,0x31,0x75, + 0x18,0x6c,0x41,0x01,0x4d,0x7c,0xdb,0xde,0xb3,0x27,0x1b,0xfe,0xbd,0xd3,0xad,0xbb, + 0x88,0x4d,0x02,0x98,0x1d,0xb9,0xbd,0xd9,0xb1,0xe0,0xe0,0xa6,0x78,0xda,0x1d,0x4d, + 0xef,0xcb,0xd0,0xac,0xc6,0xd2,0x75,0x0f,0x28,0x32,0x3f,0x34,0x64,0x41,0x65,0x13, + 0x61,0x5f,0xed,0x8e,0x47,0x13,0xa5,0xbe,0x4f,0xb4,0xd3,0xf6,0x90,0xbb,0x26,0xf2, + 0x52,0x67,0x72,0xa7,0xd3,0xf9,0x66,0x95,0x74,0xef,0x51,0x6f,0xbf,0x48,0xe6,0xa3, + 0x69,0xcb,0xe7,0x4c,0xea,0x22,0xc0,0xb4,0x6e,0xd5,0x74,0xd9,0x62,0x87,0xc9,0xd5, + 0xe2,0x4a,0x46,0xc6,0xdc,0xbf,0x99,0x62,0x9d,0x9c,0x46,0xa5,0x8b,0x5d,0x10,0xa0, + 0x0e,0xd9,0x17,0xe1,0xdb,0x75,0xdf,0xf9,0xd3,0x3c,0x2f,0xc2,0xf1,0x6d,0x4b,0xc3, + 0x63,0x97,0x47,0xb4,0x86,0xb2,0xb8,0x91,0x32,0xae,0x00,0xcc,0x03,0xc4,0xd1,0xd9, + 0x89,0x05,0xca,0x65,0x76,0x55,0xc2,0x13,0x3d,0xf6,0xc8,0x3f,0xce,0x64,0x10,0xfa, + 0xcd,0x15,0xf6,0xea,0x11,0xf6,0x32,0x17,0x50,0xef,0x43,0x35,0xda,0x87,0x8d,0xb5, + 0x88,0xbd,0xce,0x52,0x09,0x85,0x07,0xaa,0xfe,0x6e,0x77,0x33,0xce,0x2e,0xb1,0xf8, + 0xab,0x38,0x19,0xe6,0x0f,0x31,0x9f,0x4c,0xa6,0xd2,0x2f,0x9a,0x3d,0x35,0xcf,0xce, + 0x64,0xa8,0xc2,0x11,0x51,0xfc,0x12,0xae,0xd0,0xf9,0xef,0x6b,0xd9,0x82,0xd6,0x46, + 0xff,0x74,0xb4,0x22,0xaa,0xc2,0x8e,0x17,0xbf,0x5d,0xe6,0x0f,0x57,0x70,0x35,0xdd, + 0xd0,0xcf,0x16,0x3a,0x02,0xf7,0x76,0x81,0x4b,0x95,0xf7,0x6a,0x22,0x60,0x58,0x9b, + 0xe4,0x21,0x5b,0x67,0x26,0x21,0xc0,0xf0,0x5a,0xd6,0xc9,0x89,0x70,0x51,0xdd,0xf7, + 0x87,0x10,0x0d,0x0c,0xb5,0xcf,0x51,0xb4,0xd3,0x27,0x02,0x80,0xfd,0x2a,0x67,0x74, + 0xee,0x81,0xb2,0xca,0x32,0x0e,0x3a,0xdf,0x54,0x54,0xaf,0x1f,0x22,0x93,0xa7,0xeb, + 0xaa,0x52,0x98,0x6d,0x1c,0x4a,0xc0,0x86,0x87,0xc0,0xf5,0xb5,0x60,0x70,0x48,0x56, + 0x5b,0x0e,0xd7,0xf9,0x80,0x31,0x32,0x27,0x4f,0xa7,0xf2,0x8c,0xd5,0xcc,0x3b,0x73, + 0xb3,0x1a,0xae,0x82,0xb9,0x82,0xd8,0x7d,0xd8,0xb2,0x36,0x7d,0x95,0x20,0xab,0xe4, + 0xff,0x80,0x15,0x32,0xae,0x28,0x59,0xc4,0x56,0x40,0x3c,0xdf,0xa5,0x31,0x6d,0x64, + 0x5b,0x7b,0xa8,0xbd,0x2f,0x23,0x9b,0xb5,0xfd,0x43,0x77,0x25,0x3c,0xc5,0xbe,0x3b, + 0x4e,0x46,0xf3,0x7c,0x81,0x6c,0x43,0x39,0x16,0x81,0x05,0x89,0xf6,0x1e,0xcc,0xb1, + 0x31,0xd0,0x85,0xe4,0x47,0x92,0x4a,0x70,0x32,0xdb,0x1d,0x2f,0x55,0xa4,0xcb,0xd7, + 0xb5,0x44,0x1f,0x2d,0x8a,0x5e,0x2e,0x7d,0xdc,0xab,0xb4,0x0d,0xff,0x3c,0x52,0x69, + 0xdf,0xbf,0x96,0x16,0xd5,0x0d,0x26,0xb2,0xb0,0xec,0xc9,0x34,0xc9,0x8b,0xc5,0xb6, + 0xac,0x57,0x32,0xad,0x6b,0x9d,0x1b,0xb6,0x84,0xb1,0x23,0x63,0x63,0x8e,0xfb,0x42, + 0x80,0x42,0x5e,0x3b,0x6d,0xbb,0x2e,0x00,0xad,0xa8,0x6d,0x24,0xa2,0x38,0x7e,0x28, + 0x10,0x65,0x5d,0x6c,0x6a,0x9b,0x94,0xc0,0xeb,0xff,0xed,0x64,0x56,0x56,0xcc,0x84, + 0x14,0x14,0xd5,0xe2,0xbc,0x1f,0x97,0x6e,0xad,0xb2,0x02,0x98,0x72,0x74,0xb5,0xbd, + 0xdb,0x38,0xda,0x0d,0x90,0xbe,0xea,0x5c,0x5b,0x9e,0xca,0xf4,0xbf,0x86,0x9b,0xec, + 0x38,0x29,0xe4,0xe2,0x0b,0x38,0x6e,0x3d,0x77,0x1d,0x71,0xee,0xe2,0x7d,0xdd,0x8e, + 0xdd,0x18,0xd1,0x02,0xf4,0x0a,0xa4,0x15,0x04,0xfc,0xf7,0xb7,0x6c,0x9c,0xb5,0xae, + 0x66,0xf9,0x64,0xb1,0xb9,0xe7,0x7a,0x20,0x35,0x0a,0x28,0x25,0x63,0x87,0xf7,0x05, + 0x22,0xd0,0xb7,0xd3,0x71,0x4b,0x66,0xeb,0x2e,0xc8,0x52,0x29,0x6f,0x26,0xeb,0x70, + 0x86,0x77,0x94,0x9f,0xa6,0x61,0x10,0xc8,0xf8,0xb2,0x52,0x1c,0x59,0xc9,0xa3,0x70, + 0x46,0x95,0x35,0x3f,0x2e,0x2a,0x24,0x3a,0x9a,0x66,0xc9,0x4c,0x32,0xdc,0x0c,0x53, + 0x91,0xfb,0xb7,0x40,0x88,0x53,0x19,0x6b,0xe0,0x07,0x33,0x17,0x54,0x91,0xcc,0x81, + 0x16,0x73,0x00,0x44,0xe8,0xad,0xaf,0x40,0x29,0x81,0xc3,0x6b,0x3f,0x9a,0x03,0x01, + 0x06,0xe1,0x8c,0x91,0x1e,0x9d,0x49,0x30,0x3b,0xe3,0x31,0x09,0x5d,0x3c,0xce,0x0b, + 0x6b,0xb3,0x8d,0x37,0xba,0x5b,0xad,0x41,0xb4,0xdd,0x51,0xe5,0xfe,0x6a,0xd7,0x4b, + 0x8a,0x0b,0xa2,0x5a,0x01,0xea,0x21,0xfb,0x58,0xc8,0x0f,0x98,0xe3,0xa1,0x3b,0x8f, + 0x7a,0x40,0xa1,0x18,0xfd,0xc0,0x92,0x4f,0x39,0x55,0x50,0x65,0x8a,0xaf,0xfb,0x40, + 0xb9,0x0c,0x8c,0x0a,0x36,0xb6,0x5d,0x5f,0x4f,0x24,0x9b,0xc0,0x11,0xab,0xdc,0x65, + 0x01,0x05,0x9d,0x69,0xd4,0xe1,0xeb,0xca,0xe7,0x23,0x3f,0xcd,0xa5,0x5b,0x7e,0xd9, + 0xa0,0x8f,0x91,0xd3,0xc5,0xce,0x4d,0x6f,0x6d,0x0f,0xb9,0x81,0x68,0xb6,0x4a,0x40, + 0x47,0x0f,0x2e,0x01,0x69,0xc7,0x2d,0x05,0xdf,0xd3,0x82,0x7f,0xb8,0xdc,0x8b,0x60, + 0x51,0x0f,0x29,0xbf,0x66,0x70,0x1f,0xa1,0xb0,0x08,0x81,0xb0,0xf5,0x52,0x61,0x4d, + 0x3b,0xbd,0xb0,0x08,0xd6,0x6a,0x19,0xbf,0x31,0x5a,0xec,0x00,0xf8,0x1a,0x31,0x39, + 0xdf,0x54,0x75,0x9a,0x4c,0xc5,0x03,0x46,0xab,0xe3,0x04,0x81,0x2a,0xab,0x87,0x97, + 0xa7,0xdd,0x8d,0x92,0x4e,0x3d,0xe9,0xdd,0xe7,0x0b,0xbf,0xad,0x9c,0x53,0x26,0x79, + 0x7f,0xb1,0x4b,0x22,0xe4,0xae,0x9f,0x02,0xbf,0xf0,0x5b,0x88,0x06,0x33,0xe9,0xf1, + 0xa9,0xdd,0xe5,0xaa,0xa6,0x7e,0xde,0x7b,0x75,0xfe,0xea,0xb0,0xaf,0xaf,0xbb,0x54, + 0x04,0x3f,0x3f,0x3d,0x3f,0x41,0xc3,0x70,0x52,0xbb,0xab,0x8b,0xde,0xd5,0x4d,0xae, + 0xc0,0x3b,0x27,0xce,0x11,0x57,0xcf,0x75,0x05,0xfe,0xf0,0xe5,0xe1,0xc1,0xc1,0xd1, + 0xaa,0x02,0xff,0xfc,0xe4,0x79,0xef,0x88,0x46,0xab,0x84,0xf6,0xf8,0x55,0xe7,0xec, + 0xf9,0xe9,0x69,0xbf,0x2c,0xc8,0x9f,0x3d,0x3f,0x73,0x4e,0xbb,0xeb,0x05,0xf9,0x57, + 0x47,0xaf,0xba,0x67,0x87,0x3b,0x0b,0xf2,0xdd,0xf3,0xa3,0x93,0x83,0x83,0xaa,0x20, + 0xff,0xf2,0xe8,0xc8,0xe9,0x76,0x88,0x71,0x3f,0x0b,0x6a,0x9c,0x8d,0x27,0x25,0x63, + 0x5f,0xd8,0x0b,0xe9,0x7a,0xd6,0xbd,0x95,0xab,0xa5,0x8e,0x6a,0x5f,0xdc,0xc4,0x74, + 0x79,0x13,0xb3,0x85,0x80,0xb9,0xb4,0xb3,0x5c,0xc3,0xb5,0xeb,0x55,0x9f,0xf5,0xb2, + 0x0e,0x19,0xd0,0x1a,0x8a,0x5d,0x05,0x02,0xec,0xac,0x8e,0xdb,0xea,0x28,0xe4,0xb8, + 0xad,0xce,0x8d,0xa9,0xb8,0x3f,0x38,0x0e,0xc2,0x6b,0x31,0x42,0x64,0xcf,0xbd,0x06, + 0x19,0x67,0x79,0xac,0x2c,0xb3,0xc1,0x31,0xd5,0xf3,0xca,0x7b,0x41,0x52,0xf0,0x59, + 0x31,0x9a,0x06,0xc7,0x53,0x67,0xa0,0xce,0x58,0xf0,0x65,0xad,0x57,0x3e,0x1f,0x36, + 0x44,0x18,0xe0,0x0b,0x7a,0xb7,0xca,0xee,0x6d,0x4d,0x70,0x6f,0xef,0x58,0x57,0x9c, + 0x07,0x7b,0x42,0x1c,0x4f,0xbb,0x83,0x0b,0x99,0xe7,0xb8,0x44,0x8f,0x2e,0x37,0xd5, + 0x98,0xa1,0x9c,0xa1,0x68,0xf1,0x37,0xb0,0x5b,0x5d,0xb4,0x14,0xf1,0xe1,0xe0,0x38, + 0x5d,0xb5,0xa5,0xc4,0x5e,0x4a,0x54,0xd6,0xe9,0xcc,0x73,0xcd,0x12,0x7f,0x51,0x09, + 0x52,0x31,0x3d,0xf8,0xf1,0xe2,0x65,0xc9,0x22,0x2f,0x42,0xf7,0x6a,0xad,0xf1,0x0e, + 0x52,0xfa,0x93,0x18,0xa4,0xa0,0xc6,0xfd,0xc8,0x0a,0xc7,0x0d,0x3e,0x7d,0x18,0x25, + 0xd0,0xa9,0x2c,0xa4,0xd7,0x40,0x1e,0x6b,0x6c,0x73,0xc0,0x0a,0x01,0x77,0xaa,0x56, + 0x04,0x0a,0x68,0x6a,0xd1,0xf8,0xc6,0xe0,0x14,0x43,0x61,0xef,0x99,0x28,0x12,0x68, + 0xec,0x1a,0x91,0xe2,0xb8,0xcd,0xdd,0x14,0x15,0xd0,0x61,0x3d,0xf2,0x8c,0x7a,0x8c, + 0xa8,0x01,0x74,0xaf,0x21,0xed,0x89,0x2d,0x9c,0xe7,0x5d,0xdb,0x39,0x3c,0xb2,0x3b, + 0x76,0xf7,0xa9,0xf8,0xe5,0x2f,0xff,0x29,0x60,0x64,0x48,0x9f,0x82,0xce,0x40,0x89, + 0xf2,0x90,0x80,0x4f,0x1c,0x43,0xf4,0x92,0x44,0x4a,0x14,0x67,0x00,0x40,0x50,0xaa, + 0x1c,0x85,0x33,0x3f,0x6a,0x08,0x58,0x75,0x24,0xe3,0x49,0x31,0xf5,0x1a,0x0e,0x3f, + 0x13,0xa0,0xd7,0xbb,0xbe,0x10,0x8d,0xfc,0x1b,0x15,0x73,0xc3,0x39,0xcc,0x0e,0xb0, + 0x81,0x2a,0x27,0xa4,0xfb,0x59,0x48,0x07,0x78,0xda,0x14,0x80,0xb8,0x1b,0x83,0x0b, + 0x7c,0x42,0x53,0xdc,0x6f,0xf7,0x30,0x75,0x51,0x0d,0x9b,0xa8,0x45,0x2a,0x55,0xc8, + 0x62,0x1a,0x62,0xba,0x1f,0x73,0x29,0xe8,0x9b,0x18,0x69,0x71,0xfd,0x2a,0x8a,0x0a, + 0x0f,0x2b,0x92,0x7c,0x94,0xad,0x45,0x4f,0x5f,0xd7,0x09,0xad,0xd4,0xdc,0x26,0x3d, + 0x6f,0x5a,0x24,0x80,0x97,0xa2,0xc2,0x5f,0xfc,0x2c,0xf4,0x5b,0xf4,0x58,0x83,0xd7, + 0x48,0x11,0x7b,0x0b,0x59,0x13,0x1b,0xec,0x52,0x8f,0x21,0xd4,0xd9,0x18,0xfc,0x14, + 0x16,0x53,0xe1,0x57,0xec,0x0b,0x92,0x4d,0x60,0xd5,0xe0,0x56,0x92,0x52,0x45,0x91, + 0xae,0x73,0xe5,0x11,0x54,0x17,0x94,0xd1,0x98,0x6a,0x6f,0x12,0xf9,0xea,0x56,0x0c, + 0x91,0x16,0xb8,0x7a,0x47,0x40,0x8d,0x9a,0x98,0x63,0xe8,0x37,0xc8,0x08,0xa9,0x91, + 0xd6,0xe3,0x44,0x64,0x52,0x2b,0x9a,0x6b,0x86,0xb1,0xc8,0x8a,0x94,0x1c,0x55,0x24, + 0x99,0x38,0x99,0x07,0x61,0xc2,0x47,0xa3,0xe2,0x42,0x16,0xf3,0xd4,0x56,0x48,0xb0, + 0x64,0x69,0xea,0xe7,0x64,0x2a,0x84,0xc5,0xd3,0x42,0xd9,0xa2,0xcf,0xda,0xe6,0x79, + 0x9a,0x9a,0x90,0x2b,0x8e,0xe5,0x6c,0xf0,0xd3,0x34,0x81,0xc5,0xdc,0x96,0x46,0x45, + 0xe3,0x66,0xd2,0x15,0x27,0xf1,0x2d,0xe0,0xe6,0x71,0x1b,0x3d,0x4c,0x5b,0xd0,0x9a, + 0xa9,0xda,0x89,0x26,0xab,0xea,0x39,0x06,0xee,0xe4,0x65,0x96,0xf3,0xba,0x2b,0x21, + 0xa8,0x05,0xe5,0x22,0x0a,0x73,0xd8,0xa9,0x00,0x3f,0xc7,0x04,0xd3,0xeb,0x67,0xbb, + 0xc7,0x6d,0x6e,0xb1,0x68,0x3d,0xc8,0x87,0xb4,0xfc,0xe1,0x2d,0x7d,0xcb,0x20,0x35, + 0x98,0x39,0xba,0xd8,0x3a,0x08,0x1c,0x07,0xd1,0xb6,0x01,0x03,0x17,0x42,0x4b,0x41, + 0x31,0x38,0x51,0x43,0xa0,0xb0,0x02,0xd7,0x01,0x6b,0x95,0xc8,0xb0,0xe3,0x07,0xc1, + 0x7d,0x0e,0x50,0x8d,0x3f,0x9d,0xfa,0x58,0x50,0xb4,0x36,0x7e,0x84,0xb6,0x87,0x8e, + 0xff,0x20,0x47,0x12,0xa6,0x43,0xb5,0x58,0x68,0x3c,0x2e,0xd6,0x09,0xc5,0xc5,0x16, + 0x1d,0x7c,0xa1,0x27,0x80,0xda,0x55,0xfc,0xdc,0x0a,0xa5,0x6f,0x28,0xc9,0x88,0x5f, + 0xfe,0xed,0xaf,0xfa,0xf8,0x7b,0x3b,0xa2,0xaa,0x44,0x55,0x06,0x28,0xed,0x32,0xd5, + 0x3d,0x6d,0xda,0x2d,0x7f,0x98,0x37,0xd6,0x1d,0x09,0xa1,0x97,0xa9,0x2b,0xe8,0x4f, + 0x71,0xb7,0x74,0xbb,0x74,0xf0,0xb1,0x32,0xe1,0x2b,0x29,0x69,0xc7,0x20,0x3a,0xbf, + 0xfc,0xe5,0x3f,0x9c,0xee,0x33,0x51,0x16,0x95,0x04,0x00,0xa0,0xe0,0xba,0x10,0x59, + 0x2f,0x16,0x1b,0xe4,0xd0,0x9b,0x4d,0xd6,0x22,0xce,0x4e,0x7e,0xe2,0x0a,0x37,0xb5, + 0x90,0xc2,0x11,0x83,0x81,0x6a,0xc2,0xd8,0x87,0xc1,0xf3,0x10,0x65,0xd6,0x7c,0x28, + 0x4c,0x56,0x16,0x20,0xca,0x8e,0x30,0xb6,0x0a,0x08,0x54,0xed,0x3e,0x01,0xe2,0x05, + 0x9b,0xac,0xf8,0x0d,0x7f,0xbe,0x6f,0x8d,0x99,0x8c,0x76,0xac,0xf1,0x83,0xae,0x8e, + 0x61,0xf3,0x83,0xa5,0xac,0xaf,0xf3,0x82,0xf9,0x7e,0xe2,0x88,0xb6,0xf8,0xe5,0xdf, + 0xff,0xea,0xf0,0xa2,0xa8,0x9b,0x68,0x1e,0xf6,0xc4,0xdf,0xff,0x26,0x62,0x58,0xfc, + 0x39,0x98,0xa1,0xd8,0x42,0x8e,0xec,0x17,0xf0,0x90,0x54,0x60,0x54,0x44,0xa6,0x29, + 0x63,0x32,0xdc,0x0c,0x5f,0x3e,0xa7,0x51,0x88,0xcd,0x0d,0x20,0x99,0x78,0xe7,0x7f, + 0xb6,0xc4,0x47,0xaa,0x1e,0x9f,0xc9,0x1c,0x48,0x94,0x4a,0xf1,0xa3,0x39,0x76,0x5e, + 0x33,0xde,0x9f,0x6e,0x2d,0x68,0x65,0x0c,0x35,0xa5,0xd2,0x51,0xc4,0x8e,0x9c,0x03, + 0x58,0xc8,0x6b,0x02,0xdf,0xa4,0x06,0x78,0xf3,0x2d,0x27,0x4c,0x95,0x5c,0x74,0x56, + 0x51,0x12,0xe0,0xad,0xf8,0x30,0xf9,0xac,0x93,0x2d,0xd0,0x18,0x42,0x27,0xb5,0xc9, + 0x60,0x20,0x66,0x09,0xe4,0xe1,0x47,0x94,0xb6,0xe6,0x94,0xa5,0x10,0x49,0xa7,0x14, + 0x7f,0x35,0x9d,0xdd,0x66,0x3e,0x69,0xac,0xa3,0x82,0xab,0xc6,0xe0,0x8d,0x53,0x26, + 0xd4,0xfa,0xd4,0x5c,0x6d,0x6f,0x50,0xc1,0xd8,0x6b,0x74,0x38,0x0b,0x21,0xff,0x38, + 0x0d,0x96,0x2c,0xbe,0x35,0x94,0xc5,0x79,0x8d,0x5e,0x43,0x30,0xec,0xbc,0xa2,0x6e, + 0xeb,0xb4,0xb3,0xcd,0x5c,0xfd,0x20,0x6e,0xba,0xbf,0x13,0x37,0xce,0xef,0xc2,0xcd, + 0xd3,0xdf,0x89,0x9b,0xee,0xef,0xc2,0x4d,0xef,0x77,0xe2,0xe6,0xe9,0xaf,0xe6,0x86, + 0x8f,0x3d,0xf4,0xa8,0xc1,0x98,0x8a,0x0d,0x08,0x8f,0x47,0xca,0x1d,0xe1,0x78,0xe4, + 0xfd,0x14,0x2a,0x57,0x88,0x6d,0xe0,0x08,0x57,0x38,0x6b,0x2d,0x63,0xaa,0x23,0x60, + 0x98,0x73,0xa8,0xfb,0xd3,0x48,0xa2,0xb0,0x39,0xf5,0x4e,0x6f,0xaa,0x52,0xfc,0xd5, + 0xd7,0x73,0x7c,0x6d,0x18,0x03,0x7c,0x3d,0x90,0xbf,0x6e,0xaf,0x8d,0xda,0x1b,0x6b, + 0x68,0x3b,0x2e,0x57,0xfa,0x66,0x7d,0x05,0xa7,0xa7,0xa2,0xdb,0x5b,0x87,0x9f,0xb5, + 0x51,0x43,0x1f,0x72,0x3c,0x0e,0x71,0x27,0xdc,0x71,0xf7,0x5a,0x25,0x8e,0x3a,0x64, + 0x7d,0x38,0x1b,0xdd,0x2d,0x36,0x0e,0xfe,0x11,0x6c,0x3c,0xdd,0x62,0xe3,0xf0,0x1f, + 0xc1,0x46,0x6f,0x8b,0x8d,0x67,0xbf,0x1f,0x1b,0xf7,0x21,0xc5,0xca,0x84,0x14,0x6e, + 0x7c,0x4b,0xd9,0xa8,0x4c,0xa4,0x39,0x65,0x48,0x42,0x4b,0x94,0x35,0x73,0x29,0xf9, + 0x62,0xc6,0x30,0xee,0x16,0x16,0xee,0x67,0x05,0x1d,0x0b,0x23,0x1d,0x21,0xe3,0x55, + 0x68,0x91,0xea,0x78,0xd4,0x4c,0xe3,0x28,0x85,0xf5,0x4b,0x0a,0x06,0x86,0xfb,0x57, + 0x92,0xea,0xc2,0x82,0x36,0x03,0xa2,0x49,0x93,0xb9,0x84,0x0d,0x09,0x3c,0x2a,0x24, + 0xf1,0x16,0xc9,0x00,0x29,0x0a,0xdb,0x89,0x44,0xec,0xfb,0xb3,0xb4,0xaf,0xf0,0x23, + 0xdd,0xfa,0x58,0x0e,0x7e,0x87,0xc1,0xa6,0x50,0xcf,0x6e,0x29,0xfe,0xd2,0x70,0x74, + 0x05,0x7c,0xc9,0x40,0xec,0x4b,0x88,0xe5,0xe2,0x26,0x2c,0x46,0xd3,0x0a,0xa9,0xa8, + 0xed,0x51,0x95,0xab,0x91,0x80,0x1a,0x5f,0x4a,0x54,0x94,0xb4,0x15,0x3b,0x04,0xfc, + 0x09,0x0c,0x30,0x0e,0xa9,0xed,0x9f,0x76,0x3a,0x76,0xf2,0xab,0xc1,0xfb,0x0f,0xe3, + 0xb1,0x06,0x37,0xbc,0xb8,0x04,0x72,0x26,0xb8,0xce,0xe2,0x55,0xe0,0x9b,0x11,0x3a, + 0x83,0x94,0xaf,0x2f,0x9a,0x00,0x13,0x90,0x41,0xb5,0x6a,0x2e,0xd8,0x69,0xcb,0x2c, + 0xb0,0xe7,0x2e,0xa6,0x83,0x77,0x40,0x0c,0xfe,0x04,0x40,0x0b,0xdf,0xe9,0xfa,0x75, + 0x2c,0x9a,0x84,0x2b,0x58,0xee,0xea,0x14,0xa2,0xba,0xf7,0x03,0xa4,0xd3,0x54,0x00, + 0x8c,0xee,0x52,0x37,0x7d,0xb3,0x0d,0x6a,0x35,0xb2,0x01,0x9b,0x30,0x81,0xb3,0x2e, + 0x1c,0x0c,0x97,0xd4,0xc4,0xa8,0xce,0x41,0x5b,0xcf,0x12,0xe5,0xd9,0x5f,0x05,0xe0, + 0x78,0xb6,0x10,0x30,0x37,0x10,0xf3,0xb8,0x08,0x23,0x60,0xbb,0x29,0xad,0x99,0x9f, + 0x39,0x50,0x10,0x87,0x2c,0x55,0xed,0x23,0xce,0x42,0x6c,0x3d,0x8a,0x24,0x33,0x4a, + 0xd4,0x6e,0xcf,0xc2,0x40,0xc3,0x75,0x71,0x93,0x41,0xc2,0x2c,0xbe,0x9c,0x50,0x8d, + 0x9e,0x1c,0xd8,0x4e,0x7d,0xdf,0xc9,0x69,0x8f,0x38,0x7d,0xb6,0x93,0xd3,0xf2,0x14, + 0x73,0x85,0xbd,0x76,0x74,0x63,0x36,0x69,0x93,0x15,0xaa,0xb2,0x36,0xdb,0x38,0x10, + 0x70,0x4e,0x9a,0x1b,0x26,0xb4,0x2b,0xdd,0x39,0xf7,0xf7,0x74,0x4c,0x70,0x48,0x32, + 0x38,0x5c,0xc9,0xe9,0xa5,0xc2,0x91,0x8a,0x70,0x13,0xdf,0x3c,0x91,0xd2,0xfe,0xc1, + 0x12,0x53,0xaa,0xb5,0xdc,0x4c,0xc3,0x48,0xf2,0x57,0x73,0xf7,0x10,0xee,0x0c,0x39, + 0xb6,0x89,0x77,0x49,0x8f,0x35,0xde,0x33,0xfb,0xfb,0x2c,0x99,0x08,0xda,0x5c,0x4c, + 0x64,0x45,0xe9,0x02,0x43,0xb0,0x75,0x4a,0xd5,0x36,0x88,0x36,0x3c,0x61,0x1c,0xc8, + 0xcf,0xd5,0x7d,0xbd,0x3f,0xa2,0x37,0x00,0x30,0x6c,0x9d,0x32,0xfe,0x96,0xf6,0xb5, + 0x69,0xd6,0x1a,0x36,0xbb,0x24,0xa0,0x07,0x38,0xbd,0x25,0x72,0x76,0x57,0x80,0xea, + 0xc1,0xc7,0xcc,0x1f,0x5d,0x11,0x9c,0x64,0x1f,0x60,0xe4,0x3c,0x4b,0x0a,0xde,0x19, + 0x90,0x33,0x12,0x28,0x2f,0x5d,0x05,0xf6,0xf0,0x9a,0x5d,0x58,0xf7,0x5c,0x0d,0xd5, + 0x1d,0xe9,0xf9,0x17,0x18,0x31,0xba,0xb0,0x21,0xc5,0xe2,0xb4,0xc8,0xa2,0xf6,0xe9, + 0x2c,0x68,0xbd,0x03,0x1a,0x06,0x56,0xbe,0xa2,0x87,0x4f,0xfc,0xcc,0x9f,0x21,0x9b, + 0x03,0x20,0xf3,0x63,0xca,0xbe,0xb2,0xbc,0x6d,0x67,0x53,0xc5,0xd3,0xc1,0x87,0x8f, + 0xef,0x5b,0x1c,0x1a,0x9a,0x27,0x69,0x1a,0x49,0xfa,0x6a,0x12,0x28,0x38,0x86,0x09, + 0x67,0x72,0xec,0x35,0xa6,0x45,0x91,0xe6,0x6e,0xbb,0x3d,0xc1,0x26,0x75,0x3e,0xb4, + 0xb1,0x1b,0x6d,0xcb,0x78,0x32,0x9b,0xc7,0x93,0x76,0x6d,0xbb,0xd9,0x1e,0x46,0xc9, + 0xb0,0x3d,0xf3,0xc3,0xb8,0x1d,0x24,0xa3,0xbc,0x0d,0x93,0x0e,0x5b,0xbe,0xde,0x6e, + 0xcc,0x82,0xc6,0x20,0xa7,0x8d,0xb4,0xa5,0x36,0x02,0xc3,0x5b,0x0d,0x33,0xfc,0xc1, + 0xef,0x35,0x55,0x9e,0xca,0x11,0xcf,0xa3,0x76,0xcc,0x71,0x01,0xe1,0x15,0x34,0x01, + 0xd5,0x27,0x78,0xa1,0x7b,0x3a,0x6a,0x95,0xef,0x6e,0xec,0x55,0x8f,0x68,0xff,0xa1, + 0x19,0x56,0xcf,0x85,0x83,0xe2,0x7c,0x06,0x28,0x64,0x03,0xbd,0xbf,0x8a,0x24,0x7d, + 0x7d,0x79,0xfb,0x3a,0x40,0x8f,0xe5,0x1e,0x3d,0xe4,0x4e,0xe7,0x3a,0x1e,0xbf,0x65, + 0x40,0xf1,0xe3,0x8c,0x5e,0xd2,0xf0,0xc6,0x7e,0x94,0x4b,0x2b,0xc8,0xfc,0xc9,0x04, + 0x96,0xa1,0x2f,0x69,0x73,0xc0,0x1b,0x16,0x19,0xe8,0x96,0x79,0x3e,0xfc,0x89,0x1e, + 0xd2,0x2f,0x1b,0xfa,0x7b,0xed,0x36,0x07,0x04,0x0e,0x58,0xba,0x14,0x99,0x57,0x4f, + 0x14,0xc1,0x1c,0xe8,0x31,0xa2,0x79,0xcc,0x15,0x10,0x21,0x83,0xb0,0x50,0x19,0x49, + 0x9d,0x48,0x89,0x9b,0x10,0x5b,0x0d,0xd8,0x27,0xf9,0x49,0xe2,0x07,0x44,0xac,0x7a, + 0x18,0x09,0xeb,0x4f,0xf8,0x69,0xfd,0x71,0x98,0xcd,0x6e,0xfc,0x4c,0x6a,0xbf,0xc3, + 0xd6,0x91,0xaa,0x61,0x4c,0xd9,0x5e,0x09,0x00,0xba,0xe1,0x95,0x34,0xaf,0xcd,0xc5, + 0x6a,0x59,0xef,0xcf,0x6d,0x7e,0x09,0xc5,0xbb,0x5e,0x12,0xf1,0x13,0x4c,0x94,0xcf, + 0xa3,0x82,0x78,0xcc,0xfd,0x30,0x10,0x43,0xec,0xc4,0x02,0x29,0x2a,0x81,0x27,0x91, + 0x8a,0x74,0x23,0x7f,0x4e,0x0e,0x1c,0x16,0xae,0xc8,0x23,0xf0,0x87,0x2c,0x22,0x9a, + 0xba,0x56,0x63,0x5a,0x44,0xea,0x8a,0x9b,0x18,0xfe,0x99,0x54,0x9f,0x48,0x54,0x17, + 0x76,0x1a,0x6c,0x0e,0x3f,0x48,0x84,0xd7,0x6b,0x49,0x8f,0x62,0x15,0x53,0xa6,0xaf, + 0x0e,0xd7,0xb8,0x96,0x41,0x8f,0x53,0x11,0x70,0x0d,0x6a,0xfc,0x23,0xed,0x17,0x4d, + 0x36,0xfc,0xdc,0x4a,0xae,0x3e,0xca,0xcf,0x85,0x45,0x33,0x9b,0x0b,0xf8,0x32,0x29, + 0x8d,0xaa,0xb7,0x1e,0x89,0xe3,0xc7,0x0f,0x6f,0x2f,0xa4,0x9f,0x8d,0xa6,0xef,0xb9, + 0x73,0xd3,0xb4,0x64,0xe4,0xfd,0xa1,0x49,0x9d,0xef,0xee,0x0c,0x70,0x61,0x98,0x7d, + 0x8c,0x81,0xb3,0x35,0x69,0x1c,0x9d,0xba,0x2a,0x87,0xca,0x4d,0xa2,0xc1,0xef,0x4f, + 0x5c,0x59,0xaa,0xe5,0xd3,0xd5,0x25,0x77,0x5e,0x7b,0xa1,0x40,0xbd,0xe7,0x43,0x16, + 0x69,0x58,0x0b,0xb8,0xe1,0x34,0x09,0x5c,0xe3,0xfd,0x0f,0x17,0x1f,0x0d,0x8b,0x08, + 0xb8,0xf4,0xb1,0xdc,0xf1,0xca,0x81,0x26,0xb2,0x7a,0x81,0x60,0xfb,0x9d,0x17,0x55, + 0xf3,0x19,0x37,0x03,0x3b,0xb9,0xf2,0x3c,0x65,0x42,0xe6,0x02,0x4a,0xca,0xfd,0x5b, + 0x34,0x4a,0x7a,0x4b,0x01,0x8b,0x18,0xfb,0x50,0x74,0x60,0x58,0x06,0x1a,0x0c,0x8b, + 0x4e,0xe1,0x4b,0xcb,0x5e,0x32,0x85,0x94,0xce,0x08,0x40,0xaf,0x0f,0x52,0x4a,0x56, + 0xa6,0xa6,0xa1,0x25,0x67,0x24,0x57,0xeb,0xe3,0x68,0x91,0x60,0x68,0x44,0xb6,0x5b, + 0x7f,0x17,0x43,0x0f,0x33,0xe2,0x44,0xeb,0x67,0x55,0xb2,0xe2,0x2c,0x5b,0xe3,0x61, + 0x09,0x49,0x2d,0x6b,0xf6,0x06,0x8b,0xfe,0x27,0xd2,0xd9,0xb0,0x88,0xd9,0x8f,0xb4, + 0xd6,0x78,0x91,0x7f,0x68,0x1a,0xaa,0x7c,0x6b,0x98,0xb6,0xda,0x29,0xd1,0x05,0x31, + 0x81,0x09,0xe9,0xcd,0x25,0x1e,0xc5,0x2a,0x5f,0xe8,0x03,0xfb,0xbc,0x58,0x96,0x14, + 0x76,0x08,0x8e,0x64,0xb6,0xbf,0x4f,0x62,0x7b,0x54,0x8a,0xad,0x32,0x78,0x75,0xa9, + 0xd8,0x83,0x61,0xfe,0x00,0x87,0xe3,0x9d,0x95,0xab,0x77,0x52,0x54,0xbb,0x50,0x7b, + 0x23,0x05,0x9d,0xa4,0xa0,0xd3,0x3e,0xab,0xda,0x2f,0x55,0xb5,0x0d,0x7d,0x97,0x4f, + 0xa7,0xd8,0xc6,0x1d,0xd7,0xa9,0xf2,0x69,0x18,0x04,0x91,0xb4,0x05,0xbd,0xeb,0x01, + 0x64,0x59,0x64,0xb7,0xe4,0x43,0x9f,0x66,0x70,0xa6,0x30,0x8d,0x42,0x32,0x6c,0x44, + 0xa6,0x30,0x4f,0xb2,0x4b,0x7a,0x82,0x30,0xba,0x65,0xc7,0x0f,0x15,0x58,0x12,0x03, + 0xe1,0xd8,0x1c,0x7a,0xbe,0x3b,0x79,0xfd,0xfd,0x85,0xf7,0xe9,0xd3,0x91,0xe5,0x5c, + 0x5a,0x9f,0x7a,0xfc,0xf9,0x94,0x3f,0xbb,0xfc,0xe9,0xe8,0xcf,0x2e,0x7f,0x3e,0xe5, + 0xcf,0x1e,0x7f,0x1e,0xf2,0xe7,0x91,0xea,0xa3,0x6e,0x3b,0x87,0x97,0x97,0xfd,0x95, + 0x46,0x26,0xb0,0x8a,0xd7,0x94,0x2a,0x9b,0xe0,0xca,0x02,0x37,0xac,0x8a,0xd2,0x0f, + 0x42,0xaf,0xd3,0x0f,0x8f,0x79,0x7e,0x5b,0xd5,0xbe,0xfb,0xe1,0x93,0x27,0x26,0x04, + 0xcb,0x6d,0x9f,0xc2,0xcb,0x4f,0x9d,0x4b,0xd8,0x24,0xc6,0xee,0xef,0x57,0x4d,0x0e, + 0x35,0x11,0x29,0x6d,0x48,0x61,0xcd,0x5b,0xd0,0x73,0xe0,0xbc,0xe8,0xb8,0x75,0x9a, + 0x2d,0x67,0xcd,0x48,0x88,0x25,0x52,0x69,0x73,0x52,0xb9,0xc7,0x04,0xd3,0x60,0x58, + 0xd3,0xf8,0x97,0x79,0xa7,0x13,0x3c,0x33,0x9e,0x50,0x83,0xe9,0x36,0x27,0x98,0x8b, + 0xdb,0x9d,0x36,0xb5,0x39,0x68,0x33,0x78,0xbb,0x6b,0x98,0xac,0xd6,0x6e,0xaf,0x52, + 0x98,0xaf,0xb2,0x62,0xf3,0xfd,0xab,0x53,0xc7,0xf9,0xd0,0xea,0x75,0xbb,0x9d,0xf3, + 0xd6,0x45,0xa7,0xd3,0xed,0x99,0x7d,0x41,0x50,0x4e,0xe9,0x5d,0x12,0x78,0x4b,0xe8, + 0x4f,0x2d,0xcc,0x64,0xc9,0x8d,0x62,0xc8,0x02,0x00,0xac,0x82,0x0b,0x4c,0xe0,0x23, + 0x48,0x7a,0xdd,0xde,0xb7,0xc4,0x4e,0x9b,0xe6,0xef,0xeb,0x7b,0x85,0x57,0x5b,0xc5, + 0x13,0x43,0x10,0xdf,0xc3,0x67,0xc2,0x78,0xd2,0xe4,0x77,0xb0,0xf8,0xf1,0x94,0xa6, + 0x26,0xf0,0xad,0xd3,0x31,0xdb,0xf8,0xf7,0xc4,0x68,0x13,0x8b,0x06,0xd1,0x80,0x84, + 0x69,0xaa,0xe2,0x89,0x77,0xcf,0x58,0x70,0xdc,0xde,0x1a,0xaf,0x60,0xa7,0x51,0x13, + 0x77,0xb1,0xee,0x7e,0xfc,0x10,0x2a,0xa2,0xdf,0x56,0xd2,0xfb,0xd7,0x39,0xd0,0xb9, + 0x02,0x51,0x49,0x76,0x12,0x45,0x4d,0x63,0xd7,0x23,0x9f,0x24,0xd5,0x8a,0x98,0x9f, + 0x65,0x94,0x36,0x34,0xa5,0x93,0x2c,0xf3,0x6f,0xed,0x30,0xe7,0xbf,0x68,0x7f,0x71, + 0xed,0x7e,0xba,0xb6,0xf8,0xbf,0xcb,0xda,0xa0,0x32,0x08,0x91,0x0c,0x39,0xa3,0x06, + 0xa5,0xc4,0x90,0x49,0xbc,0xc0,0xce,0x8a,0xf4,0xbd,0x04,0x8b,0x83,0x4e,0x5f,0x45, + 0x04,0x8e,0x06,0xf5,0xd7,0x18,0x1f,0xa1,0x13,0x41,0xed,0x99,0x7c,0x61,0x24,0xe3, + 0xb1,0xe1,0x36,0x31,0x12,0x16,0x50,0x1d,0xde,0x10,0xb0,0x80,0xac,0x2a,0x5a,0x08, + 0x8d,0x86,0x69,0xd2,0xdb,0x95,0xd0,0x70,0x8c,0x3c,0xad,0x42,0x7d,0x49,0x9b,0xf1, + 0xde,0xf7,0xf4,0x7e,0x95,0x91,0xcf,0x87,0x34,0x12,0x94,0x28,0x6a,0xac,0x66,0xb9, + 0x32,0x5c,0x43,0x0d,0x62,0x20,0x20,0xfd,0x00,0xa9,0xc3,0xa0,0xbf,0xaa,0x95,0x5e, + 0x1b,0xab,0xfa,0x9b,0x0b,0x7e,0x63,0xa1,0x46,0x96,0xdf,0x96,0x20,0x56,0xfb,0x7a, + 0x54,0x6b,0x73,0x4d,0x06,0xe3,0x30,0x4a,0xaf,0x9c,0x09,0xe5,0x46,0xf7,0x74,0xb3, + 0xfb,0x47,0xf6,0x2a,0x8e,0x4c,0xaa,0x9e,0x6f,0xf4,0x29,0xc6,0x4b,0x84,0x34,0x62, + 0x06,0x0b,0xb8,0x8f,0x8b,0xf8,0x0b,0x4c,0x9c,0x56,0x12,0x84,0x03,0x6c,0x48,0x70, + 0x75,0x2e,0xa3,0x96,0xcc,0xf2,0xbb,0x87,0xb9,0xaa,0xda,0x1d,0x92,0xd7,0xf1,0xd2, + 0x78,0x43,0x48,0x45,0x51,0x09,0xe0,0x02,0x76,0x89,0x38,0x45,0xef,0x17,0x86,0x78, + 0x5d,0x28,0x40,0x53,0x9d,0xca,0x88,0xa4,0x7a,0xe0,0xdb,0x56,0x82,0x2f,0xd7,0xb6, + 0x73,0x4d,0xf7,0x2f,0x48,0x4f,0xd1,0x34,0x4e,0xfd,0x28,0xa2,0xe2,0xb8,0xf1,0x44, + 0x35,0x3d,0x31,0x7e,0xf9,0xcb,0x7f,0x1b,0x30,0x89,0xb7,0x95,0x49,0x7c,0x79,0x4d, + 0x25,0xb3,0xaf,0xe9,0xc4,0x87,0x28,0xa9,0xe3,0xa6,0x6e,0x47,0xe4,0x36,0x76,0x22, + 0xf4,0x94,0x16,0x03,0xa0,0xf2,0x24,0xa9,0x2d,0xbe,0x97,0xc5,0x4d,0x92,0x5d,0x89, + 0x52,0xb1,0xd5,0x66,0x58,0x9d,0x1d,0xe5,0xf5,0xc3,0x23,0x5a,0xe5,0x69,0xfd,0xec, + 0xe7,0x96,0xca,0xc8,0xa5,0xbc,0x19,0xfb,0x50,0xde,0x64,0xa1,0xac,0x1d,0xe3,0x30, + 0xc1,0xd5,0xf9,0x18,0x8b,0x91,0x5e,0xa0,0xa5,0xa3,0x03,0x65,0x11,0x64,0x99,0x15, + 0xae,0x33,0xb7,0x13,0xac,0x5a,0x18,0x79,0x88,0x76,0x09,0x7d,0x9c,0xb8,0xa9,0xd3, + 0xad,0xc3,0x45,0xd6,0x20,0xf8,0x84,0x02,0x9b,0x24,0x56,0x7c,0x7d,0x62,0x98,0x95, + 0x9f,0xac,0x51,0xe2,0x03,0x75,0x5b,0x1f,0xc9,0x7b,0x7a,0x18,0xba,0xd2,0x73,0x57, + 0xe5,0xbc,0xb4,0xae,0x8d,0x49,0x31,0x41,0x98,0x82,0xb5,0x96,0x81,0xc8,0xe6,0xf2, + 0x84,0x64,0x46,0x77,0x77,0x07,0x9d,0x4e,0x8f,0x82,0x1d,0x39,0xba,0xa8,0x6d,0x0f, + 0x4a,0x5a,0xb4,0xc7,0xdb,0x52,0xe0,0x48,0x1d,0x46,0x21,0x31,0x75,0x5e,0x18,0x90, + 0x11,0xe2,0x06,0x3a,0xb2,0x49,0xe8,0x5b,0x25,0xe3,0xa3,0xb8,0xd8,0xe6,0x24,0xfb, + 0x7c,0x77,0xc7,0x11,0xb6,0x0c,0x2e,0x05,0x37,0x94,0x31,0x01,0xb1,0x1a,0x73,0x24, + 0xf3,0x82,0x0a,0x3d,0x00,0x1b,0x06,0xa0,0x79,0xc9,0x0e,0x9f,0x16,0xad,0x07,0x9a, + 0xd1,0x14,0x34,0xd0,0x0a,0xe9,0xc1,0x23,0x6b,0x42,0xe3,0x53,0x97,0xfb,0xfa,0xa2, + 0x23,0x75,0xaf,0x26,0x45,0x2a,0xcd,0x3d,0x8a,0xc2,0x6a,0xe6,0x79,0x74,0x77,0xe7, + 0x98,0x94,0xc2,0x6b,0xad,0x67,0xe1,0x35,0xb5,0x96,0x43,0xf2,0xc8,0xab,0x72,0x40, + 0x1d,0xef,0xe6,0xc8,0xf3,0xf9,0x71,0xaf,0x9f,0x23,0xb9,0x57,0x78,0xf3,0x51,0xb9, + 0xb1,0x31,0xf3,0xe8,0x53,0x7e,0xa9,0x4d,0x66,0x0d,0x30,0xe4,0x68,0xe7,0x19,0xf1, + 0x57,0xc7,0x05,0x22,0x37,0xf1,0x14,0x16,0x50,0xef,0xaa,0x37,0x6b,0xc3,0xcd,0x4b, + 0xd5,0x4b,0x35,0x01,0x4f,0x43,0xbe,0xdf,0xd3,0x39,0xce,0x5a,0x02,0x42,0xf6,0xd9, + 0xb4,0x86,0xf5,0x14,0xcc,0xc8,0x74,0x6f,0x9d,0xcb,0xfd,0xfd,0x47,0xb5,0xad,0x97, + 0x5e,0x06,0x2f,0x8e,0xe4,0x48,0xcc,0xda,0xec,0xb6,0x2b,0x88,0x38,0xb3,0x56,0xdb, + 0xbf,0x99,0x42,0x31,0x39,0x12,0x39,0x82,0x3f,0xad,0x28,0xd4,0x28,0x86,0x9a,0x96, + 0xb5,0xa0,0x87,0x39,0x48,0x43,0xea,0xf0,0xc7,0x23,0xe2,0x25,0x37,0xbc,0x73,0x80, + 0x21,0xdc,0x9f,0x54,0x1f,0xf3,0xd6,0x47,0x3d,0xc2,0xb7,0xbe,0xe3,0x50,0x48,0xab, + 0xc7,0xf0,0x6a,0xc5,0xfa,0xb5,0xa7,0xd4,0xf8,0x3e,0xc9,0xef,0xee,0x3e,0x1d,0xf6, + 0xac,0xf2,0xff,0x4b,0x13,0xfc,0x29,0x9e,0xae,0x88,0xd5,0x4d,0xf9,0x85,0x95,0xdf, + 0xa9,0x37,0x84,0xc9,0x80,0x9a,0xcd,0xeb,0x36,0x60,0x03,0xe0,0x02,0x59,0xf2,0x37, + 0x14,0x00,0x3b,0xdf,0x18,0x5f,0x20,0x62,0x5f,0x6f,0x28,0x81,0xa8,0x5c,0xbb,0xc6, + 0xdf,0xff,0xa6,0x40,0xca,0x52,0x19,0x6d,0x59,0x8a,0xdd,0x72,0xf5,0xd2,0x6a,0xeb, + 0x8e,0x4e,0x06,0x5c,0x09,0xef,0xd1,0x2a,0x6d,0x12,0x4e,0xa9,0x39,0x4b,0x12,0x73, + 0x59,0xc3,0x5b,0xdf,0x75,0xf0,0x26,0x80,0xa2,0x8a,0xde,0x05,0x68,0x87,0x73,0x0d, + 0x1a,0xb4,0xb4,0x0c,0x25,0xdd,0x5c,0xaa,0xb0,0x18,0x66,0xea,0xc0,0x05,0xdb,0x91, + 0x2b,0xde,0xe1,0x99,0xcb,0xfe,0x5e,0xcd,0xc9,0x7e,0xe5,0x1c,0x34,0x68,0x7d,0x8e, + 0x6c,0xed,0x2c,0x74,0x73,0x1a,0x6d,0x28,0x98,0x85,0x6b,0x4e,0xf5,0x69,0xea,0x25, + 0x82,0x22,0x9b,0x4b,0xda,0x92,0xd1,0x94,0xa5,0x60,0xd4,0x6b,0xf5,0xd7,0x2b,0x5f, + 0x85,0x09,0x2a,0xff,0xe9,0xef,0x70,0xaf,0x6b,0x38,0x15,0xf3,0xaa,0x51,0xf2,0x42, + 0x45,0x03,0x97,0x2e,0x97,0xee,0x42,0x45,0x01,0x97,0xd0,0x29,0xd8,0xaf,0x8e,0x29, + 0xe9,0xc5,0x39,0xce,0xf3,0x35,0xa0,0x5a,0xae,0x60,0x89,0x15,0x28,0x40,0x97,0x66, + 0x49,0x91,0x10,0x02,0x2c,0x5f,0x59,0xe7,0x5f,0x68,0x68,0x56,0x8c,0xd5,0x7e,0x85, + 0x21,0x62,0xbb,0x95,0x11,0x56,0xcc,0xc0,0xb1,0xbe,0xe0,0xaa,0x44,0xc2,0xab,0x2d, + 0x03,0xcc,0x96,0x37,0x95,0xeb,0x66,0x59,0xa8,0xf5,0xfe,0x5a,0x36,0xe8,0xb7,0x0b, + 0x74,0xa4,0xba,0x5e,0x9a,0xcb,0x72,0x2a,0xc2,0x9a,0xa6,0x02,0x9f,0xfc,0x75,0xd9, + 0x2f,0x79,0xdd,0xd6,0xce,0x7a,0x3d,0x67,0x97,0xc4,0x57,0xfc,0x95,0xf1,0x8c,0xf7, + 0x03,0xde,0xd7,0x15,0x50,0xad,0xfd,0xd1,0xf6,0xe2,0x53,0x0e,0x0b,0x5e,0x7d,0x8e, + 0x89,0x2c,0x4e,0x8a,0x22,0x0b,0x87,0xc8,0xb9,0x4d,0x43,0x1d,0x1a,0xc2,0xc0,0x9e, + 0x38,0x8a,0x8e,0x2a,0x7f,0x58,0xdb,0x82,0x7c,0x51,0xe9,0x19,0xee,0xf7,0xc6,0x78, + 0xa2,0x48,0xab,0x8d,0x48,0xd7,0x79,0xde,0xbd,0x47,0xed,0x70,0x42,0x93,0x6d,0x97, + 0xbd,0x74,0x97,0x6c,0xb4,0x4f,0xc4,0x6e,0xdd,0x5c,0x5f,0x18,0x0e,0xa6,0xe9,0xc0, + 0x3b,0xd6,0x5b,0xf5,0xc1,0x03,0xee,0xa9,0x6f,0xc0,0xb4,0x96,0x91,0x94,0x53,0xd5, + 0xd1,0xc8,0x0e,0x93,0xa9,0x76,0xea,0x64,0x32,0xba,0x3f,0x3f,0x4f,0xc6,0xdd,0xd5, + 0x73,0x54,0x5e,0xfd,0x37,0x40,0x24,0xac,0x04,0x01,0x3e,0x2e,0xce,0xe4,0xd8,0xc7, + 0x0e,0x1b,0x19,0x4e,0xfd,0x38,0xc5,0x16,0xec,0xb1,0x21,0xd1,0x99,0x4a,0x80,0x55, + 0x41,0x82,0x39,0xdf,0x8c,0x7f,0x74,0xd7,0x30,0xad,0xa9,0x35,0x7d,0x61,0xa8,0x62, + 0x1c,0x3d,0x6b,0x11,0x6a,0x14,0x88,0x75,0xf1,0xa3,0x52,0x32,0x28,0xd7,0xb3,0xc2, + 0x3c,0x3b,0x62,0x8b,0xb6,0xc2,0xfd,0x7d,0xfa,0x24,0x10,0x64,0xae,0x4d,0x6e,0x95, + 0xcd,0xd6,0xae,0xa9,0xf4,0x04,0xd5,0xa3,0x59,0xbb,0xa7,0x58,0x27,0x68,0x40,0xda, + 0x9a,0x41,0x26,0x46,0x4f,0xba,0xe9,0x67,0x44,0xea,0x0f,0xbb,0x31,0x6d,0x2e,0x81, + 0xd4,0x8a,0x8c,0x46,0x4e,0xbf,0xa1,0x23,0x5d,0x2a,0x98,0x87,0x7c,0x44,0x90,0xaa, + 0xe2,0x9e,0x2a,0x27,0x52,0xc7,0xc2,0x1f,0x12,0xb2,0x55,0x0f,0x0b,0x5a,0x25,0x16, + 0x25,0x4a,0xe5,0xe3,0x55,0xf4,0x54,0xc9,0x34,0x4c,0x2d,0x2e,0x56,0xea,0xd3,0xa7, + 0x89,0xb4,0x1e,0xf2,0x2c,0xbd,0xbd,0x87,0x50,0xcc,0xbf,0x29,0x52,0x2f,0xac,0x39, + 0x80,0x7e,0xd6,0x66,0x85,0xc7,0x2c,0xf7,0x94,0x64,0xbb,0x98,0xfd,0xc7,0x8b,0x97, + 0xaa,0xae,0x2d,0x3f,0x87,0xf4,0x9c,0x09,0xd7,0x55,0x42,0xaa,0x87,0xf3,0x6f,0x8e, + 0x60,0xe2,0x04,0x68,0x16,0xbd,0xd4,0xae,0x44,0x17,0x5e,0x7f,0xf8,0xf8,0x1d,0x9d, + 0x0f,0x8e,0xae,0x2c,0x7d,0x24,0x48,0xbf,0xd5,0x41,0xe4,0x98,0xd9,0x3c,0x51,0x25, + 0x49,0x9f,0x4a,0x3a,0xe1,0x8f,0xf9,0x50,0xf0,0x93,0xa5,0xb6,0x78,0x23,0x65,0xaa, + 0x3b,0x03,0xbb,0xcb,0x7c,0xaa,0xa6,0xa3,0x52,0x14,0x64,0x47,0x65,0x70,0xc6,0xcc, + 0x92,0x89,0x4e,0x12,0x5e,0x96,0xfe,0x59,0x96,0xfa,0xcf,0xa4,0x68,0xf0,0x30,0xf7, + 0x72,0x5b,0x4f,0x50,0xa1,0x68,0xe0,0x04,0x5b,0xc9,0xd8,0x7b,0x34,0x2f,0x77,0x9b, + 0x73,0x5d,0x5a,0xa9,0xae,0xab,0x0a,0xb3,0xb9,0xa8,0x55,0x9b,0x39,0xda,0x62,0xc6, + 0xda,0x4f,0xc4,0x34,0x21,0xc2,0x8e,0xb9,0xac,0xca,0x14,0x73,0x7b,0x96,0x20,0x03, + 0x93,0xbb,0x6e,0xee,0xa0,0xe7,0x2b,0xd4,0x0b,0xf1,0x95,0x6d,0x45,0xd9,0x46,0xbf, + 0xbd,0x42,0xb9,0x7d,0xf5,0x78,0x9c,0x2e,0x85,0xf1,0xb2,0xcb,0x2a,0x06,0x0d,0x38, + 0xcb,0x92,0x34,0x05,0x6b,0x5c,0xcd,0x60,0x3a,0xb5,0x56,0x50,0x0a,0xd4,0xb7,0xd5, + 0x1e,0x64,0xb8,0xb5,0x97,0x2b,0x36,0xee,0xad,0x00,0x72,0x6d,0x01,0x6a,0xa3,0xae, + 0x82,0x58,0xed,0x17,0x54,0xda,0xea,0x19,0xde,0xb6,0xfa,0x81,0xa8,0xff,0x03,0xa5, + 0xa7,0xe7,0x67,0x31,0x4a,0x00,0x00, }; constexpr size_t MIDI_INDEX_HTML_GZ_LEN = sizeof(MIDI_INDEX_HTML_GZ); // diff --git a/firmware/patternflow/net_config.h b/firmware/patternflow/net_config.h index b5134678..2ebb142d 100644 --- a/firmware/patternflow/net_config.h +++ b/firmware/patternflow/net_config.h @@ -32,6 +32,8 @@ #include "patternflow_secrets.h" #endif +#include "src/core_shared.h" + // ── Wi-Fi ──────────────────────────────────────────────────────────── // Whether the panel has a radio at all. 0 makes a USB-only instrument: // everything network-side (console, OTA, every feature that dials out) @@ -190,7 +192,8 @@ // it into virtual knob deltas (applyLaneMotion), so EVERY encoder-driven // pattern reacts with no per-pattern code. This is how many knob clicks a // full 0..1 swing maps to. Higher = stronger response. No per-frame clamp, -// so the value tracks without lag. +// so the value tracks without lag. The default is a parameter's whole range, +// two turns of 24 detents (src/core_shared.h). // // The old name is honoured below because a firmware built elsewhere may // already set it in its overrides.h, and a rename that silently ignores @@ -199,6 +202,6 @@ #ifdef PF_AUDIO_VIRTUAL_KNOB_SCALE #define PF_LANE_MOTION_SCALE PF_AUDIO_VIRTUAL_KNOB_SCALE #else -#define PF_LANE_MOTION_SCALE 48.0f +#define PF_LANE_MOTION_SCALE ((float)(PF_SHARED_DETENTS_PER_TURN * PF_SHARED_TURNS_PER_FULL_RANGE)) #endif #endif diff --git a/firmware/patternflow/patternflow.ino b/firmware/patternflow/patternflow.ino index cb409430..f451f8eb 100644 --- a/firmware/patternflow/patternflow.ino +++ b/firmware/patternflow/patternflow.ino @@ -270,7 +270,7 @@ bool updateDirty = false; // loop() draws it. -1 when nothing is being received. volatile int uploadPctShown = -1; -const uint32_t MODE_HOLD_MS = 1000; +const uint32_t MODE_HOLD_MS = PF_SHARED_LONG_PRESS_MS; const uint32_t BRIGHTNESS_IDLE_MS = 5000; // How long the value has to be still before it is written to NVS - the // console's slider sends a step for every pixel of a drag. diff --git a/firmware/patternflow/src/core_bus.h b/firmware/patternflow/src/core_bus.h index 29dfedcf..d423228e 100644 --- a/firmware/patternflow/src/core_bus.h +++ b/firmware/patternflow/src/core_bus.h @@ -23,13 +23,16 @@ #include #include "core_encoders.h" // InputFrame +#include "core_shared.h" // The wire scale of an absolute channel: 0..PF_BUS_MAX, integer. Everything // that writes the bus — MQTT param/1..4, POST /api/params, a .pfs cue, a MIDI // CC — speaks this scale, and the site's Director bakes to it // (BUS_WIRE_MAX in web/src/lib/pattern/controls.ts). abi/pf_params.h clamps // to the same literal and cannot be changed to this name: the ABI is frozen. -#define PF_BUS_MAX 1000 +// The number is BUS_MAX in shared/constants.json, and check:shared holds the +// ABI's literal to it. +#define PF_BUS_MAX PF_SHARED_BUS_MAX namespace PatternflowBus { @@ -87,9 +90,9 @@ inline void applyRemoteParam(int index, long value) { if (diff != 0) { const int32_t acc = __atomic_add_fetch(&clickResidual[index], (int32_t)diff, __ATOMIC_RELAXED); - const int32_t d = acc / 10; // truncates toward zero, both signs + const int32_t d = acc / PF_SHARED_BUS_UNITS_PER_CLICK; // truncates toward zero, both signs if (d != 0) { - __atomic_fetch_sub(&clickResidual[index], d * 10, __ATOMIC_RELAXED); + __atomic_fetch_sub(&clickResidual[index], d * PF_SHARED_BUS_UNITS_PER_CLICK, __ATOMIC_RELAXED); __atomic_fetch_add(&pendingDelta[index], d, __ATOMIC_RELAXED); } } diff --git a/firmware/patternflow/src/core_canvas.h b/firmware/patternflow/src/core_canvas.h index 0a1dfbb0..3ecf687d 100644 --- a/firmware/patternflow/src/core_canvas.h +++ b/firmware/patternflow/src/core_canvas.h @@ -25,9 +25,9 @@ #include #include "core_loop_sync.h" // onLoopTask(): present() belongs to the frame -// Vendored HUB75 driver (src/hub75/), not the Library Manager copy: it -// carries one addition, blitRGB888(), that upstream has no equivalent for. -// See src/hub75/VENDORED.md. +// Vendored HUB75 driver (src/hub75/), not the Library Manager copy. Every +// change from upstream is in src/hub75/patternflow.patch, and +// src/hub75/VENDORED.md says why. #include "hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h" #include "config.h" diff --git a/firmware/patternflow/src/core_display.h b/firmware/patternflow/src/core_display.h index d471cb7d..53e8d004 100644 --- a/firmware/patternflow/src/core_display.h +++ b/firmware/patternflow/src/core_display.h @@ -1,7 +1,7 @@ #pragma once -// Vendored HUB75 driver (src/hub75/), not the Library Manager copy: it -// carries one addition, blitRGB888(), that upstream has no equivalent for. -// See src/hub75/VENDORED.md. +// Vendored HUB75 driver (src/hub75/), not the Library Manager copy. Every +// change from upstream is in src/hub75/patternflow.patch, and +// src/hub75/VENDORED.md says why. #include "hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h" #include "config.h" diff --git a/firmware/patternflow/src/core_encoders.h b/firmware/patternflow/src/core_encoders.h index 84cd93e8..b0d61299 100644 --- a/firmware/patternflow/src/core_encoders.h +++ b/firmware/patternflow/src/core_encoders.h @@ -2,6 +2,7 @@ #include #include #include "config.h" +#include "core_shared.h" volatile long encPos[4] = {0, 0, 0, 0}; // sub-steps; 4 per detent volatile uint8_t encState[4] = {0, 0, 0, 0}; // last ACCEPTED (A<<1)|B @@ -73,7 +74,9 @@ void IRAM_ATTR isr4() { handleEncoder(3, ENC4_A, ENC4_B); } // (namespace "pf_knobs"), set from the /knobs page (core_knobs_http.h). inline bool knobInvert[4] = {INVERT_ENCODER != 0, INVERT_ENCODER != 0, INVERT_ENCODER != 0, INVERT_ENCODER != 0}; -inline int knobSubSteps[4] = {4, 4, 4, 4}; // edges per click: 4, 2 or 1 +// Edges per click: 4, 2 or 1. +inline int knobSubSteps[4] = {PF_SHARED_EDGES_PER_DETENT, PF_SHARED_EDGES_PER_DETENT, + PF_SHARED_EDGES_PER_DETENT, PF_SHARED_EDGES_PER_DETENT}; // A settings change must not move a knob. The click count carries on from // where it was, counting the edges since the change in the new direction // and at the new size, so the frame's delta stays zero across a save. @@ -86,7 +89,9 @@ inline long getClicks(int idx) { return knobClickBase[idx] + rel / knobSubSteps[idx]; } -inline int clampSubSteps(int v) { return (v == 1 || v == 2 || v == 4) ? v : 4; } +inline int clampSubSteps(int v) { + return (v == 1 || v == 2 || v == 4) ? v : PF_SHARED_EDGES_PER_DETENT; +} // Call on the frame task (the frame reads the same counters). inline void setKnobSettings(int idx, bool invert, int subSteps) { @@ -140,7 +145,7 @@ struct Button { bool pressed() { bool cur = digitalRead(pin); uint32_t now = millis(); - if (cur != lastState && (now - lastChangeMs) > 50) { + if (cur != lastState && (now - lastChangeMs) > PF_SHARED_BUTTON_DEBOUNCE_MS) { lastState = cur; lastChangeMs = now; if (cur == LOW) { @@ -166,7 +171,7 @@ struct Button { return c; } - bool longPressed(uint32_t threshold = 1000) { + bool longPressed(uint32_t threshold = PF_SHARED_LONG_PRESS_MS) { if (!isDown()) return false; uint32_t now = millis(); if (!longPressFired && (now - pressStartMs) > threshold) { diff --git a/firmware/patternflow/src/core_shared.h b/firmware/patternflow/src/core_shared.h new file mode 100644 index 00000000..4a7f65ae --- /dev/null +++ b/firmware/patternflow/src/core_shared.h @@ -0,0 +1,45 @@ +// GENERATED by web/scripts/gen-shared.ts from shared/constants.json. Do not edit. +// Change the JSON and run `npm run gen:shared` in web/. check:shared fails +// while this file says anything else. +// +// License: MIT +#pragma once + +// The geometries PANEL_GEOMETRY picks from in config.h. Two 64x64 modules in a +// chain make the same 128x64 canvas as one 128x64 module. +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE 1 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_W 128 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_H 64 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_CHAIN 1 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_W 128 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_H 64 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE 2 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_W 64 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_H 64 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_CHAIN 1 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_W 64 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_H 64 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2 3 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_W 64 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_H 64 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_CHAIN 2 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_W 128 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_H 64 +#define PF_SHARED_PANEL_GEOM_DEFAULT PF_SHARED_PANEL_GEOM_128x64_SINGLE + +// The PEC11R-4220F-S0024 has 24 detents a turn. Two turns cross a parameter's +// whole range, so 48 detents do. +#define PF_SHARED_KNOB_COUNT 4 +#define PF_SHARED_DETENTS_PER_TURN 24 +#define PF_SHARED_TURNS_PER_FULL_RANGE 2 + +// Quadrature edges in one detent. A press held past LONG_PRESS_MS is a long +// press, and button edges closer than BUTTON_DEBOUNCE_MS are ignored. +#define PF_SHARED_EDGES_PER_DETENT 4 +#define PF_SHARED_LONG_PRESS_MS 1000 +#define PF_SHARED_BUTTON_DEBOUNCE_MS 50 + +// The absolute bus speaks integers 0 to BUS_MAX. A pattern that reads only knob +// deltas gets one click for every BUS_UNITS_PER_CLICK a held channel moves. +#define PF_SHARED_BUS_MAX 1000 +#define PF_SHARED_BUS_UNITS_PER_CLICK 10 diff --git a/firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h b/firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h index 05a3c56a..354f1876 100644 --- a/firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h +++ b/firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h @@ -347,7 +347,7 @@ struct HUB75_I2S_CFG bool _clockphase = true, uint16_t _min_refresh_rate = 60, uint8_t _pixel_color_depth_bits = PIXEL_COLOR_DEPTH_BITS_DEFAULT) - : mx_width(_w), mx_height(_h), chain_length(_chain), gpio(_pinmap), driver(_drv), double_buff(_dbuff), i2sspeed(_i2sspeed), latch_blanking(_latblk), clkphase(_clockphase), min_refresh_rate(_min_refresh_rate) + : mx_width(_w), mx_height(_h), chain_length(_chain), gpio(_pinmap), driver(_drv), line_decoder(_line_drv), double_buff(_dbuff), i2sspeed(_i2sspeed), latch_blanking(_latblk), clkphase(_clockphase), min_refresh_rate(_min_refresh_rate) { setPixelColorDepthBits(_pixel_color_depth_bits); } diff --git a/firmware/patternflow/src/hub75/UPSTREAM.md b/firmware/patternflow/src/hub75/UPSTREAM.md new file mode 100644 index 00000000..28abd48e --- /dev/null +++ b/firmware/patternflow/src/hub75/UPSTREAM.md @@ -0,0 +1,80 @@ +# Upstream + +This folder is [ESP32-HUB75-MatrixPanel-DMA](https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA) +at tag `3.0.13`, commit `a6221865c71fd5aeba885c31b81fe41bd36c5705` (25 October +2025). It was copied in on 31 July 2026, in `42bfc10`. Upstream's `master` was +already past the tag that day, but the copy is the tag: every upstream file here +that [patternflow.patch](patternflow.patch) does not name is byte for byte the +one at `a622186`, plus the commits under +[Taken from upstream since](#taken-from-upstream-since). + +The patch is every change Patternflow made since. It is `diff -ruN` of +upstream's `src/`, with those commits on it, against this folder, over the +files both carry, and [VENDORED.md](VENDORED.md) says why each change is there. + +## What was not copied + +Only upstream's `src/` is here, and without its three images: + +- `platforms/esp32/RGB_HUB75_PINS.png` +- `platforms/esp32s3/ESP32-S3-DevKitC-1-pin-layout.png` +- `platforms/esp32s3/ReservedPinsForPSRAM.PNG` + +Everything at upstream's root stayed behind: `examples/`, `doc/`, `testing/`, +`LICENSE.txt` and the build manifests. One manifest came along under another +name. `library.properties` is `UPSTREAM_library.properties` here, kept for its +version line. + +## Upstream's TODO + +`setupDMA()` in `ESP32-HUB75-MatrixPanel-I2S-DMA.cpp` has +`// TODO: should we release all previous rowBitStructs here???`. It is +upstream's, and it stays as it is. Editing it would add a hunk to the patch. + +## Rebuilding the patch + +From the repository root, with GNU diff: + +```sh +U=$(mktemp -d) +git clone -q https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA "$U/upstream" +git -C "$U/upstream" checkout -q a6221865c71fd5aeba885c31b81fe41bd36c5705 +git -C "$U/upstream" cherry-pick -n 7bf3b6c1dfb407f02ff6f3394b56d6f813048f5c +ln -s "$U/upstream/src" "$U/a" +ln -s "$PWD/firmware/patternflow/src/hub75" "$U/b" +(cd "$U" && diff -ruN -x VENDORED.md -x UPSTREAM.md -x UPSTREAM_library.properties \ + -x patternflow.patch -x '*.png' -x '*.PNG' a b) | + sed -E -e '/^diff /d' -e 's/^(---|\+\+\+) ([ab]\/[^[:space:]]+).*/\1 \2/' \ + > firmware/patternflow/src/hub75/patternflow.patch +``` + +The `sed` drops the timestamps and the command lines diff prints, so the same +tree always gives the same file. Rebuild the patch instead of editing it. +Upstream ends some lines in spaces, the patch carries them as context, and an +editor that trims trailing whitespace (`.editorconfig` asks for that) breaks +those hunks. + +To check it, apply it to a clean copy of the tag with the taken commits on it, +which is what the rebuild left in `$U/upstream`. The last `diff -r` prints +nothing: + +```sh +cp -R "$U/upstream/src" "$U/check" +patch -p1 --fuzz=0 -d "$U/check" < firmware/patternflow/src/hub75/patternflow.patch +diff -r -x VENDORED.md -x UPSTREAM.md -x UPSTREAM_library.properties \ + -x patternflow.patch -x '*.png' -x '*.PNG' "$U/check" firmware/patternflow/src/hub75 +``` + +## Taken from upstream since + +A commit taken from upstream gets a line here, in the order it was taken, with +its hash and title. Its full hash goes on the end of the `cherry-pick -n` line +in the rebuild above, in the same order, so the patch stays Patternflow's +changes and nothing upstream wrote. + +- `7bf3b6c` "Fix: Properly set line_decoder in HUB75_I2S_CFG struct + constructor" (Christopher King, 3 April 2026). At 3.0.13 the constructor + never sets `line_decoder`, and `initDisplay()` in `core_display.h` builds its + config on the stack, so the field held whatever the stack held. It was taken + ahead of older commits because `c09362a`, the oldest one left, reads the + field to address every row. diff --git a/firmware/patternflow/src/hub75/VENDORED.md b/firmware/patternflow/src/hub75/VENDORED.md index d457c7a3..0ea88367 100644 --- a/firmware/patternflow/src/hub75/VENDORED.md +++ b/firmware/patternflow/src/hub75/VENDORED.md @@ -354,12 +354,13 @@ which is what makes comparing tens of millions of DMA words worth doing. ## What this costs -- Upstream updates are manual. Diff a new release against this tree and re-apply - the additions. -- The additions are marked `PATTERNFLOW ADDITION (not upstream)`, and the two - in-place changes above are marked `PATTERNFLOW FIX` — - `blitRGB888()` in `ESP32-HUB75-MatrixPanel-I2S-DMA.h` and `.cpp`, - `resumeDMAoutput()` in the `.h` only. Nothing else is modified. +- Upstream updates are manual. [UPSTREAM.md](UPSTREAM.md) names the commit this + copy was taken from and the command that rebuilds the patch. +- [patternflow.patch](patternflow.patch) is every change from upstream. A + `PATTERNFLOW ADDITION (not upstream)` or `PATTERNFLOW FIX` marker heads the + larger changes in the source, but not every change has one: the two fields + behind `lastFrameOnTime()`, which `../core_power.h` reads, carry neither. The + patch is the list. - The build server compiles the sketch folder, so it picks this up with no separate configuration. - If the Library Manager copy is still installed it is simply unused: the sketch diff --git a/firmware/patternflow/src/hub75/patternflow.patch b/firmware/patternflow/src/hub75/patternflow.patch new file mode 100644 index 00000000..d4c00813 --- /dev/null +++ b/firmware/patternflow/src/hub75/patternflow.patch @@ -0,0 +1,948 @@ +--- a/ESP32-HUB75-MatrixPanel-I2S-DMA.cpp ++++ b/ESP32-HUB75-MatrixPanel-I2S-DMA.cpp +@@ -1,3 +1,4 @@ ++#include + #include "ESP32-HUB75-MatrixPanel-I2S-DMA.h" + + #if defined(SPIRAM_DMA_BUFFER) +@@ -32,6 +33,68 @@ + */ + #define PIXEL_COLOR_MASK_BIT(color_depth_index, mask_offset) (1 << (color_depth_index + mask_offset)) + ++// PATTERNFLOW: how many EXTRA times one row sends plane `plane`, after the pass ++// that sends every plane once. This replaces upstream's 2^(i - L - 1) rule, and ++// pfOEWindowPixels() further down is its other half - read that comment first. ++// ++// Upstream's rule cannot be made binary without throwing light away. A plane is ++// on the LEDs while the NEXT buffer clocks in, a window belongs to a buffer and ++// not to a pass, and under the 2^k rule the windows that the top plane needs ++// wide are the same ones the planes below it need narrow. Solved exactly, that ++// chain (15 buffers a row on the shipped panel) keeps 42% of the row lit. ++// ++// Searching every grouped order of up to 16 buffers a row for the exactly-binary ++// solution that keeps the most of the row lit found this one, and nothing close: ++// ++// scheme 0 all planes, then D-2 once, then D-1 three times D + 4 buffers ++// depth 8: 0 1 2 3 4 5 6 7 6 7 7 7 -> 66% of the row lit ++// scheme 1 all planes, then D-1 once D + 1 buffers ++// scheme 2 all planes D buffers ++// ++// Fewer buffers is also a faster refresh: 12 against 15 is 325 Hz against 260 on ++// the shipped panel. Schemes 1 and 2 are the fallbacks for a row too long to ++// reach min_refresh_rate with scheme 0; they are dimmer, and still binary. ++// PATTERNFLOW: pad every frame out to an exact refresh rate, for cameras. ++// ++// A row is lit once per refresh, for 1/32 of it - a short pulse, not a glow. A ++// phone's rolling shutter therefore collects a whole number of pulses per sensor ++// line, and unless the exposure is an exact multiple of the refresh period some ++// lines get N and some N + 1: bands. Exposure times are not arbitrary - mains ++// flicker avoidance pins them to 1/50, 1/100, 1/25 or 1/60, 1/30 - and the ++// lowest rate all of those are whole multiples of is 300 Hz. The 12-buffer chain ++// runs at 325.5, which is close to the worst case for 1/50 (6.51 periods) and ++// 1/60 (5.43). So: a run of blank words, OE off, at the end of every frame, ++// bringing it to 16 MHz / 300 = 53,333 clocks. About 8% of the light, no CPU at ++// all - the DMA engine walks the chain by itself - and 1/120 and faster still ++// band at any rate this clock can reach (it would take 600 Hz). ++// 0 turns it off. Never pads below min_refresh_rate. ++#ifndef PF_TARGET_REFRESH_HZ ++#define PF_TARGET_REFRESH_HZ 300 ++#endif ++static constexpr int PF_PAD_CHUNK_WORDS = 256; // one small blank buffer, linked as often as needed ++static ESP32_I2S_DMA_STORAGE_TYPE *pfPadBuffer = nullptr; ++ ++// How many blank clocks one frame needs. Pure, so check_oe.py lifts it out and ++// tests it on a host. 0 means "leave the frame alone": padding is off, the target ++// is not above the refresh floor, the frame is already longer than the target ++// (a slower clock - there is nothing to pad toward), or the gap is more than a ++// quarter of the frame, which would be a different chain's job and not padding's. ++long pfFramePadWords(long clockHz, long frameWords, long targetHz, long minRefreshHz) ++{ ++ if (targetHz <= 0 || targetHz <= minRefreshHz || clockHz <= 0 || frameWords <= 0) return 0; ++ const long targetWords = (clockHz + targetHz / 2) / targetHz; ++ if (targetWords <= frameWords) return 0; ++ if (targetWords - frameWords >= frameWords / 4) return 0; ++ return targetWords - frameWords; ++} ++ ++static inline int pfChainExtraPasses(int plane, int depth, int scheme) ++{ ++ if (scheme == 0 && depth >= 3) return (plane == depth - 1) ? 3 : (plane == depth - 2) ? 1 : 0; ++ if (scheme <= 1 && depth >= 2) return (plane == depth - 1) ? 1 : 0; ++ return 0; ++} ++ + bool MatrixPanel_I2S_DMA::setupDMA(const HUB75_I2S_CFG &_cfg) + { + +@@ -84,39 +147,56 @@ + + ESP_LOGI("I2S-DMA", "Minimum visual refresh rate (scan rate from panel top to bottom) requested: %d Hz", m_cfg.min_refresh_rate); + +- while (1) ++ // PATTERNFLOW: pick the brightest chain scheme that still clears the refresh ++ // floor (see pfChainExtraPasses). lsbMsbTransitionBit is no longer used. ++ for (pfChainScheme = 0; ; pfChainScheme++) + { + int psPerClock = 1000000000000UL / m_cfg.i2sspeed; + int nsPerLatch = ((PIXELS_PER_ROW + CLKS_DURING_LATCH) * psPerClock) / 1000; // time per row + +- // add time to shift out LSBs + LSB-MSB transition bit - this ignores fractions... +- int nsPerRow = m_cfg.getPixelColorDepthBits() * nsPerLatch; ++ int buffersPerRow = m_cfg.getPixelColorDepthBits(); ++ for (int i = 0; i < m_cfg.getPixelColorDepthBits(); i++) ++ buffersPerRow += pfChainExtraPasses(i, m_cfg.getPixelColorDepthBits(), pfChainScheme); + +- // Now add the time for the remaining bit depths +- for (int i = lsbMsbTransitionBit + 1; i < m_cfg.getPixelColorDepthBits(); i++) { +- //nsPerRow += (1 << (i - lsbMsbTransitionBit - 1)) * (m_cfg.getPixelColorDepthBits() - i) * nsPerLatch; +- nsPerRow += (1 << (i - lsbMsbTransitionBit - 1)) * nsPerLatch; +- } +- +- int nsPerFrame = nsPerRow * ROWS_PER_FRAME; ++ int nsPerFrame = buffersPerRow * nsPerLatch * ROWS_PER_FRAME; + int actualRefreshRate = 1000000000UL / (nsPerFrame); + calculated_refresh_rate = actualRefreshRate; + +- ESP_LOGW("I2S-DMA", "lsbMsbTransitionBit of %d gives %d Hz refresh rate.", lsbMsbTransitionBit, actualRefreshRate); +- +- if (actualRefreshRate > m_cfg.min_refresh_rate) +- break; ++ ESP_LOGW("I2S-DMA", "chain scheme %d: %d buffers per row, %d Hz refresh rate.", pfChainScheme, buffersPerRow, actualRefreshRate); + +- if (lsbMsbTransitionBit < m_cfg.getPixelColorDepthBits() - 1) +- lsbMsbTransitionBit++; +- else ++ if (actualRefreshRate > m_cfg.min_refresh_rate || pfChainScheme >= 2) + break; + } + +- if (lsbMsbTransitionBit > 0) ++ // PATTERNFLOW: frame padding to PF_TARGET_REFRESH_HZ (see above). ++ int pfPadWords = 0; ++ if (m_cfg.line_decoder != HUB75_I2S_CFG::SM5266P && m_cfg.line_decoder != HUB75_I2S_CFG::SM5368) + { +- ESP_LOGW("I2S-DMA", "lsbMsbTransitionBit of %d used to achieve refresh rate of %d Hz. Percieved colour depth to the eye may be reduced.", lsbMsbTransitionBit, m_cfg.min_refresh_rate); ++ int buffersPerRow = m_cfg.getPixelColorDepthBits(); ++ for (int i = 0; i < m_cfg.getPixelColorDepthBits(); i++) ++ buffersPerRow += pfChainExtraPasses(i, m_cfg.getPixelColorDepthBits(), pfChainScheme); ++ const long frameWords = (long)buffersPerRow * (PIXELS_PER_ROW + CLKS_DURING_LATCH) * ROWS_PER_FRAME; ++ const long wanted = pfFramePadWords((long)m_cfg.i2sspeed, frameWords, PF_TARGET_REFRESH_HZ, m_cfg.min_refresh_rate); ++ if (wanted > 0) ++ { ++ if (!pfPadBuffer) ++ pfPadBuffer = (ESP32_I2S_DMA_STORAGE_TYPE *)heap_caps_malloc( ++ PF_PAD_CHUNK_WORDS * sizeof(ESP32_I2S_DMA_STORAGE_TYPE), MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA); ++ if (pfPadBuffer) ++ { ++ // Output off, no latch, and the address left on the last row - whose top ++ // plane is still waiting for the next frame's buffer 0 to light it. ++ const ESP32_I2S_DMA_STORAGE_TYPE blank = ++ (ESP32_I2S_DMA_STORAGE_TYPE)(BIT_OE | ((ROWS_PER_FRAME - 1) << BITS_ADDR_OFFSET)); ++ for (int i = 0; i < PF_PAD_CHUNK_WORDS; i++) pfPadBuffer[i] = blank; ++ pfPadWords = (int)wanted; ++ const long total = frameWords + wanted; ++ calculated_refresh_rate = (int)(((long)m_cfg.i2sspeed + total / 2) / total); ++ ESP_LOGW("I2S-DMA", "padding each frame with %d blank clocks: %d Hz refresh rate.", pfPadWords, calculated_refresh_rate); ++ } ++ } + } ++ const int pfPadDescriptors = (pfPadWords + PF_PAD_CHUNK_WORDS - 1) / PF_PAD_CHUNK_WORDS; + + ESP_LOGI("I2S-DMA", "DMA frame buffer color depths: %d", m_cfg.getPixelColorDepthBits()); + +@@ -146,14 +226,14 @@ + int dma_descriptors_per_row = dma_descs_per_row_all_cdepths; + + // Add descriptors for MSB bits after transition +- for (int i = lsbMsbTransitionBit + 1; i < m_cfg.getPixelColorDepthBits(); i++) { +- dma_descriptors_per_row += (1 << (i - lsbMsbTransitionBit - 1)) * dma_descs_per_row_1cdepth; ++ for (int i = 0; i < m_cfg.getPixelColorDepthBits(); i++) { ++ dma_descriptors_per_row += pfChainExtraPasses(i, m_cfg.getPixelColorDepthBits(), pfChainScheme) * dma_descs_per_row_1cdepth; + } + + //dma_descriptors_per_row = 1; + + // Allocate DMA descriptors +- int dma_descriptions_required = dma_descriptors_per_row * ROWS_PER_FRAME; ++ int dma_descriptions_required = dma_descriptors_per_row * ROWS_PER_FRAME + pfPadDescriptors; + + ESP_LOGV("I2S-DMA", "DMA descriptors per row: %d", dma_descriptors_per_row); + ESP_LOGV("I2S-DMA", "DMA descriptors required per buffer: %d", dma_descriptions_required); +@@ -202,13 +282,13 @@ + } + + // Step 2: Handle additional descriptors for bits beyond the lsbMsbTransitionBit +- for (int i = lsbMsbTransitionBit + 1; i < m_cfg.getPixelColorDepthBits(); i++) ++ for (int i = 0; i < m_cfg.getPixelColorDepthBits(); i++) + { + // binary time division setup: we need 2 of bit (LSBMSB_TRANSITION_BIT + 1) four of (LSBMSB_TRANSITION_BIT + 2), etc + // because we sweep through to MSB each time, it divides the number of times we have to sweep in half (saving linked list RAM) + // we need 2^(i - LSBMSB_TRANSITION_BIT - 1) == 1 << (i - LSBMSB_TRANSITION_BIT - 1) passes from i to MSB + +- for (int k = 0; k < (1 << (i - lsbMsbTransitionBit - 1)); k++) ++ for (int k = 0; k < pfChainExtraPasses(i, m_cfg.getPixelColorDepthBits(), pfChainScheme); k++) + { + // Link and send all colour data, all passes of everything in one hit. + for (int dma_desc_1cdepth = 0; dma_desc_1cdepth < dma_descs_per_row_1cdepth; dma_desc_1cdepth++) +@@ -230,6 +310,14 @@ + + + } // end all rows ++ ++ // PATTERNFLOW: the blank run that brings the frame to its target length. ++ for (int left = pfPadWords; left > 0; left -= PF_PAD_CHUNK_WORDS) ++ { ++ const int words = (left < PF_PAD_CHUNK_WORDS) ? left : PF_PAD_CHUNK_WORDS; ++ dma_bus.create_dma_desc_link(pfPadBuffer, words * sizeof(ESP32_I2S_DMA_STORAGE_TYPE), (fb==1)); ++ _dmadescriptor_count++; ++ } + + ESP_LOGI("I2S-DMA", "Created %d DMA descriptors for buffer %d.", _dmadescriptor_count, fb); + +@@ -414,6 +502,530 @@ + } // updateMatrixDMABuffer (specific co-ords change) + + ++/* PATTERNFLOW ADDITION (not upstream) — see the header for why this exists. */ ++ ++// The bitplane transpose, done once per channel value instead of once per ++// plane per channel. A plane wants bit (d + MASK_OFFSET) of each of the six ++// CIE values; the loop used to extract it with six masked tests per plane — ++// about thirty-six per pixel pair, and that loop was 60% of every frame ++// (presentUs 9.9 ms of 16.5 on the 128x64 panel at 6 bits). These tables ++// spread the top `depth` bits of a post-LUT byte's CIE value across 6-bit ++// slots, slot d holding bit d, so one OR of six table reads yields every ++// plane's R1 G1 B1 R2 G2 B2 at once and each plane is then a shift and a ++// mask. Planes 0-4 live in the low word (30 bits), 5-7 in the high one. ++// Bit-exact with the loop it replaces: the DMA words are identical. ++static uint32_t pfSpreadLo[256]; ++static uint32_t pfSpreadHi[256]; ++// lumConvTab is const, so it lives in flash and reaches the loop through the ++// cache; the on-time sum reads it six times a pixel pair. A copy in DRAM. ++static uint16_t pfCie[256]; ++static uint8_t pfSpreadDepth = 0; ++ ++// PATTERNFLOW FIX: the ceiling this encoding actually has. ++// ++// One 6-bit field per plane - three colours x two panel halves - packed into two ++// uint32: planes 0-4 in lo at 6*d, planes 5-9 in hi at 6*(d-5). Plane 9 ends at ++// bit 29 and there is no room for a tenth field. Past that it degrades quietly ++// and then illegally: depth 11 needs bits 30-35 of hi and loses the top four, ++// and depth 12 shifts a uint32 by 36, which is undefined behaviour in both the ++// builder above and the reader in blitRGB888. ++// ++// HUB75_I2S_CFG clamps a requested depth to PIXEL_COLOR_DEPTH_BITS_MAX, which is ++// 12, so the configuration permits what this path cannot represent. The upstream ++// updateMatrixDMABuffer path does not use these tables and is unaffected, which ++// is why the fix is a guard here rather than a lower MAX - fillScreen() still ++// goes through it. Nothing in this firmware asks for more than 8. ++static constexpr uint8_t PF_SPREAD_MAX_DEPTH = 10; ++ ++ ++// Both halves of the panel for one column: the post-processed bytes, their ++// on-time, and every plane's bits spread into (lo, hi). ++#define PF_PLANES_FOR(srcT, srcB, lo, hi, onTime) \ ++ { \ ++ uint8_t _t[3], _m[3]; \ ++ PF_POST_PIXEL((srcT), _t); \ ++ PF_POST_PIXEL((srcB), _m); \ ++ (onTime) += (uint32_t)pfCie[_t[0]] + pfCie[_t[1]] + pfCie[_t[2]] + \ ++ (uint32_t)pfCie[_m[0]] + pfCie[_m[1]] + pfCie[_m[2]]; \ ++ (lo) = pfSpreadLo[_t[0]] | (pfSpreadLo[_t[1]] << 1) | (pfSpreadLo[_t[2]] << 2) | \ ++ (pfSpreadLo[_m[0]] << 3) | (pfSpreadLo[_m[1]] << 4) | (pfSpreadLo[_m[2]] << 5); \ ++ (hi) = pfSpreadHi[_t[0]] | (pfSpreadHi[_t[1]] << 1) | (pfSpreadHi[_t[2]] << 2) | \ ++ (pfSpreadHi[_m[0]] << 3) | (pfSpreadHi[_m[1]] << 4) | (pfSpreadHi[_m[2]] << 5); \ ++ } ++ ++// Two adjacent columns share one aligned 32-bit word in every plane. ++#define PF_CLEAR32 (((uint32_t)BITMASK_RGB12_CLEAR << 16) | (uint32_t)BITMASK_RGB12_CLEAR) ++ ++// An empty asm statement that claims to rewrite its operand. The value is ++// untouched; the optimiser just stops knowing where it came from. Each use ++// says what this GCC does with that knowledge. MSVC, which the host test may ++// be built with, has no such statement and needs none. ++#if defined(__GNUC__) ++#define PF_OPAQUE(v) __asm__("" : "+r"(v)) ++#define PF_NOINLINE __attribute__((noinline)) ++#else ++#define PF_OPAQUE(v) ((void)0) ++#define PF_NOINLINE ++#endif ++// PF_NOINLINE keeps a function out of blitRGB888. For the passes further down ++// that is the whole idea: folded back in, they would share one set of registers ++// again. For the rest it is about where the code lives - whatever is inlined ++// into an IRAM function is in IRAM, and that is internal RAM. ++ ++// Not inlined: this runs once per depth change, and inlined into blitRGB888 it ++// sat in IRAM for nothing. ++static PF_NOINLINE void pfBuildSpread(uint8_t depth) ++{ ++ const uint8_t maskOffset = 16 - depth; ++ for (int v = 0; v < 256; v++) ++ { ++#ifdef NO_CIE1931 ++ const uint16_t cie = (uint16_t)v; ++#else ++ const uint16_t cie = lumConvTab[v]; ++#endif ++ pfCie[v] = cie; ++ // PATTERNFLOW FIX: round to nearest, not toward zero. ++ // ++ // Only the top `depth` bits of the 16-bit CIE value reach a plane; the low ++ // byte was simply dropped. Truncation is biased downward by up to half a ++ // plane step everywhere, and at the bottom of the range that is the whole ++ // signal: measured over the shipped white balance, eight input levels emitted ++ // nothing at all, and neighbouring neutral greys came out as opposite colour ++ // casts because each channel crossed its threshold at a different code. On the ++ // panel, level 9 read blue, level 17 cyan and level 18 red - all three from a ++ // neutral input. Adding half a step before the test costs nothing at runtime: ++ // this loop runs once per depth change, not per pixel. ++ // ++ // Computed over all 256 levels x 3 channels: dead levels 8 -> 4, level 9 ++ // becomes (1,1,1), levels 17 and 18 both become (2,2,2), and full-scale white ++ // moves from (212,255,240) to (212,255,241) - so the hand-converged LED_WB_* ++ // constants keep their meaning. tests/blit_test.cpp carries the same rounding ++ // in its independent reference; the two must move together or 30M compared ++ // DMA words disagree. ++#ifdef NO_CIE1931 ++ const uint32_t thresholdSrc = cie; ++#else ++ uint32_t thresholdSrc = (uint32_t)cie + (maskOffset ? (1u << (maskOffset - 1)) : 0u); ++ if (thresholdSrc > 0xFFFFu) thresholdSrc = 0xFFFFu; ++#endif ++ uint32_t lo = 0, hi = 0; ++ for (uint8_t d = 0; d < depth; d++) ++ { ++#ifdef NO_CIE1931 ++ const uint16_t mask = d; ++#else ++ const uint16_t mask = PIXEL_COLOR_MASK_BIT(d, maskOffset); ++#endif ++ const uint32_t bit = (thresholdSrc & mask) ? 1u : 0u; ++ ++ if (d < 5) lo |= bit << (6 * d); ++ else hi |= bit << (6 * (d - 5)); ++ } ++ pfSpreadLo[v] = lo; ++ pfSpreadHi[v] = hi; ++ } ++ pfSpreadDepth = depth; ++} ++ ++// Rec.601 luma pivot for the saturation boost, then the caller's gamma/WB LUT. ++// Kept as a macro so it stays inside the pixel loop with no call overhead. ++#define PF_POST_PIXEL(src, dst) { int _r = (src)[0], _g = (src)[1], _b = (src)[2]; int _y = (_r * 77 + _g * 150 + _b * 29) >> 8; _r = _y + (((_r - _y) * satBoostQ8) >> 8); _g = _y + (((_g - _y) * satBoostQ8) >> 8); _b = _y + (((_b - _y) * satBoostQ8) >> 8); if (_r < 0) _r = 0; else if (_r > 255) _r = 255; if (_g < 0) _g = 0; else if (_g > 255) _g = 255; if (_b < 0) _b = 0; else if (_b > 255) _b = 255; (dst)[0] = lutR[_r]; (dst)[1] = lutG[_g]; (dst)[2] = lutB[_b]; } ++ ++// --------------------------------------------------------------------------- ++// The kernel as three short passes (2026-10). ++// ++// Counted in the linked image, the one long loop this replaces - it is still ++// here, as pfBlitRowPair - ran 635 instructions per column pair in 691 ++// cycles: compute-bound, not waiting on the DMA engine as its own comment ++// said. Three things in the disassembly made up the excess, and none of them ++// was the arithmetic: ++// ++// - depth was a run-time value, so every plane word paid for a multiply by ++// six, two variable shifts and a plane pointer reloaded from a stack ++// array - 25 instructions where 10 do; ++// - this compiler (GCC 8.4 at -Os) synthesises x77, x150 and x29 as ++// shift/add chains, about 20 instructions a pixel where three multiplies ++// do; ++// - one 400-instruction body keeps more values alive than the 14 registers ++// a windowed call leaves it, so every column pair made 48 reloads from the ++// stack and 12 literal loads. ++// ++// Each pass below is short enough to keep its state in registers, and the ++// plane pass has the depth written into it: ++// ++// 1a pfPostRow saturation + gamma/WB LUT RGB888 -> 3 bytes a pixel ++// pfPostRowRaw the same, when the LUTs are the identity ++// 1b pfSpreadRow on-time + plane spread 6 bytes -> (lo, hi) a column ++// 2 pfStoreRow8 plane words 2 x (lo, hi) -> 8 plane words ++// ++// 426 instructions per column pair, counted the same way - 390 when the LUTs ++// are the identity, which is what config.h ships. Same tables, same DMA ++// words, same on-time sum: check_blit.py holds this to its scalar reference ++// exactly as it held the loop. What the split costs is a store and a reload ++// of every intermediate, through scratch on the caller's stack. ++// ++// THE NEXT SPEED-UP HAS A FLOOR THAT IS NOT THE CPU. flipDMABuffer() returns ++// before the swap happens: the retired buffer stays on the panel until the DMA ++// finishes its pass, up to 3.3 ms, and the next blit writes into that very ++// buffer. Nothing shows, for one reason - the scan takes 96 us a row pair ++// (12 buffers of 128 words at 16 MHz), it is already ahead when the blit ++// starts, and a writer slower than the reader never catches it. At 390 ++// instructions a column pair this blit cannot go under 104 us a row pair. A ++// kernel that does get under 96 - 3.07 ms a frame - or one split across the ++// two cores, overtakes the scan and tears; it has to wait for the swap first. ++ ++// GCC's count-register pass rewrites `while (p != end)` as a down-counter, ++// meant for Xtensa's zero-overhead loop instruction. In passes 1a and 1b it ++// then finds no register left for the counter and keeps it on the stack - ++// load, decrement, store, branch, every time round. With the pass off the test ++// is a compare against a pointer. ++#if defined(__XTENSA__) ++#define PF_NO_COUNT_REG __attribute__((optimize("no-branch-count-reg"))) ++#else ++#define PF_NO_COUNT_REG ++#endif ++ ++// Columns per trip through the passes. The scratch between them is 192 + 256 ++// bytes at this size, and it lives on the stack of whoever calls blitRGB888 - ++// not in static storage, so a blit split by row pair across the two cores ++// would have nothing shared to trip over. ++#define PF_BLIT_CHUNK 32 ++ ++// What pass 1b hands pass 2 for one column: every plane's R1 G1 B1 R2 G2 B2. ++struct PfPlanes { uint32_t lo, hi; }; ++ ++// One pixel of pass 1a as far as the clamp, written once for its two forms. ++// ++// (c*q + y*(256-q)) >> 8 is PF_POST_PIXEL's y + (((c-y)*q) >> 8) with the y*256 ++// carried inside the shift - exact for any q, because the shift is arithmetic. ++// It costs one multiply shared by the three channels where the other form ++// costs a subtract in each. ++#define PF_SATURATE(src) \ ++ const int r = (src)[0], g = (src)[1], b = (src)[2]; \ ++ const int k = ((r * wr + g * wg + b * wb) >> 8) * kq; \ ++ int R = (r * q + k) >> 8, G = (g * q + k) >> 8, B = (b * q + k) >> 8; \ ++ if (R < 0) R = 0; else if (R > 255) R = 255; \ ++ if (G < 0) G = 0; else if (G > 255) G = 255; \ ++ if (B < 0) B = 0; else if (B > 255) B = 255; ++// What it reads besides the pixel. The weights are hidden, or the three ++// multiplies come back as the shift/add chains. ++#define PF_SATURATE_CONSTANTS(q) \ ++ const int kq = 256 - (q); \ ++ int wr = 77, wg = 150, wb = 29; \ ++ PF_OPAQUE(wr); PF_OPAQUE(wg); PF_OPAQUE(wb); ++ ++// Pass 1a. `n` pixels of one canvas row -> the three bytes the tables are ++// indexed by, written six apart: the other row of the pair fills the gaps, and ++// pass 1b walks one pointer instead of two. ++static PF_NOINLINE PF_NO_COUNT_REG void IRAM_ATTR ++pfPostRow(const uint8_t *src, unsigned n, uint8_t *out, ++ const uint8_t *lutR, const uint8_t *lutG, const uint8_t *lutB, int q) ++{ ++ PF_SATURATE_CONSTANTS(q) ++ const uint8_t *const end = src + (size_t)n * 3; ++ do ++ { ++ PF_SATURATE(src) ++ out[0] = lutR[R]; out[1] = lutG[G]; out[2] = lutB[B]; ++ src += 3; out += 6; ++ } while (src != end); ++} ++ ++// Pass 1a when all three LUTs are the identity - gamma 1.0 and white balance ++// 1/1/1, which is what config.h ships. The lookups would hand each index back ++// unchanged, at two instructions apiece and three table pointers this loop has ++// no registers for: 41 instructions a pixel with them, 32 without. ++static PF_NOINLINE PF_NO_COUNT_REG void IRAM_ATTR ++pfPostRowRaw(const uint8_t *src, unsigned n, uint8_t *out, int q) ++{ ++ PF_SATURATE_CONSTANTS(q) ++ const uint8_t *const end = src + (size_t)n * 3; ++ do ++ { ++ PF_SATURATE(src) ++ out[0] = (uint8_t)R; out[1] = (uint8_t)G; out[2] = (uint8_t)B; ++ src += 3; out += 6; ++ } while (src != end); ++} ++ ++static bool IRAM_ATTR pfLutIsIdentity(const uint8_t *lut) ++{ ++ for (unsigned v = 0; v < 256; v++) ++ if (lut[v] != v) return false; ++ return true; ++} ++ ++// Pass 1b. Six index bytes per column - the top row's three, then the bottom ++// row's - become that column's planes, and their on-time is returned. ++static PF_NOINLINE PF_NO_COUNT_REG uint32_t IRAM_ATTR ++pfSpreadRow(const uint8_t *idx, unsigned n, PfPlanes *out) ++{ ++ const uint32_t *const spreadLo = pfSpreadLo, *const spreadHi = pfSpreadHi; ++ const uint16_t *const cie = pfCie; ++ uint32_t on = 0; ++ const PfPlanes *const end = out + n; ++ do ++ { ++ const unsigned t0 = idx[0], t1 = idx[1], t2 = idx[2], m0 = idx[3], m1 = idx[4], m2 = idx[5]; ++ on += (uint32_t)cie[t0] + cie[t1] + cie[t2] + cie[m0] + cie[m1] + cie[m2]; ++ out->lo = spreadLo[t0] | (spreadLo[t1] << 1) | (spreadLo[t2] << 2) | ++ (spreadLo[m0] << 3) | (spreadLo[m1] << 4) | (spreadLo[m2] << 5); ++ out->hi = spreadHi[t0] | (spreadHi[t1] << 1) | (spreadHi[t2] << 2) | ++ (spreadHi[m0] << 3) | (spreadHi[m1] << 4) | (spreadHi[m2] << 5); ++ idx += 6; out++; ++ } while (out != end); ++ return on; ++} ++ ++static inline uint32_t pfSwapHalves(uint32_t v) { return (v << 16) | (v >> 16); } ++ ++#if defined(SPIRAM_DMA_BUFFER) ++#define PF_PLANE_WRITEBACK(p) Cache_WriteBack_Addr((uint32_t)(p), sizeof(uint32_t)) ++#else ++#define PF_PLANE_WRITEBACK(p) ((void)0) ++#endif ++ ++// One plane's word for two adjacent columns: the ten control bits of each stay, ++// the six colour bits of each are replaced. `fieldA` is already in bits 0-5 and ++// `fieldB` in bits 16-21; which column the DMA sends first is the FIFO order's ++// business, as in the loop. The word access is spelled the way the loop spells ++// it, for the reason given there. ++#define PF_STORE_PLANE(fieldA, fieldB) \ ++ { \ ++ void *aligned = __builtin_assume_aligned(p, sizeof(uint32_t)); \ ++ uint32_t word; \ ++ memcpy(&word, aligned, sizeof(word)); \ ++ word = (word & PF_CLEAR32) | \ ++ (aLow ? ((fieldA) | (fieldB)) : pfSwapHalves((fieldA) | (fieldB))); \ ++ memcpy(aligned, &word, sizeof(word)); \ ++ PF_PLANE_WRITEBACK(p); \ ++ p = (ESP32_I2S_DMA_STORAGE_TYPE *)((uint8_t *)p + stride); \ ++ } ++ ++// Pass 2, for a depth of exactly 8. `pairs` column pairs of (lo, hi) go into ++// all eight planes of the row pair; `col` is plane 0 at the first column and ++// `stride` the bytes from one plane to the next. With the depth known every ++// field's position is a constant - an extract and a shift - where the loop ++// computes 6 * d and shifts by a register. ++static PF_NOINLINE void IRAM_ATTR ++pfStoreRow8(const PfPlanes *in, unsigned pairs, ESP32_I2S_DMA_STORAGE_TYPE *col, size_t stride) ++{ ++ const bool aLow = (ESP32_TX_FIFO_POSITION_ADJUST(0) == 0); ++ const uint32_t fieldBMask = 0x003F0000u; ++ const PfPlanes *const end = in + (size_t)pairs * 2; ++ do ++ { ++ const uint32_t loA = in[0].lo, hiA = in[0].hi, loB = in[1].lo, hiB = in[1].hi; ++ // Hidden: shown that p starts at col, GCC walks a pointer of its own for ++ // half the planes and keeps stride multiples on the stack for the rest, ++ // three instructions a column pair more than stepping p by the stride. ++ ESP32_I2S_DMA_STORAGE_TYPE *p = col; ++ PF_OPAQUE(p); ++ PF_STORE_PLANE(loA & 0x3Fu, (loB << 16) & fieldBMask); ++ PF_STORE_PLANE((loA >> 6) & 0x3Fu, (loB << 10) & fieldBMask); ++ PF_STORE_PLANE((loA >> 12) & 0x3Fu, (loB << 4) & fieldBMask); ++ PF_STORE_PLANE((loA >> 18) & 0x3Fu, (loB >> 2) & fieldBMask); ++ PF_STORE_PLANE((loA >> 24) & 0x3Fu, (loB >> 8) & fieldBMask); ++ PF_STORE_PLANE(hiA & 0x3Fu, (hiB << 16) & fieldBMask); ++ PF_STORE_PLANE((hiA >> 6) & 0x3Fu, (hiB << 10) & fieldBMask); ++ PF_STORE_PLANE((hiA >> 12) & 0x3Fu, (hiB << 4) & fieldBMask); ++ in += 2; col += 2; ++ } while (in != end); ++} ++ ++// The loop the passes replaced, kept whole for what they do not cover: an odd ++// width, where alternate planes are not word-aligned, and any depth but 8. ++// One row pair per call; returns its on-time. Nothing this firmware ships ++// reaches it, so it is the one part of the blit that is not in IRAM - and not ++// inlined, or it would follow blitRGB888 back there. ++static PF_NOINLINE uint64_t ++pfBlitRowPair(const uint8_t *top, const uint8_t *bot, ++ ESP32_I2S_DMA_STORAGE_TYPE *plane0, uint16_t w, uint8_t depth, ++ const uint8_t *lutR, const uint8_t *lutG, const uint8_t *lutB, int satBoostQ8) ++{ ++ uint64_t onTime = 0; ++ ++ // Row pointers do not change across the pixels of a row — hoist all of ++ // them once instead of recomputing per pixel per plane. A row's planes ++ // are one allocation, `w` words apart (rowBitStruct::getDataPtr). ++ ESP32_I2S_DMA_STORAGE_TYPE *plane[16]; ++ for (uint8_t d = 0; d < depth; d++) ++ plane[d] = plane0 + (size_t)d * w; ++ ++ // Two columns per step. x and x + 1 are adjacent uint16_t words in every ++ // plane, so both halves of the panel for both columns — two pixel pairs — ++ // are one aligned 32-bit read-modify-write per plane instead of two. ++ // (This used to add that every access arbitrates with the DMA engine and ++ // that this is the cost. Counted, it is not: see the passes above.) ++ // Which column takes the low half is the FIFO order's business, decided ++ // once by the macro at compile time. ++ const bool aLow = (ESP32_TX_FIFO_POSITION_ADJUST(0) == 0); ++ uint16_t x = 0; ++ // DMA allocation is word-aligned. With an even width, every plane and ++ // every two-column offset is too. Tell the compiler that fact below: ++ // memcpy on a uint16_t* otherwise becomes four byte loads, a stack ++ // spill, and four byte stores on Xtensa, not the intended word access. ++ // Odd widths use the scalar path since alternate planes are unaligned. ++ const uint16_t pairedWidth = (w & 1) ? 0 : w; ++ for (; x + 1 < pairedWidth; x += 2) ++ { ++ uint32_t loA, hiA, loB, hiB; ++ PF_PLANES_FOR(top + (size_t)x * 3, bot + (size_t)x * 3, loA, hiA, onTime); ++ PF_PLANES_FOR(top + (size_t)(x + 1) * 3, bot + (size_t)(x + 1) * 3, loB, hiB, onTime); ++ ++ uint8_t d = depth; ++ while (d > 5) ++ { ++ --d; ++ const uint32_t bA = (hiA >> (6 * (d - 5))) & 0x3Fu; ++ const uint32_t bB = (hiB >> (6 * (d - 5))) & 0x3Fu; ++ const uint32_t both = aLow ? (bA | (bB << 16)) : (bB | (bA << 16)); ++ ESP32_I2S_DMA_STORAGE_TYPE *p = plane[d] + x; ++ void *aligned = __builtin_assume_aligned(p, sizeof(uint32_t)); ++ uint32_t word; ++ memcpy(&word, aligned, sizeof(word)); ++ word = (word & PF_CLEAR32) | both; ++ memcpy(aligned, &word, sizeof(word)); ++#if defined(SPIRAM_DMA_BUFFER) ++ Cache_WriteBack_Addr((uint32_t)p, sizeof(word)); ++#endif ++ } ++ while (d) ++ { ++ --d; ++ const uint32_t bA = (loA >> (6 * d)) & 0x3Fu; ++ const uint32_t bB = (loB >> (6 * d)) & 0x3Fu; ++ const uint32_t both = aLow ? (bA | (bB << 16)) : (bB | (bA << 16)); ++ ESP32_I2S_DMA_STORAGE_TYPE *p = plane[d] + x; ++ void *aligned = __builtin_assume_aligned(p, sizeof(uint32_t)); ++ uint32_t word; ++ memcpy(&word, aligned, sizeof(word)); ++ word = (word & PF_CLEAR32) | both; ++ memcpy(aligned, &word, sizeof(word)); ++#if defined(SPIRAM_DMA_BUFFER) ++ Cache_WriteBack_Addr((uint32_t)p, sizeof(word)); ++#endif ++ } ++ } ++ ++ // Scalar fallback for odd widths, one column at a time. ++ for (; x < w; ++x) ++ { ++ uint32_t lo, hi; ++ PF_PLANES_FOR(top + (size_t)x * 3, bot + (size_t)x * 3, lo, hi, onTime); ++ const uint16_t idx = ESP32_TX_FIFO_POSITION_ADJUST(x); ++ uint8_t d = depth; ++ while (d > 5) ++ { ++ --d; ++ const uint16_t bits = (uint16_t)((hi >> (6 * (d - 5))) & 0x3Fu); ++ ESP32_I2S_DMA_STORAGE_TYPE *p = plane[d] + idx; ++ *p = (*p & BITMASK_RGB12_CLEAR) | bits; ++#if defined(SPIRAM_DMA_BUFFER) ++ Cache_WriteBack_Addr((uint32_t)p, sizeof(ESP32_I2S_DMA_STORAGE_TYPE)); ++#endif ++ } ++ while (d) ++ { ++ --d; ++ const uint16_t bits = (uint16_t)((lo >> (6 * d)) & 0x3Fu); ++ ESP32_I2S_DMA_STORAGE_TYPE *p = plane[d] + idx; ++ *p = (*p & BITMASK_RGB12_CLEAR) | bits; ++#if defined(SPIRAM_DMA_BUFFER) ++ Cache_WriteBack_Addr((uint32_t)p, sizeof(ESP32_I2S_DMA_STORAGE_TYPE)); ++#endif ++ } ++ } ++ ++ return onTime; ++} ++ ++void IRAM_ATTR MatrixPanel_I2S_DMA::blitRGB888(const uint8_t *rgb, ++ const uint8_t *lutR, const uint8_t *lutG, const uint8_t *lutB, ++ int satBoostQ8) ++{ ++ if (!initialized || rgb == nullptr) ++ return; ++ ++ const uint16_t w = PIXELS_PER_ROW; ++ const uint8_t depth = m_cfg.getPixelColorDepthBits(); ++ const uint8_t rows = ROWS_PER_FRAME; ++ // Refuse rather than corrupt. Above PF_SPREAD_MAX_DEPTH the plane fields do not ++ // fit and the shifts are undefined; leaving the previous frame up and saying so ++ // once is diagnosable, where writing garbage into the DMA buffer is not. Cannot ++ // fire on any configuration this firmware ships. ++ if (depth > PF_SPREAD_MAX_DEPTH) ++ { ++ static bool warned = false; ++ if (!warned) ++ { ++ warned = true; ++ ESP_LOGE("I2S-DMA", "blitRGB888: colour depth %d exceeds %d, the most the " ++ "plane encoding can hold - frame not written", depth, PF_SPREAD_MAX_DEPTH); ++ } ++ return; ++ } ++ if (pfSpreadDepth != depth) pfBuildSpread(depth); ++ ++ // Total LED on-time this frame asks for, summed as we go. The per-pixel ++ // linear values are computed below anyway and live in registers when we add ++ // them, so this rides along for six adds per pixel pair — nothing next to ++ // the bitplane writes underneath it. See lastFrameOnTime(). ++ uint64_t onTime = 0; ++ ++ // The passes need every plane word-aligned (an even width) and the depth ++ // pass 2 is written for. Everything else goes to the loop they replaced. ++ const bool passes = (depth == 8) && !(w & 1); ++ // Asked of the tables on every frame, not remembered: they are the caller's, ++ // and it rebuilds them in place whenever gamma or white balance is tuned. ++ // 768 compares a frame, against 36 instructions on each of its column pairs. ++ const bool rawLut = passes && pfLutIsIdentity(lutR) && pfLutIsIdentity(lutG) && pfLutIsIdentity(lutB); ++ // A row's planes are one allocation, `w` words apart ++ // (rowBitStruct::getDataPtr), so plane 0 and a stride reach all of them. ++ const size_t stride = (size_t)w * sizeof(ESP32_I2S_DMA_STORAGE_TYPE); ++ uint8_t idx[PF_BLIT_CHUNK * 6]; ++ PfPlanes planes[PF_BLIT_CHUNK]; ++ ++ // A two-scan panel lights row `r` and row `r + ROWS_PER_FRAME` together, and ++ // both live in the same uint16_t. Walk the pairs, not the rows. ++ for (uint8_t row = 0; row < rows; row++) ++ { ++ const uint8_t *top = rgb + (size_t)row * w * 3; ++ const uint8_t *bot = rgb + ((size_t)row + rows) * w * 3; ++ ESP32_I2S_DMA_STORAGE_TYPE *plane0 = getRowDataPtr(row, 0); ++ ++ if (!passes) ++ { ++ onTime += pfBlitRowPair(top, bot, plane0, w, depth, lutR, lutG, lutB, satBoostQ8); ++ continue; ++ } ++ for (unsigned x = 0; x < w; x += PF_BLIT_CHUNK) ++ { ++ const unsigned n = (w - x < PF_BLIT_CHUNK) ? (w - x) : PF_BLIT_CHUNK; ++ if (rawLut) ++ { ++ pfPostRowRaw(top + (size_t)x * 3, n, idx, satBoostQ8); ++ pfPostRowRaw(bot + (size_t)x * 3, n, idx + 3, satBoostQ8); ++ } ++ else ++ { ++ pfPostRow(top + (size_t)x * 3, n, idx, lutR, lutG, lutB, satBoostQ8); ++ pfPostRow(bot + (size_t)x * 3, n, idx + 3, lutR, lutG, lutB, satBoostQ8); ++ } ++ onTime += pfSpreadRow(idx, n, planes); ++ pfStoreRow8(planes, n / 2, plane0 + x, stride); ++ } ++ } ++ ++ _lastFrameOnTime = onTime; ++ // What an all-white frame would have summed to: every pixel, every channel, ++ // at the top of the CIE table. Recomputed rather than cached because panel ++ // geometry and colour depth are both configurable. ++ _maxFrameOnTime = (uint64_t)w * rows * 2ULL * 3ULL * lumConvTab[255]; ++} // blitRGB888 ++ ++ + /* Update the entire buffer with a single specific colour - quicker */ + void MatrixPanel_I2S_DMA::updateMatrixDMABuffer(uint8_t red, uint8_t green, uint8_t blue) + { +@@ -630,6 +1242,78 @@ + } while (row_idx); + } + ++// PATTERNFLOW FIX: how wide the OE window written into one plane's buffer is. ++// Pure, so toolchain/check_oe.py can lift it out verbatim and test it on a host. ++// ++// THE FACT EVERYTHING HERE RESTS ON. A plane's bits are latched at the END of ++// its buffer, so they are on the LEDs while the NEXT buffer in the descriptor ++// chain clocks in - and it is that next buffer's OE bits that gate them. ++// clearFrameBuffer() already knows this: it marks buffer 0 with the PREVIOUS ++// row's address "because it is used to display previous row while we pump in ++// MSBs for the next row". So the window written into buffer b does not light ++// plane b. It lights whatever was latched before it, and buffer 0's window is ++// the top plane's - which is what upstream's (2 * depth - colouridx) % depth was ++// reaching for when it gave "the LSB" a full window. ++// ++// With the chain from pfChainExtraPasses(), at depth 8, scheme 0: ++// ++// sent 0 1 2 3 4 5 6 7 6 7 7 7 | next row's 0 ++// lights 7' 0 1 2 3 4 5 6 7 6 7 7 (7' = previous row's) ++// ++// light[p] = W[p+1] p <= 4 1/32 .. 1/2 of a row ++// light[5] = W[6] 1 ++// light[6] = W[7] + W[7] 2 ++// light[7] = W[6] + W[7] + W[7] + W[0] 4 ++// ++// so W = [1, 1/32, 1/16, 1/8, 1/4, 1/2, 1, 1] is exactly binary, every window is ++// the full one shifted right, and floors keep it strictly ordered at every ++// brightness (sum of floor(n / 2^j) over j >= 1 is n - popcount(n) < n). Schemes ++// 1 and 2 are the same shape with one and no extra pass. ++// ++// TWO EARLIER VERSIONS OF THIS FUNCTION WERE WRONG, and the record matters more ++// than the embarrassment. Both kept upstream's chain (at depth 8, L = 4: ++// 0 1 2 3 4 5 6 7 5 6 6 7 7 7 7). 2026-09-10 read the full window on buffer 0 ++// as an LSB bug and shrank it, which took a fifth of the top plane's light away. ++// The morning of 2026-09-17 corrected the repeat counts and kept the same ++// misreading - window b lights plane b - and was worse. Both were "verified" by ++// a test that modelled delivered light the same wrong way, so tens of thousands ++// of assertions agreed with them. Light per plane 0-7 at full brightness: ++// ++// upstream 7 15 31 63 125 250 375 625 3 codes where light goes DOWN ++// 09-10 7 15 31 63 125 250 375 503 3, and 127 -> 128 loses 42% ++// 09-17 a.m. 9 19 39 78 78 210 355 458 15 such codes ++// this 3 7 15 31 62 125 250 500 none ++// ++// What it looked like: a pattern whose background is a near-white (214,211,204) ++// and whose edges fade to dark. Each channel crosses code 128 at a different ++// point along the fade, and the one that has crossed is suddenly far brighter ++// than the ones that have not - blue first, a purple fringe; then green, a ++// sky-blue one; then red, and it is neutral again. Fringes round every star and ++// petal. A grey test card at identity white balance cannot show this: all three ++// channels cross together. ++// ++// Solving upstream's chain exactly was the first thing tried and was confirmed ++// fringe-free on the panel, but it keeps only 42% of the row lit and was too ++// dark to ship. This chain keeps 66% (upstream's, not binary: 80%). ++int pfOEWindowPixels(int buffer, int depth, int scheme, ++ int width, int blank, int brt) ++{ ++ if (buffer < 0 || buffer >= depth) return 0; ++ int full = (int)(((uint32_t)(width - blank) * (uint32_t)brt) >> 8); ++ if (full > width) full = width; ++ if (full < 0) full = 0; ++ ++ // The buffers whose window is the full one: buffer 0 always (the top plane's ++ // last pass), and every buffer that is sent again. ++ int firstFull = depth; // lowest buffer index >= 1 at full width ++ if (scheme == 0 && depth >= 3) firstFull = depth - 2; ++ else if (scheme <= 1 && depth >= 2) firstFull = depth - 1; ++ ++ if (buffer == 0 || buffer >= firstFull) return full; ++ const int shift = firstFull - buffer; // 1 for the buffer just below, and so on down ++ return (shift < 31) ? (full >> shift) : 0; ++} ++ + void MatrixPanel_I2S_DMA::setBrightnessOE(uint8_t brt, const int _buff_id) + { + +@@ -642,6 +1326,12 @@ + uint8_t _depth = fb->rowBits[0]->colour_depth; + uint16_t _width = fb->rowBits[0]->width; + ++ // One window per buffer, the same for every row - see pfOEWindowPixels above ++ // for which plane each one actually lights. ++ int windows[16] = {0}; ++ for (int b = 0; b < _depth && b < 16; b++) ++ windows[b] = pfOEWindowPixels(b, _depth, pfChainScheme, _width, _blank, brt); ++ + // start with iterating all rows in dma_buff structure + int row_idx = fb->rowBits.size(); + do +@@ -654,13 +1344,7 @@ + { + --colouridx; + +- char bitplane = (2 * _depth - colouridx) % _depth; +- char bitshift = (_depth - lsbMsbTransitionBit - 1) >> 1; +- +- char rightshift = std::max(bitplane - bitshift - 2, 0); +- // calculate the OE disable period by brightness, and also blanking +- int brightness_in_x_pixels = ((_width - _blank) * brt) >> (7 + rightshift); +- brightness_in_x_pixels = (brightness_in_x_pixels >> 1) | (brightness_in_x_pixels & 1); ++ int brightness_in_x_pixels = windows[colouridx]; + + // switch pointer to a row for a specific color index + ESP32_I2S_DMA_STORAGE_TYPE *row = fb->rowBits[row_idx]->getDataPtr(colouridx); +--- a/ESP32-HUB75-MatrixPanel-I2S-DMA.h ++++ b/ESP32-HUB75-MatrixPanel-I2S-DMA.h +@@ -607,6 +607,45 @@ + void fillScreenRGB888(uint8_t r, uint8_t g, uint8_t b); + void drawPixelRGB888(int16_t x, int16_t y, uint8_t r, uint8_t g, uint8_t b); + ++ /* PATTERNFLOW ADDITION (not upstream) — blit a whole RGB888 frame. ++ * ++ * drawPixelRGB888() is built to change one pixel: for every call it bounds- ++ * checks, works out which half of the panel the row is in, and then walks the ++ * colour-depth planes recomputing the row pointer and doing a read-modify-write ++ * per plane. Painting a full frame that way costs one call per pixel and two ++ * RMWs per plane per word (the top and bottom halves arrive as separate calls ++ * even though they share the same uint16_t). ++ * ++ * A full-frame repaint knows more than that. Both halves of a word are known at ++ * once, the row pointers are the same for every pixel in a row, and nothing ++ * needs bounds-checking. Measured on a 128x64 P2.5 at 8-bit depth this took the ++ * frame's push cost from 12.3 ms to a fraction of it — see PFCanvas::present(). ++ * ++ * `rgb` is W*H*3 bytes, row-major, 8 bits per channel, sized to the panel. ++ * Colour output is identical to calling drawPixelRGB888() for every pixel: the ++ * same lumConvTab/CIE1931 curve and the same bit masks are used. ++ */ ++ void blitRGB888(const uint8_t *rgb, ++ const uint8_t *lutR, const uint8_t *lutG, const uint8_t *lutB, ++ int satBoostQ8); ++ ++ /** ++ * Total LED on-time the last blit asked for, and the largest value that ++ * total could have been (an all-white frame). ++ * ++ * Summed from the post-CIE linear values — the numbers that actually become ++ * PWM duty — so the ratio between them is the fraction of full-white current ++ * the frame draws. Accumulated inside the blit's existing pixel loop from ++ * values already in registers, which is why it costs nothing worth ++ * measuring; see core_power.h for what reads it. ++ * ++ * Brightness is applied later, in OE timing, so this is deliberately ++ * independent of it: the limiter measures demand and sets brightness, and ++ * cannot end up chasing its own tail. ++ */ ++ uint64_t lastFrameOnTime() const { return _lastFrameOnTime; } ++ uint64_t maxFrameOnTime() const { return _maxFrameOnTime; } ++ + #ifdef USE_GFX_LITE + // 24bpp FASTLED CRGB colour struct support + void fillScreen(CRGB color); +@@ -717,7 +756,8 @@ + } + + /** +- * Stop the ESP32 DMA Engine. Screen will forever be black until next ESP reboot. ++ * Stop the ESP32 DMA Engine. Upstream says "forever ... until next ESP ++ * reboot" — see resumeDMAoutput() below, which is Patternflow's way back. + */ + void stopDMAoutput() + { +@@ -726,6 +766,31 @@ + dma_bus.dma_transfer_stop(); + } + ++ /** ++ * PATTERNFLOW ADDITION (not upstream) ++ * ++ * Restart the DMA engine after stopDMAoutput(), so a stop is a sleep rather ++ * than a one-way trip. This is what makes the firmware's sleep mode possible: ++ * with the transfer stopped the panel's driver ICs stop being clocked at ++ * 15 MHz, which is the part of the idle draw that blanking the framebuffer ++ * cannot touch. ++ * ++ * The pair is symmetric on both supported platforms — dma_transfer_stop() ++ * resets the LCD peripheral and halts the GDMA channel, and this starts the ++ * same channel again from the head of descriptor chain A. It has to live in ++ * the driver because dma_bus is protected. ++ * ++ * Note the restart always resumes at chain A while back_buffer_id is ++ * wherever it was left, so the caller should blank BOTH buffers before ++ * resuming or a stale frame can show for one flip. core_sleep.h does that. ++ */ ++ void resumeDMAoutput() ++ { ++ if (!initialized) ++ return; ++ dma_bus.dma_transfer_start(); ++ } ++ + // ------- PROTECTED ------- + // those might be useful for child classes, like VirtualMatrixPanel + protected: +@@ -893,6 +958,7 @@ + volatile int back_buffer_id = 0; // If using double buffer, which one is NOT active (ie. being displayed) to write too? + int brightness = 128; // If you get ghosting... reduce brightness level. ((60/64)*255) seems to be the limit before ghosting on a 64 pixel wide physical panel for some panels. + int lsbMsbTransitionBit = 0; // For colour depth calculations ++ int pfChainScheme = 0; // PATTERNFLOW: which chain begin() linked - see pfChainExtraPasses in the .cpp + + /* ESP32-HUB75-MatrixPanel-I2S-DMA functioning constants + * we should not those once object instance initialized it's DMA structs +@@ -907,6 +973,10 @@ + bool initialized = false; + bool config_set = false; + ++ // Power demand of the last blitted frame — see lastFrameOnTime(). ++ uint64_t _lastFrameOnTime = 0; ++ uint64_t _maxFrameOnTime = 0; ++ + }; // end Class header + + /***************************************************************************************/ diff --git a/firmware/toolchain/check_midi.py b/firmware/toolchain/check_midi.py index d654331e..63185e12 100644 --- a/firmware/toolchain/check_midi.py +++ b/firmware/toolchain/check_midi.py @@ -13,7 +13,8 @@ def main(): compiler, env = compiler_environment() with tempfile.TemporaryDirectory(prefix='patternflow-midi-') as directory: work = Path(directory) - for name in ('features/midi/core_midi.h', 'features/midi/midi_queue.h', 'src/core_bus.h'): + for name in ('features/midi/core_midi.h', 'features/midi/midi_queue.h', 'src/core_bus.h', + 'src/core_shared.h'): dest = work / name dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes((ROOT / 'firmware/patternflow' / name).read_bytes()) diff --git a/firmware/toolchain/release.py b/firmware/toolchain/release.py index a6b6c9a0..d4c68a17 100644 --- a/firmware/toolchain/release.py +++ b/firmware/toolchain/release.py @@ -8,7 +8,10 @@ `cut` (on dev, clean tree, written [Unreleased] section): 1. bumps PF_IMPROV_FW_VERSION and each named edition's PF_VARIANT_VERSION, 2. turns CHANGELOG's [Unreleased] into "## [X.Y.Z] - today" under a fresh - [Unreleased], and the "current" lines in AGENTS.md, + [Unreleased], and the "current" lines in AGENTS.md; copies the new + section to docs/releases/vX.Y.Z.md, and turns the fourth-newest section + in CHANGELOG into a line in its list of older releases (the root keeps + three in full), 3. runs shelf.sh for the core and each named edition (the images the site serves, built from a sketch copy without patternflow_secrets.h - shelf.sh refuses an image that carries credentials or the wrong version), @@ -50,6 +53,9 @@ ROOT = Path(__file__).resolve().parents[2] NET_CONFIG = ROOT / "firmware/patternflow/net_config.h" CHANGELOG = ROOT / "CHANGELOG.md" +RELEASES = ROOT / "docs/releases" +IN_FULL = 3 # releases CHANGELOG.md keeps in full; each older one is a line under OLDER +OLDER = "## Older releases" AGENTS = ROOT / "AGENTS.md" MANIFEST = ROOT / "web/public/flash/manifest.json" EDITIONS_TS = ROOT / "web/src/app/editions/editions-data.ts" @@ -193,6 +199,66 @@ def bump_changelog(core: str, today: str, allow_empty: bool) -> str: return body +# A release section runs from its "## [X.Y.Z] - date" line to the next "## ". +SECTION = re.compile(r"^## \[(?!Unreleased\])([^\]]+)\][^\n]*\n.*?(?=^## |\Z)", re.M | re.S) + + +def release_file(label: str) -> Path: + # [3.11.0] and [v1.1.0] alike: the file name always carries the v. + return RELEASES / f"v{label.lstrip('v')}.md" + + +def as_filed(section: str) -> str: + """A section as its file in docs/releases/ holds it: relative links two + folders up, nothing else changed.""" + return re.sub(r"\]\((?![a-z][a-z0-9+.-]*:|[#/])", "](../../", section.rstrip("\n")) + "\n" + + +def older_line(section: str) -> str: + label, rest = re.match(r"## \[([^\]]+)\]([^\n]*)", section).groups() + return f"- [{label}](docs/releases/{release_file(label).name}){rest}" + + +def check_filing(core: str) -> None: + """Before anything is written: the new release has no file yet, and the + section about to leave CHANGELOG.md says what its file says, so the + root's copy is not lost.""" + if release_file(core[1:]).exists(): + die(f"docs/releases/{release_file(core[1:]).name} already exists") + text, _ = read(CHANGELOG) + sections = list(SECTION.finditer(text)) + if len(sections) >= IN_FULL: + leaving = sections[IN_FULL - 1] + path = release_file(leaving.group(1)) + if path.exists() and read(path)[0] != as_filed(leaving.group(0)): + die(f"CHANGELOG.md's [{leaving.group(1)}] section and docs/releases/{path.name} differ; " + "make them agree before cutting, since the section leaves the root") + + +def file_changelog(core: str) -> list[str]: + """Copy the section just dated to docs/releases/, and turn the one after + the newest IN_FULL into a line under OLDER. Returns the files it wrote.""" + text, crlf = read(CHANGELOG) + sections = list(SECTION.finditer(text)) + written = [release_file(core[1:])] + write(written[0], as_filed(sections[0].group(0)), crlf) + if len(sections) > IN_FULL: + leaving = sections[IN_FULL] + path = release_file(leaving.group(1)) + if not path.exists(): + write(path, as_filed(leaving.group(0)), crlf) + written.append(path) + text = text[:leaving.start()] + text[leaving.end():] + if OLDER not in text: + text = text.rstrip("\n") + f"\n\n{OLDER}\n\n" + text = text.replace(f"{OLDER}\n\n", f"{OLDER}\n\n{older_line(leaving.group(0))}\n", 1) + write(CHANGELOG, text, crlf) + print(f" CHANGELOG.md: [{leaving.group(1)}] -> a line under '{OLDER[3:]}'") + written = [p.relative_to(ROOT).as_posix() for p in written] + print(" " + ", ".join(written) + ": written") + return written + + def bump_agents_editions(editions_now: dict[str, str]) -> None: if not all(editions_now.get(n) for n in EDITIONS): return @@ -279,10 +345,12 @@ def cmd_cut(args: argparse.Namespace) -> None: die("the working tree has uncommitted changes:\n " + "\n ".join(dirty)) if out(["git", "tag", "-l", core]): die(f"tag {core} already exists") + check_filing(core) print(f"== cut {core}" + (" with " + ", ".join(f"{k} {v}" for k, v in editions.items()) if editions else "")) bump_firmware(core, editions) bump_changelog(core, today, args.allow_empty_changelog) + filed = file_changelog(core) bump_agents(core, today, {**current_edition_versions(), **editions}) if args.no_build: @@ -308,6 +376,7 @@ def cmd_cut(args: argparse.Namespace) -> None: else: paths = ["firmware/patternflow/net_config.h", "CHANGELOG.md", "AGENTS.md", "web/public/flash/manifest.json", "web/src/app/editions/editions-data.ts"] + paths += filed paths += [f"firmware/bundles/{n}/overrides.h" for n in editions] run(["git", "add", "-A", "--", *paths, "web/public/flash/bin"]) summary = f"Core {core}" + "".join(f", {n.capitalize()} {v}" for n, v in editions.items()) diff --git a/hardware/README.md b/hardware/README.md index d254d54d..633ac2f8 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -11,7 +11,7 @@ Open hardware files for Patternflow: the board, the enclosure, the bill of mater | v2.1 | Legacy. Last of the v2.x generation; **does not fit the v3 cases** | `pcb/gerber/patternflow_v2.1_gerber.zip` | inside the [v2.1.0 guide](https://github.com/engmung/Patternflow/blob/v2.1.0/BUILD_GUIDE.md#1-bill-of-materials-bom) | `case/legacy_v2/` | | v1.0, v2.0 | Archived early revisions, kept for the record | `pcb/gerber/patternflow_v1.0_gerber.zip`, `pcb/gerber/patternflow_v2.0_gerber.zip` (do not order) | — | — | -The build itself is [`../BUILD_GUIDE.md`](../BUILD_GUIDE.md) (v3 boards; the same build in 3D is [patternflow.work/guide/build](https://patternflow.work/guide/build)) and [`../BUILD_GUIDE_v2.md`](../BUILD_GUIDE_v2.md) (v2.x); the map of every enclosure + electronics combination is [`docs/assembly/README.md`](../docs/assembly/README.md). Every v3.x firmware release runs on every v3 board; the pin map has not changed since v2. +The build itself is [`../BUILD_GUIDE.md`](../BUILD_GUIDE.md) (v3 boards; the same build in 3D is [patternflow.work/guide/build](https://patternflow.work/guide/build)) and [`../docs/legacy/BUILD_GUIDE_v2.md`](../docs/legacy/BUILD_GUIDE_v2.md) (v2.x); the map of every enclosure + electronics combination is [`docs/assembly/README.md`](../docs/assembly/README.md). Every v3.x firmware release runs on every v3 board; the pin map has not changed since v2. ## Folders diff --git a/hardware/case/README.md b/hardware/case/README.md index 1cd18a98..a8b77048 100644 --- a/hardware/case/README.md +++ b/hardware/case/README.md @@ -45,12 +45,12 @@ The full photo sequence lives in the [build guide](../../BUILD_GUIDE.md) — §4 ## `legacy_v2/` — v2.x boards only -Everything that fits the v2.x board generation, kept for existing builds. **None of these fit the v3 board.** Follow the [v2 build guide](../../BUILD_GUIDE_v2.md). +Everything that fits the v2.x board generation, kept for existing builds. **None of these fit the v3 board.** Follow the [v2 build guide](../../docs/legacy/BUILD_GUIDE_v2.md). | File | What | |---|---| | `encloser_v2.1.stl` | ✅ **Recommended v2 print** — the snap-fit design cut for the v2.1 board, every part in one STL (knobs included). Print & assembly verified. No body gluing, snap-fit back panel, two wall-mount holes, and **no LED-matrix bump trimming** ([#19](https://github.com/engmung/Patternflow/issues/19) recesses built in). Assemble per the sequence in the [v3 guide §6](../../BUILD_GUIDE.md#6-case-assembly). | -| `plate_main.stl` + `plate_dividers.stl` | Classic split plates (256 mm bed, glued) — the path the [v2 guide](../../BUILD_GUIDE_v2.md) documents with full photos | +| `plate_main.stl` + `plate_dividers.stl` | Classic split plates (256 mm bed, glued) — the path the [v2 guide](../../docs/legacy/BUILD_GUIDE_v2.md) documents with full photos | | `oneshot_v2_part1/2.stl` | One-piece snap-fit for the v2.x board (330 mm+ bed) | (An `easyfit` plate variant existed briefly but was retired over a fit defect — [#154](https://github.com/engmung/Patternflow/issues/154). It remains browsable at the [v2.1.0 tag](https://github.com/engmung/Patternflow/tree/v2.1.0/hardware/case).) diff --git a/shared/constants.json b/shared/constants.json new file mode 100644 index 00000000..7fce1275 --- /dev/null +++ b/shared/constants.json @@ -0,0 +1,29 @@ +{ + "//": "Values the firmware and the site both carry. Change one here, then run npm run gen:shared in web/. It writes firmware/patternflow/src/core_shared.h and web/src/lib/shared/constants.ts, and check:shared fails while either is stale.", + "panel": { + "//": "The geometries PANEL_GEOMETRY picks from in config.h. Two 64x64 modules in a chain make the same 128x64 canvas as one 128x64 module.", + "PANEL_GEOM_DEFAULT": "128x64_SINGLE", + "PANEL_GEOMETRIES": { + "128x64_SINGLE": { "id": 1, "physW": 128, "physH": 64, "chain": 1 }, + "64x64_SINGLE": { "id": 2, "physW": 64, "physH": 64, "chain": 1 }, + "64x64_CHAIN2": { "id": 3, "physW": 64, "physH": 64, "chain": 2 } + } + }, + "knobs": { + "//": "The PEC11R-4220F-S0024 has 24 detents a turn. Two turns cross a parameter's whole range, so 48 detents do.", + "KNOB_COUNT": 4, + "DETENTS_PER_TURN": 24, + "TURNS_PER_FULL_RANGE": 2 + }, + "encoder": { + "//": "Quadrature edges in one detent. A press held past LONG_PRESS_MS is a long press, and button edges closer than BUTTON_DEBOUNCE_MS are ignored.", + "EDGES_PER_DETENT": 4, + "LONG_PRESS_MS": 1000, + "BUTTON_DEBOUNCE_MS": 50 + }, + "bus": { + "//": "The absolute bus speaks integers 0 to BUS_MAX. A pattern that reads only knob deltas gets one click for every BUS_UNITS_PER_CLICK a held channel moves.", + "BUS_MAX": 1000, + "BUS_UNITS_PER_CLICK": 10 + } +} diff --git a/tools/README.md b/tools/README.md index 57fad520..ef17f503 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,6 +1,6 @@ # tools/ -Clients that Patternflow ships and that run on their own — a browser extension, a phone app, a diagnostic script. The line between this folder and [`integrations/`](../integrations/README.md): a **tool** is a standalone program of ours; an **integration** is a bridge that runs inside or beside someone else's software (a DAW, Max, TouchDesigner, Node-RED). Both talk to the panel only through the contracts in [`docs/`](../docs/README.md#contracts--other-software-is-built-against-these-not-against-the-firmware-source). +Clients that Patternflow ships and that run on their own — a browser extension, a phone app, a diagnostic script. The line between this folder and [`integrations/`](../integrations/README.md): a **tool** is a standalone program of ours; an **integration** is a bridge that runs inside or beside someone else's software (a DAW, Max, TouchDesigner, Node-RED). Both talk to the panel only through the contracts in [`docs/`](../docs/README.md#contracts--other-software-is-built-against-these-not-against-the-firmware-source). `virtual-panel/` is the one folder here on the panel's side of them. | Folder | What it is | Contract | | :--- | :--- | :--- | @@ -8,6 +8,7 @@ Clients that Patternflow ships and that run on their own — a browser extension | [`patternflow-audio-android/`](patternflow-audio-android/README.md) | The phone-side twin: captures what the phone is playing and drives the knobs with it, for filming. It takes its mapping from the panel | [`audio-ws-spec.md`](../docs/audio-ws-spec.md), and the microphone endpoints of [`rest-api.md`](../docs/rest-api.md#microphone-audio-edition) | | [`rtpmidi-probe/`](rtpmidi-probe/README.md) | A plain-Python RTP-MIDI session initiator that walks the MIDI contract against a panel from a machine with no MIDI driver | [`midi-spec.md`](../docs/midi-spec.md) | | [`case-models/`](case-models/README.md) | Not a client of the panel: the scripts that make the 3D model of each case the /build page's preview shows (`web/public/cases//model.glb`), from the case's own files in `hardware/case/`. Run by hand; the site's build does not run them | The node names `web/src/components/3d/caseModels.ts` finds in every model, listed in its README | +| [`virtual-panel/`](virtual-panel/README.md) | Not a client: a Patternflow with no board. The guide's `DeviceSim` answers on the firmware's ports, and the vectors beside each contract in `docs/` are replayed against it on every pull request. `npm run virtual-panel` in `web/` starts one | [`rest-api.md`](../docs/rest-api.md), [`osc-spec.md`](../docs/osc-spec.md), [`midi-spec.md`](../docs/midi-spec.md), [`mqtt-spec.md`](../docs/mqtt-spec.md) and [`audio-ws-spec.md`](../docs/audio-ws-spec.md), the device's side of each | ## The extension is also firmware source diff --git a/tools/patternflow-audio-extension/README.md b/tools/patternflow-audio-extension/README.md index 664875e0..ebcd3168 100644 --- a/tools/patternflow-audio-extension/README.md +++ b/tools/patternflow-audio-extension/README.md @@ -4,7 +4,7 @@ Captures the audio of a Chrome or Edge tab, splits it into four frequency bands, and sends each band to one of the panel's four knobs over your local network. The panel has to run the **Audio edition** ([patternflow.work/editions](https://patternflow.work/editions)). -[`AUDIO_GUIDE.md`](../../AUDIO_GUIDE.md) is the guide to everything around it; +[`AUDIO_GUIDE.md`](../../docs/AUDIO_GUIDE.md) is the guide to everything around it; the wire protocol is a written contract, [`docs/audio-ws-spec.md`](../../docs/audio-ws-spec.md). diff --git a/tools/virtual-panel/README.md b/tools/virtual-panel/README.md new file mode 100644 index 00000000..7cade56a --- /dev/null +++ b/tools/virtual-panel/README.md @@ -0,0 +1,27 @@ +# tools/virtual-panel/ + +A Patternflow with no board: the guide's `DeviceSim` (`web/src/lib/guide/deviceSim.ts`) behind the ports the firmware listens on, so the contract vectors in [`docs/`](../../docs/README.md) have a panel to replay against ([rfc-cleanup.md §2.6](../../docs/rfc-cleanup.md#26-step-4--the-test-spine)). It runs each pattern's JS twin, the code the guide and Pattern Lab run, never a `.pfm` module. It is a test fixture. Wi-Fi and firmware updates are not part of it. + + cd web + npm run virtual-panel -- --port http=8080 + +HTTP is on 80 here as on the device, and Linux keeps every port below 1024 from a process that is not root, so the flag moves it. `--port '*=0'` binds each port to a free one and prints the number it got. `--features mqtt` composes a feature in by its directory name in `firmware/patternflow/features/`; with none the panel is the default build. `--pack origin` leaves out the Basics pack. `--clock manual` holds time still until something moves it. + +`GET /frame.png` and everything under `/vp/` are the panel's own, and the firmware has neither. The PNG is the 128×64 frame as the LEDs would show it, brightness included. `/vp/turn?knob=2&detents=3` and `/vp/button?knob=4&down=1` are a hand, and `/vp/advance?ms=500` moves the manual clock. `/vp/state` says what the running pattern has been given since start, its knob deltas and button presses, with the name card over it and the first error it threw. Knobs count from 1, as in every contract. + +A transport is a file in `transports/` whose default export is a `Transport` (`panel.ts`). `start()` attaches each one whose `feature` is `null`, and a feature's only when asked for it. Each gets a `Seam` with the `DeviceSim`, the panel's clock, the HTTP routes and its ports, a place for the fields it adds to `/api/status`, and events for a hand's turn, a button, a pattern change and sleep. There are eight: + +| Transport | Feature | What it answers | `--port` | +| :--- | :--- | :--- | :--- | +| `rest-state.ts` | core | Device state in [rest-api.md](../../docs/rest-api.md): `/api/status`, `/api/params`, `/api/sleep`, `/api/display`, `/api/knobs`, `DELETE /api/crash` and the favicon | `http`, 80 | +| `rest-patterns.ts` | core | The pattern list, select and a module's files, over the Basics pack as `basics.zip` carries it, and the volume's fields in `/api/status` | `http` | +| `mqtt.ts` | `mqtt` | [mqtt-spec.md](../../docs/mqtt-spec.md) as a broker's client: the roles, the channels, the topics and the eight-second snapshot. `GET /vp/mqtt` is the banner a `message` put up | none, it dials the broker `/api/mqtt` names | +| `mqtt-http.ts` | `mqtt` | `/api/mqtt`, the device page's `/api/mqtt/config` and `/api/mqtt/forget`, and the three `mqtt` fields in `/api/status`. It has no Director, so `/api/mqtt/director`, `/flowlocal` and `/mode` are the 404 | `http` | +| `osc.ts` | `osc` | [osc-spec.md](../../docs/osc-spec.md) both ways | `osc`, 9001, and replies to `oscReply`, 9000 | +| `midi.ts` | `midi` | The RTP-MIDI session in [midi-spec.md](../../docs/midi-spec.md) and its *Host → device* table | `midi`, 5004, and the next port for data | +| `midi-out.ts` | `midi` | *Device → host* on `midi.ts`'s session, `/api/midi` and the `midi` block in `/api/status` | `http` | +| `audio-ws.ts` | `audio` | The WebSocket in [audio-ws-spec.md](../../docs/audio-ws-spec.md), its 500 ms release and `/api/audio` | `ws`, 81 | + +Each file's header comment says which lines of the spec it follows, and which firmware source it reads where the spec says nothing. What none of them does is in each vector folder's README, under the vectors it does not have. + +Nothing in CI runs `main.ts`. The suites in `web/scripts/drift/` call `start()` themselves: a vector suite gives each vector a fresh panel on free ports and the manual clock, and `check:virtual-panel` gives each preset in `firmware/patternflow/presets/` one of its own for two seconds and fails when any of them throws. diff --git a/tools/virtual-panel/http.ts b/tools/virtual-panel/http.ts new file mode 100644 index 00000000..fe04caba --- /dev/null +++ b/tools/virtual-panel/http.ts @@ -0,0 +1,48 @@ +/** + * The panel's HTTP server, on docs/rest-api.md's rules for every route: one request at a time (line 18), + * arguments from the query or a form body, never JSON (26), and no-store on JSON and on the plain-text 404 (21). + */ +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +export type Request = { method: string; path: string; args: URLSearchParams; headers: http.IncomingHttpHeaders }; +/** A body that is not a string or a Buffer goes out as JSON. */ +export type Reply = { status?: number; headers?: Record; body?: unknown }; +export type Handler = (req: Request) => Reply | Promise; +/** Adds a route. Method "*" answers any method the path has no route of its own for. */ +export type Routes = (method: string, path: string, handler: Handler) => void; + +const NOT_FOUND: Reply = { status: 404, headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" }, body: "Not found" }; + +export async function serve(port: number) { + const table = new Map(); + const routes: Routes = (method, path, handler) => { + if (table.has(`${method} ${path}`)) throw new Error(`${method} ${path} is routed twice`); + table.set(`${method} ${path}`, handler); + }; + + async function answer(req: http.IncomingMessage, res: http.ServerResponse) { + const [url, method] = [new URL(req.url ?? "/", "http://panel"), req.method ?? "GET"]; + let body = ""; + for await (const part of req) body += part; + const args = new URLSearchParams(url.search); + if (req.headers["content-type"]?.startsWith("application/x-www-form-urlencoded")) new URLSearchParams(body).forEach((v, k) => args.append(k, v)); + const handler = table.get(`${method} ${url.pathname}`) ?? table.get(`* ${url.pathname}`); + let reply = NOT_FOUND; + try { + if (handler) reply = await handler({ method, path: url.pathname, args, headers: req.headers }); + } catch (e) { + reply = { status: 500, headers: { "Content-Type": "text/plain" }, body: String((e as Error).stack ?? e) }; + } + const json = !(typeof reply.body === "string" || Buffer.isBuffer(reply.body) || reply.body === undefined); + res.writeHead(reply.status ?? 200, json ? { "Content-Type": "application/json", "Cache-Control": "no-store", ...reply.headers } : reply.headers); + await new Promise((done) => res.end(json ? JSON.stringify(reply.body) : reply.body, done)); + } + + // Each request waits until the one before it is answered, as on the device. + let queue = Promise.resolve(); + const server = http.createServer((req, res) => void (queue = queue.then(() => answer(req, res)).catch(() => void res.destroy()))); + await new Promise((done, fail) => server.once("error", fail).listen(port, () => done())); + const close = () => new Promise((done) => (server.closeAllConnections(), server.close(() => done()))); + return { routes, port: (server.address() as AddressInfo).port, close }; +} diff --git a/tools/virtual-panel/main.ts b/tools/virtual-panel/main.ts new file mode 100644 index 00000000..0fa89ce8 --- /dev/null +++ b/tools/virtual-panel/main.ts @@ -0,0 +1,26 @@ +/** `npm run virtual-panel` in web/: a virtual panel until Ctrl-C, its flags start()'s options (README.md). */ +import { start, type Options } from "./panel"; + +const ports: Record = {}; +const options: Options = { ports }; +const args = process.argv.slice(2); +for (let i = 0; i < args.length; i += 2) { + const [flag, value = ""] = [args[i], args[i + 1]]; + if (flag === "--features") options.features = value.split(",").filter(Boolean); + else if (flag === "--pack" && (value === "origin" || value === "basics")) options.pack = value; + else if (flag === "--clock" && (value === "real" || value === "manual")) options.clock = value; + else if (flag === "--port" && /^[\w*]+=\d+$/.test(value)) ports[value.split("=")[0]] = Number(value.split("=")[1]); + else throw new Error(`virtual-panel takes no ${flag} ${value}`); +} + +start(options).then( + (panel) => { + for (const [name, port] of Object.entries(panel.ports)) console.log(`${name.padEnd(8)} ${port}`); + process.once("SIGINT", () => void panel.close()); + }, + (e: NodeJS.ErrnoException) => { + console.error(`virtual-panel: ${e.message}`); + if (e.code === "EACCES") console.error("Ports below 1024 need root on Linux. Move it: --port http=8080"); + process.exitCode = 1; + }, +); diff --git a/tools/virtual-panel/panel.ts b/tools/virtual-panel/panel.ts new file mode 100644 index 00000000..4e90c6fe --- /dev/null +++ b/tools/virtual-panel/panel.ts @@ -0,0 +1,163 @@ +/** + * DeviceSim (web/src/lib/guide/deviceSim.ts) behind the ports a Patternflow answers on. Each file in + * transports/ default-exports a Transport, so a new one never edits this file (README.md). + */ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { DeviceSim, type SimPack } from "@/lib/guide/deviceSim"; +import { PANEL_H, PANEL_W, type PanelOverlay } from "@/lib/guide/panelScreens"; +import { KNOB_COUNT } from "@/lib/shared/constants"; + +import { serve, type Reply, type Routes } from "./http"; +import { png } from "./png"; + +const [WIDTH, HEIGHT] = [PANEL_W, PANEL_H]; // the frame's size, here and nowhere else +const FRAME_MS = 1000 / 60; // the 60 fps the guide ticks DeviceSim at +const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "transports"); + +/** Panel time in ms since start(). after() and every() return what stops them. */ +export type Clock = { now(): number; after(ms: number, fn: () => void): () => void; every(ms: number, fn: () => void): () => void }; +/** Knobs count from 1, as in the contracts. A turn or a button is a hand on /vp/; a pattern change is anyone's. */ +export type Events = { + turn: { knob: number; detents: number }; + button: { knob: number; down: boolean }; + pattern: { index: number; slug: string; name: string }; + sleep: { on: boolean }; +}; +/** What attach() is handed. caps is every attached transport's, in order. */ +export type Seam = { + sim: DeviceSim; + clock: Clock; + routes: Routes; + caps: readonly string[]; + /** Fields each transport adds to /api/status. */ + status: (() => Record)[]; + /** The caller's number for a port, or else the firmware's; bound is each port as bound, for start() to answer with. */ + port(name: string, firmware: number): number; + bound: Record; + on(event: E, fn: (e: Events[E]) => void): void; + onClose(fn: () => void | Promise): void; +}; +/** feature is its directory in firmware/patternflow/features/, or null for the core. */ +export type Transport = { feature: string | null; caps: string[]; attach(seam: Seam): void | Promise }; +/** No features is the default build. Ports go by name, "*" for the rest, 0 for any free one. */ +export type Options = { features?: string[]; pack?: SimPack; ports?: Record; clock?: "real" | "manual" }; +// DeviceSim keeps the screen it draws over a pattern private, since the guide never reads it. +type Inside = { overlay(): PanelOverlay | null }; + +export async function start(options: Options = {}) { + const features = options.features ?? []; + const files = fs.existsSync(DIR) ? fs.readdirSync(DIR).filter((f) => f.endsWith(".ts")).sort() : []; + const all: Transport[] = await Promise.all(files.map(async (f) => (await import(pathToFileURL(path.join(DIR, f)).href)).default)); + const missing = features.filter((f) => !all.some((t) => t.feature === f)); + if (missing.length) throw new Error(`no file in tools/virtual-panel/transports/ speaks for ${missing.join(", ")}`); + const attached = all.filter((t) => t.feature === null || features.includes(t.feature)); + const [sim, events, manual] = [new DeviceSim(), new EventEmitter(), options.clock === "manual"]; + sim.setPack(options.pack ?? "basics"); + const port = (name: string, firmware: number) => options.ports?.[name] ?? options.ports?.["*"] ?? firmware; + const http = await serve(port("http", 80)); + const ports: Record = { http: http.port }; + + // What the pattern has been given since start, and the first error the running one threw. + const zeros = () => Array(KNOB_COUNT).fill(0); + const given = { deltas: zeros(), presses: zeros(), values: [] as number[], held: [] as boolean[] }; + let error: string | null = null; + const inside = sim as unknown as Inside; + + let [now, last] = [0, sim.consoleState()]; + const timers = new Set<{ at: number; every: number; fn: () => void }>(); + const timer = (ms: number, every: number, fn: () => void) => { + const t = { at: now + ms, every, fn }; + timers.add(t); + return () => void timers.delete(t); + }; + const clock: Clock = { now: () => now, after: (ms, fn) => timer(ms, 0, fn), every: (ms, fn) => timer(ms, Math.max(1, ms), fn) }; + const due = () => [...timers].filter((t) => t.at <= now).sort((a, b) => a.at - b.at)[0]; + const step = (to: number) => { + sim.tick((to - now) / 1000); + now = to; + const frame = sim.given(); // null while the panel is dark + if (sim.consoleState().patternSlug !== last.patternSlug) error = null; + if (frame) { + frame.input.knobDeltas.forEach((d, i) => (given.deltas[i] += d)); + frame.input.btnPressed.forEach((p, i) => (given.presses[i] += Number(p))); + [given.values, given.held] = [[...(frame.input.knobValues ?? [])], [...frame.input.btnHeld]]; + error ??= frame.error; + } + for (let t = due(); t; t = due()) { + if (t.every) t.at += t.every; + else timers.delete(t); + t.fn(); + } + const s = sim.consoleState(); + if (s.patternSlug !== last.patternSlug) events.emit("pattern", { index: s.patternIndex, slug: s.patternSlug, name: s.patternName }); + if (s.sleeping !== last.sleeping) events.emit("sleep", { on: s.sleeping }); + last = s; + }; + async function advance(ms: number) { + if (!manual) throw new Error("advance() needs clock: manual"); + for (let i = 0; i < 3; i++) await new Promise((done) => setImmediate(done)); // what the sockets hold arrives first + // A frame ends early where a timer is due, so each timer runs at its own time. + const end = now + ms; + while (now < end) step(Math.min(end, now + FRAME_MS, ...[...timers].map((t) => Math.max(t.at, now)))); + } + + const state = () => { + const [s, { mode, patternCount: count }, shown] = [sim.consoleState(), sim.snapshot(), inside.overlay()]; + const pattern = { index: s.patternIndex, slug: s.patternSlug, name: s.patternName, count }; + return { now, mode, pattern, level: s.level, given, banner: shown?.kind === "content" ? shown.name : null, error }; + }; + const text = (status: number, body: string): Reply => ({ status, headers: { "Content-Type": "text/plain" }, body }); + const knobIn = (args: URLSearchParams) => [Number(args.get("knob"))].find((k) => Number.isInteger(k) && k >= 1 && k <= KNOB_COUNT); + http.routes("GET", "/frame.png", () => ({ headers: { "Content-Type": "image/png", "Cache-Control": "no-store" }, body: png(sim.frame, WIDTH, HEIGHT) })); + http.routes("*", "/vp/state", () => ({ body: state() })); + http.routes("*", "/vp/turn", ({ args }) => { + const [knob, detents] = [knobIn(args), Number(args.get("detents"))]; + if (!knob || !Number.isInteger(detents) || !detents) return text(400, "knob is 1 to 4, detents a whole number other than 0"); + sim.turn(knob - 1, detents); + events.emit("turn", { knob, detents }); + return { body: state() }; + }); + http.routes("*", "/vp/button", ({ args }) => { + const [knob, down] = [knobIn(args), args.get("down")]; + if (!knob || (down !== "1" && down !== "0")) return text(400, "knob is 1 to 4, down 1 or 0"); + if (down === "1") sim.press(knob - 1); + else sim.release(knob - 1); + events.emit("button", { knob, down: down === "1" }); + return { body: state() }; + }); + http.routes("*", "/vp/advance", async ({ args }) => { + const ms = Number(args.get("ms")); + if (!manual) return text(409, "the clock is real"); + if (!(ms >= 0)) return text(400, "ms is 0 or more"); + await advance(ms); + return { body: state() }; + }); + + const closers: (() => void | Promise)[] = []; + let ticker: NodeJS.Timeout | undefined; + const close = async () => { + clearInterval(ticker); + for (const fn of closers) await fn(); + await http.close(); + }; + const seam: Seam = { + ...{ sim, clock, routes: http.routes, port, bound: ports, status: [] }, + caps: attached.flatMap((t) => t.caps), + on: (event, fn) => void events.on(event, fn), + onClose: (fn) => void closers.unshift(fn), + }; + try { + for (const t of attached) await t.attach(seam); + } catch (e) { + await close(); + throw e; + } + // The real clock follows the wall, at most 100 ms a frame, the most DeviceSim.tick() takes at once. + const t0 = performance.now(); + if (!manual) ticker = setInterval(() => step(Math.min(performance.now() - t0, now + 100)), FRAME_MS); + return { ports, advance, close }; +} diff --git a/tools/virtual-panel/png.ts b/tools/virtual-panel/png.ts new file mode 100644 index 00000000..edefb219 --- /dev/null +++ b/tools/virtual-panel/png.ts @@ -0,0 +1,28 @@ +/** A frame as a PNG: 8-bit RGB, alpha dropped, one IDAT, with Node's zlib and no package. */ +import zlib from "node:zlib"; + +const TABLE = Array.from({ length: 256 }, (_, n) => { + for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1; + return n >>> 0; +}); +const crc = (bytes: Buffer) => (bytes.reduce((c, b) => TABLE[(c ^ b) & 0xff] ^ (c >>> 8), 0xffffffff) ^ 0xffffffff) >>> 0; + +function chunk(type: string, data: Buffer) { + const out = Buffer.concat([Buffer.alloc(4), Buffer.from(type, "latin1"), data, Buffer.alloc(4)]); + out.writeUInt32BE(data.length, 0); + out.writeUInt32BE(crc(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +} + +/** rgba is width × height × 4 bytes, rows top to bottom, as DeviceSim's frame is. */ +export function png(rgba: Uint8ClampedArray, width: number, height: number): Buffer { + // Each row is a filter byte, 0 for none, then its pixels. + const rows = Buffer.alloc((width * 3 + 1) * height); + for (let i = 0; i < width * height; i++) rows.set(rgba.subarray(i * 4, i * 4 + 3), i * 3 + Math.floor(i / width) + 1); + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header.set([8, 2], 8); // 8 bits a channel, RGB + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + return Buffer.concat([signature, chunk("IHDR", header), chunk("IDAT", zlib.deflateSync(rows)), chunk("IEND", Buffer.alloc(0))]); +} diff --git a/tools/virtual-panel/transports/audio-ws.ts b/tools/virtual-panel/transports/audio-ws.ts new file mode 100644 index 00000000..5e9a30c7 --- /dev/null +++ b/tools/virtual-panel/transports/audio-ws.ts @@ -0,0 +1,173 @@ +/** + * docs/audio-ws-spec.md's device side: a plain WebSocket on 81 on node:http (lines 17 to 21), the five messages and + * the rule for anything else (27 to 34), the 500 ms release (71), and the switch (120 to 126) with GET and POST + * /api/audio (rest-api.md lines 488 and 489). The lanes are DeviceSim's, so the bus outranks them and a hand takes + * one for five seconds (38 to 49). The spec where it speaks, features/audio/ where it is silent: a value past its + * range is clamped, a d= of 1 is a full range of clicks, and what arrives is taken at the end of a frame, six messages + * a client at most. Where the firmware does something else the spec wins: a slot is skipped only when it is a lone -, + * off=x is ignored, a d= neither keeps a lane alive nor takes it for five seconds, and any socket that closes lets the + * lanes go, not only the last one. + */ +import { createHash } from "node:crypto"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; + +import { DETENTS_PER_TURN, KNOB_COUNT, TURNS_PER_FULL_RANGE } from "@/lib/shared/constants"; + +import type { Transport } from "../panel"; + +// The feature's numbers, by the names audio_config.h, core_audio_ws.h and net_config.h give them. +const PORT = 81; // PF_AUDIO_WS_PORT +const PASSES = 6; // handle() runs wsServer.loop() six times a frame, and each pass hands over one message a client +const SCALE = DETENTS_PER_TURN * TURNS_PER_FULL_RANGE; // PF_LANE_MOTION_SCALE: the clicks a d= of 1 makes +const FRAME_MS = 1000 / 60; // the frame panel.ts ticks DeviceSim at; handle() and fillInput() run once a frame +const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // RFC 6455's, for Sec-WebSocket-Accept + +const NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/; +const KNOB = /^\d+$/; +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); +const lround = (v: number) => Math.sign(v) * Math.round(Math.abs(v)); // roundf(): 0.5 is 1 and -0.5 is -1 + +type Message = { kind: "a"; values: (number | null)[] } | { kind: "k" | "d"; knob: number; value: number } | { kind: "off"; knob: number | null }; +/** One of the five, or null for anything else. N is a knob from 0 to 3 and F a decimal number. */ +function parse(text: string): Message | null { + const knob = (n: string) => (KNOB.test(n) && Number(n) < KNOB_COUNT ? Number(n) : null); + if (text === "off") return { kind: "off", knob: null }; + if (text.startsWith("off=")) { + const n = knob(text.slice(4)); + return n === null ? null : { kind: "off", knob: n }; + } + if (text.startsWith("a=")) { + const slots = text.slice(2).split(","); + if (slots.length !== KNOB_COUNT || !slots.every((s) => s === "-" || NUMBER.test(s))) return null; + return { kind: "a", values: slots.map((s) => (s === "-" ? null : clamp(Number(s), 0, 1))) }; + } + const m = /^([kd])=([^,]*),v=(.*)$/.exec(text); + const n = m && knob(m[2]); + if (!m || n === null || !NUMBER.test(m[3])) return null; + return m[1] === "k" ? { kind: "k", knob: n, value: clamp(Number(m[3]), 0, 1) } : { kind: "d", knob: n, value: clamp(Number(m[3]), -1, 1) }; +} + +// A frame from the panel: unmasked, one piece, a payload under 126 bytes. Only close and pong are sent. +const frame = (op: number, payload: Buffer) => Buffer.concat([Buffer.from([0x80 | op, payload.length]), payload]); +const [TEXT, CLOSE, PING, PONG] = [0x1, 0x8, 0x9, 0xa]; + +// DeviceSim keeps the edition's switches private: on the board a hand flips them on the NETWORK screen. +type Inside = { rowOn: boolean[] }; +type Client = { socket: Duplex; inbox: string[]; closed: boolean }; + +export default { + feature: "audio", + caps: ["audio"], + async attach({ sim, clock, routes, status, port, bound, onClose }) { + // The AUD row, on as a board with nothing in NVS boots, and the WebSocket as the one source of the lanes. + sim.setEdition("audio"); + sim.setLaneSource("ws"); + const [row, inside] = [sim.snapshot().rows.findIndex((r) => r.name === "AUD"), sim as unknown as Inside]; + if (row < 0) throw new Error("DeviceSim has no AUD row on the Audio edition"); + const on = () => inside.rowOn[row]; + + // d= adds up between frames and reaches the pattern as whole clicks, the remainder carried (consumeKnobDelta()). + const [pending, residual] = [Array(KNOB_COUNT).fill(0), Array(KNOB_COUNT).fill(0)]; + const release = (knob: number) => { + sim.releaseLane("ws", knob); + pending[knob] = residual[knob] = 0; + }; + const releaseAll = () => [...Array(KNOB_COUNT).keys()].forEach(release); + function receive(text: string) { + const m = parse(text); + if (!m || !on()) return; // switched off, a message is read and drives nothing (line 123) + if (m.kind === "a") m.values.forEach((v, i) => v !== null && sim.setLane("ws", i, v)); + else if (m.kind === "k") sim.setLane("ws", m.knob, m.value); + else if (m.kind === "d") pending[m.knob] += m.value; + else if (m.knob === null) releaseAll(); + else release(m.knob); + } + + const clients = new Set(); + const open = () => [...clients].filter((c) => !c.closed).length; + let was = on(); + clock.every(FRAME_MS, () => { + // A hand that turns AUD off on the NETWORK screen lets the lanes go, as POST /api/audio on=0 does. + if (was && !on()) releaseAll(); + was = on(); + for (const c of clients) { + for (const text of c.inbox.splice(0, PASSES)) receive(text); + if (c.closed && !c.inbox.length) { + clients.delete(c); + releaseAll(); // the socket closed (line 51) + } + } + for (let i = 0; i < KNOB_COUNT; i++) { + if (!pending[i]) continue; + const move = pending[i] * SCALE + residual[i]; + const clicks = lround(move); + [pending[i], residual[i]] = [0, Math.abs(move - clicks) < 0.001 ? 0 : move - clicks]; + if (clicks) sim.nudge(i, clicks); // no hand made them, so they take no lane and free no channel + } + }); + + // RFC 6455 as far as a client that only sends needs it. A request that is no handshake gets a 400. + function accept(req: http.IncomingMessage, socket: Duplex, head: Buffer) { + const key = req.headers["sec-websocket-key"]; + if (req.method !== "GET" || req.headers.upgrade?.toLowerCase() !== "websocket" || !key || req.headers["sec-websocket-version"] !== "13") { + socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + return; + } + const accepted = createHash("sha1").update(key + GUID).digest("base64"); + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accepted}\r\n\r\n`); + const c: Client = { socket, inbox: [], closed: false }; + clients.add(c); + socket.on("close", () => void (c.closed = true)).on("error", () => socket.destroy()); + const shut = (code: number) => { + c.closed = true; + const b = Buffer.alloc(2); + b.writeUInt16BE(code); + socket.end(frame(CLOSE, b)); + }; + let buf = head; + const read = (more: Buffer) => { + buf = Buffer.concat([buf, more]); + while (buf.length >= 2 && !c.closed) { + if (!(buf[1] & 0x80)) return shut(1002); // a client masks every frame + const [fin, op] = [buf[0] & 0x80, buf[0] & 0x0f]; + let [size, at] = [buf[1] & 0x7f, 2]; + if (size === 126) [size, at] = [buf.length < 4 ? NaN : buf.readUInt16BE(2), 4]; + else if (size === 127) [size, at] = [buf.length < 10 ? NaN : Number(buf.readBigUInt64BE(2)), 10]; + const end = at + 4 + size; + if (Number.isNaN(end) || buf.length < end) return; + const payload = Buffer.from(buf.subarray(at + 4, end).map((x, i) => x ^ buf[at + (i % 4)])); + buf = buf.subarray(end); + // A text frame is a message. A piece of a longer one, or a binary frame, is dropped as onEvent() drops it. + if (op === TEXT && fin) c.inbox.push(payload.toString("latin1")); + else if (op === PING) socket.write(frame(PONG, payload.subarray(0, 125))); + else if (op === CLOSE) return shut(payload.length >= 2 ? payload.readUInt16BE(0) : 1000); + else if (op > 2 && op !== PONG) return shut(1002); + } + }; + socket.on("data", read); + read(Buffer.alloc(0)); + } + const server = http.createServer((_, res) => void res.writeHead(400, { Connection: "close" }).end()); + server.on("upgrade", accept); + await new Promise((done, fail) => void server.once("error", fail).listen(port("ws", PORT), () => done())); + bound.ws = (server.address() as AddressInfo).port; + onClose(() => { + for (const c of clients) c.socket.destroy(); + return new Promise((done) => (server.closeAllConnections(), server.close(() => done()))); + }); + + // feature_audio.h's two routes. on is 1 or true to switch on, and any other value switches off. + const set = (to: boolean) => { + if (!to) releaseAll(); + inside.rowOn[row] = to; + }; + routes("GET", "/api/audio", () => ({ body: { audioRuntime: on(), audioClients: open() } })); + routes("POST", "/api/audio", ({ args }) => { + if (args.has("on")) set(args.get("on") === "1" || args.get("on") === "true"); + return { body: { ok: true, audioRuntime: on() } }; + }); + status.push(() => ({ audioRuntime: on(), audioClients: open() })); + }, +} satisfies Transport; diff --git a/tools/virtual-panel/transports/midi-out.ts b/tools/virtual-panel/transports/midi-out.ts new file mode 100644 index 00000000..f0ca985c --- /dev/null +++ b/tools/virtual-panel/transports/midi-out.ts @@ -0,0 +1,146 @@ +/** + * docs/midi-spec.md's device → host table (lines 57 to 60) on midi.ts's session, with GET and POST /api/midi (115 to + * 120) and the midi block in /api/status (68 to 93). Once a frame, as observeFrame() in features/midi/core_midi.h runs: + * a hand's clicks become steps by each knob's outDiv and outMul, the remainder carried, and leave as CC 24 to 27 at + * most once a knob every 50 ms, the last value always; a hand's button is a note, and a new pattern a Program Change. + * knobs counts a hand's clicks and nothing else's, and the button event is a hand's, so a CC or note a host sent never + * comes back (line 62), and neither does a lane. The spec where it speaks, core_midi.h and core_midi_http.h where it is silent: the order the arguments + * are read in and the error texts, outDiv and outMul resetting each other even to 1, tx counting what goes out with + * nobody joined, and the pattern under SELECT's cursor as the one that changed. runtime stays true and on= is not + * read, since midi.ts applies what comes in whatever a switch says. host is kept and reported, and nobody is invited, + * since midi.ts never invites. you, the asker's address, is left out: a route is not told who asked. + */ +import { isIPv4 } from "node:net"; + +import { SIM_IP } from "@/lib/guide/deviceSim"; +import { KNOB_COUNT } from "@/lib/shared/constants"; + +import type { Reply } from "../http"; +import type { Transport } from "../panel"; +import { midi } from "./midi"; + +// The feature's numbers, by the names core_midi.h gives them. +const [CHANNEL, REL, NOTE] = [1, 24, 60]; // PF_MIDI_CHANNEL, PF_MIDI_CC_REL_BASE, PF_MIDI_NOTE_BASE +const [DIV_MAX, MUL_MAX, PACE_MS] = [16, 8, 50]; // OUT_DIVISOR_MAX, OUT_MULTIPLIER_MAX, PF_MIDI_OUT_MIN_INTERVAL_MS +const STEPS_MAX = 63; // the most one relative CC carries either way +const FRAME_MS = 1000 / 60; // the frame panel.ts ticks DeviceSim at; observeFrame() runs once a frame +const CH = (CHANNEL || 1) - 1; // channel 0 sends on 1 (line 31), and the status nibble counts from 0 +const [CC, NOTE_ON, NOTE_OFF, PROGRAM] = [0xb0 | CH, 0x90 | CH, 0x80 | CH, 0xc0 | CH]; + +const toInt = (v: string) => parseInt(v, 10) || 0; // String::toInt(): leading digits, else 0 +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); +const fail = (error: string): Reply => ({ status: 400, body: { ok: false, error } }); +const each = (v: T) => Array(KNOB_COUNT).fill(v); + +export default { + feature: "midi", + caps: [], // midi.ts gives "midi" + attach({ sim, clock, routes, status, bound, on }) { + // midi.ts sorts after this file and attaches after it, so the session is looked up once a frame or a request comes. + const session = () => midi(sim)!; + // PF_MIDI_OUT_DIVISOR, PF_MIDI_OUT_MULTIPLIER and PF_MIDI_OUT_ABSOLUTE, and no host to invite. + const [outDiv, outMul] = [each(1), each(1)]; + let [absolute, host, tx] = [true, "", 0]; + // Per knob: the clicks outDiv has not made a step of, the position abs keeps and the one the host last heard, the + // steps rel has not sent, and when the knob last went out. The first message goes out at once. + const [accum, pos, sentPos, pending, lastSent] = [each(0), each(64), each(64), each(0), each(-Infinity)]; + const emit = (...message: number[]) => { + tx++; // emit() counts it, a participant joined or not + session().send(message); + }; + + // A hand's buttons as the frame reads them: a press is the edge down, held the state, sounding a note-on sent. + const [held, pressed, sounding] = [each(false), each(false), each(false)]; + on("button", ({ knob, down }) => { + if (down && !held[knob - 1]) pressed[knob - 1] = true; + held[knob - 1] = down; + }); + let [clicks, index] = [[...sim.knobState().knobs], sim.snapshot().patternIndex]; + + clock.every(FRAME_MS, () => { + const now = Math.floor(clock.now() + 1e-6); // millis(): one clock for all four knobs, in whole ms + const knobs = sim.knobState().knobs; + for (let i = 0; i < KNOB_COUNT; i++) { + if (knobs[i] !== clicks[i]) { + // Whole steps go out and the rest waits. Truncation keeps the sign, so turning back cancels it. + accum[i] += knobs[i] - clicks[i]; + const whole = Math.trunc(accum[i] / outDiv[i]); + accum[i] -= whole * outDiv[i]; + const steps = clamp(whole * outMul[i], -STEPS_MAX, STEPS_MAX); + if (absolute) pos[i] = clamp(pos[i] + steps, 0, 127); + else pending[i] += steps; + } + // The pacer runs on every frame, motion or not: a still frame is the one that sends the last value. + const due = now - lastSent[i] >= PACE_MS; + if (absolute && due && pos[i] !== sentPos[i]) { + [sentPos[i], lastSent[i]] = [pos[i], now]; + emit(CC, REL + i, pos[i]); + } else if (!absolute && pending[i] && (due || Math.abs(pending[i]) > STEPS_MAX)) { + // A sum past ±63 cannot wait, and what one message cannot carry stays for the next. + const send = clamp(pending[i], -STEPS_MAX, STEPS_MAX); + [pending[i], lastSent[i]] = [pending[i] - send, now]; + emit(CC, REL + i, 64 + send); + } + if (pressed[i]) { + emit(NOTE_ON, NOTE + i, 127); + sounding[i] = true; + } + // Every note-on gets its note-off, a button down and up inside one frame too. + if (sounding[i] && !held[i]) { + emit(NOTE_OFF, NOTE + i, 0); + sounding[i] = false; + } + } + clicks = [...knobs]; + pressed.fill(false); + // Whoever changed it, MIDI included (line 60). On SELECT it is the one under the cursor, as currentPatternIdx is. + const shown = sim.snapshot().patternIndex; + if (shown !== index) { + index = shown; + if (shown <= 127) emit(PROGRAM, shown); + } + }); + + // appendStatus()'s block. rxDetail's counters past what midi.ts keeps stay at 0: no worker task, no queue out. + const block = () => { + const r = session().read(); + const rxDetail = { + ...{ worker: true, abs: r.abs, rel: r.rel, queueDropped: r.queueDropped, txQueueDropped: 0, packetGaps: 0 }, + ...{ parseErrors: 0, bufferOverflows: 0, pollMaxGapUs: 0, pollMaxUs: 0, queueMaxAgeUs: 0, stackMin: 0 }, + }; + const outMode = absolute ? "abs" : "rel"; + const peer = r.peers[r.peers.length - 1] ?? ""; // the one that joined last + return { runtime: true, channel: CHANNEL, outDiv: [...outDiv], outMul: [...outMul], outMode, host, rtpPeers: r.peers.length, rtpPeer: peer, rx: r.rx, tx, rxDetail }; + }; + // sendState(): the same, with where to find the session and each knob's position. + const state = (): Reply => ({ body: { ok: true, ...block(), ip: SIM_IP, port: bound.midi, outPos: [...pos] } }); + + routes("GET", "/api/midi", state); + // knob first, then outDiv, outMul, outMode and host, each applied before the next is read. + routes("POST", "/api/midi", ({ args }) => { + const knob = args.has("knob") ? toInt(args.get("knob")!) - 1 : -1; + if (args.has("knob") && (knob < 0 || knob >= KNOB_COUNT)) return fail("knob must be 1..4"); + // A gain is one number, ×N, 1:1 or 1/N, so setting either side resets the other (line 117), and the carry goes. + const gain = (div: number, mul: number) => { + for (let i = 0; i < KNOB_COUNT; i++) if (knob < 0 || i === knob) [outDiv[i], outMul[i], accum[i]] = [div, mul, 0]; + }; + const [div, mul] = [toInt(args.get("outDiv") ?? ""), toInt(args.get("outMul") ?? "")]; + if (args.has("outDiv") && (div < 1 || div > DIV_MAX)) return fail("outDiv must be 1..16"); + if (args.has("outDiv")) gain(div, 1); + if (args.has("outMul") && (mul < 1 || mul > MUL_MAX)) return fail("outMul must be 1..8"); + if (args.has("outMul")) gain(1, mul); + const mode = args.get("outMode"); + if (mode !== null && mode !== "abs" && mode !== "rel") return fail("outMode must be abs or rel"); + if (mode !== null) { + absolute = mode === "abs"; + // Steps summed for one encoding mean nothing in the other. + [accum, pending].forEach((a) => a.fill(0)); + } + const to = args.get("host"); + if (to !== null && to !== "" && !isIPv4(to)) return fail("host must be an IPv4 address or empty"); + if (to !== null) host = to; + return state(); + }); + status.push(() => ({ midi: block() })); + }, +} satisfies Transport; diff --git a/tools/virtual-panel/transports/midi.ts b/tools/virtual-panel/transports/midi.ts new file mode 100644 index 00000000..cd1dd431 --- /dev/null +++ b/tools/virtual-panel/transports/midi.ts @@ -0,0 +1,195 @@ +/** + * docs/midi-spec.md's RTP-MIDI listener on node:dgram: a session named Patternflow on 5004 (control) and 5005 (data), + * two participants at most (line 28), the channel rule (31) and the receive table (39 to 45). The spec where it + * speaks, features/midi/core_midi.h and core_midi_rtp.h where it is silent: only a joined participant's packets are + * read, and a participant that leaves lets go of every note. A message waits in the 128-entry receive queue and the + * frame applies it (lines 95 to 97), so a CC reaches the pattern on the frame after. The panel never invites anyone. + * What goes out and /api/midi are midi-out.ts, on what midi(sim) gives it. + */ +import { randomBytes } from "node:crypto"; +import dgram from "node:dgram"; + +import type { DeviceSim, SimPattern } from "@/lib/guide/deviceSim"; +import { BUS_MAX, KNOB_COUNT } from "@/lib/shared/constants"; + +import type { Transport } from "../panel"; + +// The feature's numbers, by the names core_midi.h and core_midi_rtp.h give them. +const [PORT, NAME, CHANNEL] = [5004, "Patternflow", 1]; // PF_MIDI_RTP_PORT, PF_MIDI_SESSION_NAME, PF_MIDI_CHANNEL +const [ABS, REL, NOTE] = [20, 24, 60]; // PF_MIDI_CC_ABS_BASE, PF_MIDI_CC_REL_BASE, PF_MIDI_NOTE_BASE +const [PEERS, QUEUE] = [2, 128]; // two participants (line 28), and the receive queue, which a frame empties (96) +const FRAME_MS = 1000 / 60; // the frame panel.ts ticks DeviceSim at; handle() runs once a frame + +// DeviceSim keeps the pattern list private, since the guide picks by slug. +type Inside = { patterns: SimPattern[] }; +type Peer = { name: string; data: { address: string; port: number } | null }; +type Counts = { rx: number; abs: number[]; rel: number[]; queueDropped: number }; + +// A session packet is two 0xFF octets and a two-letter command (AppleMIDI). Its clock counts 100 µs. +const isSession = (b: Buffer) => b.length >= 4 && b.readUInt16BE(0) === 0xffff; +function session(command: string, size: number) { + const b = Buffer.alloc(size); + b.writeUInt16BE(0xffff, 0); + b.write(command, 2, "latin1"); + return b; +} +// The answer to an invitation: protocol 2, the token it came with, the panel's SSRC and its name. +function answer(command: "OK" | "NO", token: number, ssrc: number) { + const b = session(command, 16); + [2, token, ssrc].forEach((v, i) => b.writeUInt32BE(v, 4 + 4 * i)); + return Buffer.concat([b, Buffer.from(`${NAME}\0`)]); +} + +// Data octets after a status, by its high nibble; system common by its low one (F1 and F3 one, F2 two). +const DATA: Record = { 0x8: 2, 0x9: 2, 0xa: 2, 0xb: 2, 0xc: 1, 0xd: 1, 0xe: 2 }; +const COMMON = [0, 1, 2, 1]; +/** RFC 6295's MIDI list, each command as [status, ...data] with running status filled in. The journal is not read. */ +function commands(b: Buffer) { + const long = (b[12] & 0x80) !== 0; // B: a 12-bit length + let at = long ? 14 : 13; + const end = Math.min(b.length, at + (long ? ((b[12] & 0x0f) << 8) | b[13] : b[12] & 0x0f)); + let [delta, running] = [(b[12] & 0x20) !== 0, 0]; // Z: the first command has a delta time too + const out: number[][] = []; + while (at < end) { + if (delta) for (let n = 0; n < 4 && at < end && b[at++] & 0x80; n++); // one to four octets, the last under 128 + delta = true; + if (at >= end) break; + const status = b[at] & 0x80 ? b[at++] : running; + if (status >= 0xf8) continue; // real time: no data, and running status stands + if (status === 0xf0 || status === 0xf7) { + while (at < end && ![0xf0, 0xf4, 0xf7].includes(b[at++])); // a system exclusive segment, to the octet that ends it + running = 0; + } else if (status >= 0xf0) [at, running] = [at + (COMMON[status & 0x0f] ?? 0), 0]; + else if (!status) break; // data with no status to run on + else { + const data = b.subarray(at, (at += DATA[status >> 4])); + if (at > end || data.some((d) => d & 0x80)) break; + out.push([(running = status), ...data]); + } + } + return out; +} + +// The data port is the control port's next, as every initiator assumes; 0 looks for a free pair. +async function pair(want: number): Promise<[dgram.Socket, dgram.Socket]> { + const open = (p: number) => + new Promise((done, fail) => { + const s = dgram.createSocket("udp4").once("error", (e) => (s.close(), fail(e))); + s.bind(p, () => done(s)); + }); + for (let tries = 1; ; tries++) { + const control = await open(want); + try { + return [control, await open(control.address().port + 1)]; + } catch (e) { + control.close(); + if (want || tries === 10) throw e; + } + } +} + +const read = (peers: Map, counts: Counts) => ({ + peers: [...peers.values()].filter((p) => p.data).map((p) => p.name), + ...{ ...counts, abs: [...counts.abs], rel: [...counts.rel] }, +}); +const sessions = new WeakMap; send(message: number[]): void }>(); +/** The session on a panel with the midi feature. midi-out.ts sorts first, so it asks once a request or an event comes in. */ +export const midi = (sim: DeviceSim) => sessions.get(sim); + +export default { + feature: "midi", + caps: ["midi"], + async attach({ sim, clock, port, bound, onClose }) { + const [control, data] = await pair(port("midi", PORT)); + bound.midi = control.address().port; + onClose(async () => void (await Promise.all([control, data].map((s) => new Promise((done) => s.close(() => done())))))); + const [ssrc, peers] = [randomBytes(4).readUInt32BE(0), new Map()]; + const inbox: (number[] | null)[] = []; // null: a participant left + const counts: Counts = { rx: 0, abs: Array(KNOB_COUNT).fill(0), rel: Array(KNOB_COUNT).fill(0), queueDropped: 0 }; + const ticks = () => Math.round(clock.now() * 10); // the panel's clock in 100 µs + + // Invitation (IN) on control and then on data, clock sync (CK) and the end (BY), on either port. + function hear(socket: dgram.Socket, b: Buffer, from: dgram.RemoteInfo) { + const [command, onData] = [b.toString("latin1", 2, 4), socket === data]; + const reply = (r: Buffer) => socket.send(r, from.port, from.address, () => {}); + if ((command === "IN" || command === "BY") && b.length >= 16) { + const [token, them] = [b.readUInt32BE(8), b.readUInt32BE(12)]; + const peer = peers.get(them); + if (command === "BY") { + if (peers.delete(them) && peer?.data) inbox.push(null); + return; + } + // The data port takes only a participant the control port took. + if (onData ? !peer : !peer && peers.size >= PEERS) return reply(answer("NO", token, ssrc)); + const end = b.indexOf(0, 16); + if (onData) peer!.data = { address: from.address, port: from.port }; + else if (!peer) peers.set(them, { name: b.toString("utf8", 16, end < 0 ? b.length : end), data: null }); + reply(answer("OK", token, ssrc)); + } else if (command === "CK" && b.length >= 36 && b[8] === 0 && peers.has(b.readUInt32BE(4))) { + // The initiator's count 0, answered with count 1: its time back and the panel's beside it. + const r = session("CK", 36); + r.writeUInt32BE(ssrc, 4); + r[8] = 1; + r.writeBigUInt64BE(b.readBigUInt64BE(12), 12); + r.writeBigUInt64BE(BigInt(ticks()), 20); + reply(r); + } + } + control.on("message", (b, from) => isSession(b) && hear(control, b, from)); + data.on("message", (b, from) => { + if (isSession(b)) return hear(data, b, from); + // RTP version 2, payload type 97, from a participant that has joined. + if (b.length < 13 || b[0] >> 6 !== 2 || (b[1] & 0x7f) !== 0x61 || !peers.get(b.readUInt32BE(8))?.data) return; + for (const m of commands(b)) { + if (![0x80, 0x90, 0xb0, 0xc0].includes(m[0] & 0xf0)) continue; // the four the handlers take; the rest is ignored (44) + if (inbox.length < QUEUE) inbox.push(m); + else counts.queueDropped++; + } + }); + + clock.every(FRAME_MS, () => { + let pick = -1; + for (const m of inbox.splice(0, QUEUE)) { + if (!m) { + for (let i = 0; i < KNOB_COUNT; i++) sim.remoteButton(i, false); + continue; + } + if (CHANNEL && (m[0] & 0x0f) + 1 !== CHANNEL) continue; // 0 would take every channel (31) + const [kind, d1, d2] = [m[0] & 0xf0, m[1], m[2]]; + counts.rx++; + if (kind === 0xb0 && d1 >= ABS && d1 < ABS + KNOB_COUNT) { + // 0..127 onto the bus, rounded, 127 on 1000, held until a hand turns that knob (39). + counts.abs[d1 - ABS]++; + sim.applyRemoteParam(d1 - ABS, Math.round((d2 * BUS_MAX) / 127)); + } else if (kind === 0xb0 && d1 >= REL && d1 < REL + KNOB_COUNT) { + // 64 is still, 65 one detent on and 63 one back (40). Clicks no hand made: they free no channel. + counts.rel[d1 - REL]++; + sim.nudge(d1 - REL, d2 - 64); + } else if ((kind === 0x90 || kind === 0x80) && d1 >= NOTE && d1 < NOTE + KNOB_COUNT) { + sim.remoteButton(d1 - NOTE, kind === 0x90 && d2 > 0); // a note-on at velocity 0 lets go (41) + } else if (kind === 0xc0) pick = d1; + } + // takePattern(): the frame's last Program Change, in the order /patterns lists (42). Past the end it is nothing. + const list = (sim as unknown as Inside).patterns; + if (pick >= 0 && pick < list.length) sim.selectPattern(list[pick].slug); + }); + + let seq = randomBytes(2).readUInt16BE(0); + sessions.set(sim, { + /** The joined participants by name, and what came in: rx, abs and rel as status counts them (72, 87), queueDropped (88). */ + read: () => read(peers, counts), + /** One message, its status and data, in an RTP-MIDI packet of its own to every joined participant. No journal. */ + send(message) { + const b = Buffer.alloc(13); + b[0] = 0x80; // version 2 + b[1] = 0x61; // payload type 97 + b.writeUInt16BE(seq, 2); + b.writeUInt32BE(ticks() % 2 ** 32, 4); + b.writeUInt32BE(ssrc, 8); + b[12] = message.length; // a list under 16 octets has the short header + seq = (seq + 1) & 0xffff; + for (const p of peers.values()) if (p.data) data.send(Buffer.concat([b, Buffer.from(message)]), p.data.port, p.data.address, () => {}); + }, + }); + }, +} satisfies Transport; diff --git a/tools/virtual-panel/transports/mqtt-http.ts b/tools/virtual-panel/transports/mqtt-http.ts new file mode 100644 index 00000000..2fd97800 --- /dev/null +++ b/tools/virtual-panel/transports/mqtt-http.ts @@ -0,0 +1,85 @@ +/** + * GET and POST /api/mqtt (docs/rest-api.md lines 343 to 367, docs/mqtt-spec.md lines 11 and 13) and the three mqtt + * fields in /api/status (rest-api.md lines 66 and 111), on mqtt.ts's client. The spec where it speaks, + * features/mqtt/core_mqtt_http.h where it is silent: the channel argument, the short words for a role or a channel, + * /api/mqtt/config and /api/mqtt/forget, and the error texts. mqtt.ts has no Director or FlowLocal, so mode is + * always "normal", each normal* field is its plain twin, and their routes are the 404. + */ +import type { Reply } from "../http"; +import type { Transport } from "../panel"; +import { mqtt, type Channel, type Role } from "./mqtt"; + +const [PORT, PREFIX] = [1883, "patternflow"]; // PF_MQTT_PORT and PF_MQTT_PREFIX, what configOnLoop() takes for one not sent +const ROLES = new Map([ + ["publisher", "publisher"], ["pub", "publisher"], ["subscriber", "subscriber"], ["sub", "subscriber"], ["off", "off"], ["none", "off"], +]); +// channelFromName(): a name it does not know, custom among them, is Custom. +const CHANNELS = new Map([ + ["off", "off"], ["none", "off"], ["broadcast", "broadcast"], ["bc", "broadcast"], ["live", "live"], ["ch5", "live"], ["5", "live"], +]); +for (const n of [1, 2, 3, 4]) CHANNELS.set(`ch${n}`, `ch${n}` as Channel).set(`${n}`, `ch${n}` as Channel); +const BROKER = ["host", "port", "user", "pass", "password", "prefix"]; // the spec's password is the firmware's pass + +const toInt = (v: string) => parseInt(v.trim(), 10) || 0; // String::toInt(): leading digits, else 0 +const fail = (error: string): Reply => ({ status: 400, body: { ok: false, error } }); +const portOf = (args: URLSearchParams, was: number) => (args.has("port") ? toInt(args.get("port")!) : was); +const badPort = (port: number) => port < 1 || port > 65535; + +export default { + feature: "mqtt", + caps: [], // mqtt.ts gives "mqtt" + attach({ sim, routes, status }) { + // mqtt.ts sorts after this file and attaches after it, so the client is looked up when a request comes in. + const client = () => mqtt(sim)!; + // sendStatus(): the client's fields, what Director mode would overlay, and the bus. + const reply = (): Reply => { + const r = client().read(); + const { params, paramActive } = sim.knobState(); + const normal = { normalHost: r.host, normalUser: r.user, normalPrefix: r.prefix, normalPort: r.port, normalHasPassword: r.hasPassword }; + return { body: { ok: true, ...r, mode: "normal", directorHost: "", flowLocalHost: "", ...normal, params, paramActive } }; + }; + // configOnLoop(): each field trimmed but the password, and an empty password keeps the one set. was fills what is not sent. + const save = (args: URLSearchParams, was: { host: string; port: number; user: string; prefix: string }) => { + const get = (k: string) => args.get(k)?.trim(); + const [pass, prefix] = [args.get("pass") || args.get("password") || null, args.has("prefix") ? get("prefix") || PREFIX : was.prefix]; + client().saveConfig(get("host") ?? was.host, portOf(args, was.port), get("user") ?? was.user, pass, prefix); + }; + + routes("GET", "/api/mqtt", reply); + // postOnLoop(): a channel, with the role it prefers, or a role alone on the channel it is on (Broadcast for off). + // The spec sets the broker here too (mqtt-spec.md line 11). A field not sent stays as it is, and the broker is + // saved after the channel: prefix=room&role=subscriber on a panel that is off ends on room, where the other + // order would put it on Broadcast's prefix. + routes("POST", "/api/mqtt", ({ args }) => { + const [channel, role, broker] = [args.get("channel")?.toLowerCase(), args.get("role")?.toLowerCase(), BROKER.some((k) => args.has(k))]; + if (channel === undefined && role === undefined && !broker) return fail("channel or role required"); + // Beside a channel a word it does not know keeps the role; alone, an empty one is off and anything else is refused. + const alone = channel === undefined && role !== undefined ? (role === "" ? "off" : ROLES.get(role)) : undefined; + if (channel === undefined && role !== undefined && !alone) return fail("role must be off, publisher, or subscriber"); + if (broker && badPort(portOf(args, PORT))) return fail("port must be 1-65535"); + const was = client().read(); + if (channel !== undefined) client().applyChannel(CHANNELS.get(channel) ?? "custom", ROLES.get(role ?? "") ?? was.role); + else if (alone) client().applyChannel(alone === "off" ? "off" : was.channel === "off" ? "broadcast" : was.channel, alone); + if (broker) save(args, client().read()); + return reply(); + }); + // The device's own MQTT page saves the broker here, and anything not sent goes back to its default. + routes("POST", "/api/mqtt/config", ({ args }) => { + if (badPort(portOf(args, PORT))) return fail("port must be 1-65535"); + save(args, { host: "", port: PORT, user: "", prefix: PREFIX }); + return reply(); + }); + // forgetOnLoop(): clearConfig(), the password with it, then channel and role off. + routes("POST", "/api/mqtt/forget", () => { + client().saveConfig("", PORT, "", "", PREFIX); + client().applyChannel("off", "off"); + return reply(); + }); + + // appendStatus(): role and connection, never the broker or its credentials. + status.push(() => { + const { role, state, connected } = client().read(); + return { mqttRole: role, mqttState: state, mqttConnected: connected }; + }); + }, +} satisfies Transport; diff --git a/tools/virtual-panel/transports/mqtt.ts b/tools/virtual-panel/transports/mqtt.ts new file mode 100644 index 00000000..85337b60 --- /dev/null +++ b/tools/virtual-panel/transports/mqtt.ts @@ -0,0 +1,291 @@ +/** + * docs/mqtt-spec.md's client: MQTT 3.1.1 at QoS 0 on node:net, with retain, a keepalive and no wildcards (line 12), + * the roles and channels (13, 14), the topics (22 to 28) and the eight-second snapshot. The spec where it speaks, + * features/mqtt/core_mqtt.h where it is silent: when a Publisher sends, how a payload is read, how long a banner + * stays. As on the device nothing dials out until a role and a broker are set; /api/mqtt sets them through mqtt(sim). + */ +import net from "node:net"; + +import type { DeviceSim, SimPattern } from "@/lib/guide/deviceSim"; +import { KNOB_COUNT } from "@/lib/shared/constants"; + +import type { Seam, Transport } from "../panel"; + +// The feature's numbers, by the names mqtt_config.h and core_mqtt.h give them. +const [PORT, PREFIX, BANNER_MS] = [1883, "patternflow", 10_000]; // PF_MQTT_PORT, PF_MQTT_PREFIX, PF_MQTT_MESSAGE_DURATION_MS +const BANNER_BYTES = 79; // MESSAGE_BYTES, less the terminator +const [HEARTBEAT_MS, PARAM_MS, PARAM_DELTA] = [8000, 1000, 20]; // SNAPSHOT_HEARTBEAT_MS, PARAM_PUB_MIN_MS, PARAM_PUB_MIN_DELTA +// RECONNECT_MIN_MS after a clean try, _MID_ after fewer than RERESOLVE_AFTER failed ones, then _MAX_. +const [REDIAL_MS, REDIAL_AFTER] = [[5000, 15_000, 60_000], 4]; +const [DIAL_MS, KEEPALIVE_S] = [1500, 15]; // CONNECT_TIMEOUT_MS, and client.setKeepAlive(15) in applyClientSettings +const FRAME_MS = 1000 / 60; // the frame panel.ts ticks DeviceSim at; handle() runs once a frame + +export type Role = "off" | "publisher" | "subscriber"; +export type Channel = "off" | "broadcast" | "ch1" | "ch2" | "ch3" | "ch4" | "live" | "custom"; +const PRESETS: Partial> = { broadcast: "patternflow", live: "patternflow5" }; +for (const n of [1, 2, 3, 4]) PRESETS[`ch${n}` as Channel] = `patternflow${n}`; +const channelOf = (prefix: string) => (Object.keys(PRESETS) as Channel[]).find((c) => PRESETS[c] === prefix) ?? "custom"; +const forcesSub = (c: Channel) => /^ch[1-4]$/.test(c); +const showPrefix = (prefix: string) => /^patternflow[1-5]$/.test(prefix); // channels 1 to 4 and Live, which keep a snapshot +// PubSubClient's state() as stateText() words it; NaN is a dial still in flight. +const STATES: Record = { [-4]: "timeout", [-3]: "lost", [-2]: "connect failed", [-1]: "disconnected", 1: "bad protocol", + 2: "bad client id", 3: "broker unavailable", 4: "bad credentials", 5: "unauthorized" }; + +// The framing: a type byte, the length in groups of seven bits, the body. A string is two length bytes and UTF-8. +const u16 = (n: number) => Buffer.from([n >> 8, n & 255]); +const str = (s: string) => Buffer.concat([u16(Buffer.byteLength(s)), Buffer.from(s)]); +function packet(type: number, ...parts: Buffer[]) { + const [body, head] = [Buffer.concat(parts), [type]]; + let n = body.length; + do head.push((n % 128) | (n >= 128 ? 128 : 0)); + while ((n = Math.floor(n / 128))); + return Buffer.concat([Buffer.from(head), body]); +} +const [CONNACK, PUBLISH, PINGRESP] = [2, 3, 13]; + +const toInt = (v: string) => parseInt(v.trim(), 10) || 0; // atol(): leading digits, else 0 +const trimEnd = (v: string) => v.replace(/[\r\n ]+$/, ""); // what onMessage() strips from a payload +const SLEEP = new Map(Object.entries({ 1: true, on: true, true: true, sleep: true, 0: false, off: false, false: false, wake: false })); +// An object or nothing: a snapshot that does not parse applies nothing. +function json(text: string): Record { + try { return Object(JSON.parse(text)); } catch { return {}; } +} +// DeviceSim keeps the pattern list private, since the guide picks by slug. +type Inside = { patterns: SimPattern[] }; + +const clients = new WeakMap>(); +/** The client on a panel with the mqtt feature, for /api/mqtt. mqtt-http.ts sorts first, so it asks inside a handler. */ +export const mqtt = (sim: DeviceSim) => clients.get(sim); + +function client({ sim, clock, routes, bound, on, onClose }: Seam) { + // Off until a role is picked, as setup() leaves a device that was never told otherwise. + let [role, channel, prefix, host, port, user, pass] = ["off" as Role, "off" as Channel, PREFIX, "", PORT, "", ""]; + let [sock, up, state, failures, triedAt, error, id] = [null as net.Socket | null, false, -1, 0, -Infinity, "", 0]; + let redial: (() => void) | undefined; + // A device's id comes from its MAC; here from the HTTP port, so two panels on one broker do not throw each other off. + const clientId = `pf${bound.http.toString(16).toUpperCase().padStart(6, "0")}`; + const lanes = (v: T) => Array(KNOB_COUNT).fill(v); + let heard = lanes(null); // the last count on knob/N; the hand's clicks stand in before the first + let pending = lanes(null); // the frame's last param/N, applied once at the frame's end + let sent = lanes(null); // a Publisher's last param/N + let [paramAt, snapAt, bannerUntil, banner] = [-Infinity, -Infinity, -Infinity, ""]; + + const send = (b: Buffer) => void (up && sock?.write(b)); + const publish = (leaf: string, payload: string, retain = false) => + send(packet(0x30 | Number(retain), str(`${prefix}/${leaf}`), Buffer.from(payload))); + const live = () => role !== "off" && channel !== "off" && host !== ""; + const showBanner = (text: string) => { + banner = trimEnd(Buffer.from(text).subarray(0, BANNER_BYTES).toString()); + bannerUntil = banner ? clock.now() + BANNER_MS : -Infinity; + }; + + function later() { + redial?.(); + const wait = REDIAL_MS[failures === 0 ? 0 : failures < REDIAL_AFTER ? 1 : 2]; + redial = live() && !sock ? clock.after(Math.max(0, triedAt + wait - clock.now()), dial) : undefined; + } + function hangUp() { + const [s, was] = [sock, up]; + [sock, up, state] = [null, false, -1]; + if (s && was) s.end(packet(0xe0), () => s.destroy()); + else s?.destroy(); + } + // A role, a channel or a broker changed: start over, at once. + function restart() { + hangUp(); + [failures, triedAt, bannerUntil, heard, pending] = [0, -Infinity, -Infinity, lanes(null), lanes(null)]; + later(); + } + + // The socket and its timers keep real time, the broker's; what the panel does keeps the panel's clock. + function dial() { + redial = undefined; + if (sock || !live()) return; + triedAt = clock.now(); + const s = (sock = net.connect(port, host)); + [state, up] = [NaN, false]; + let [why, unresolved, waiting] = [-2, false, false]; // CONNECT_FAILED until the socket opens + let inbox: Buffer = Buffer.alloc(0); + let ping: NodeJS.Timeout | undefined; + const timeout = setTimeout(() => s.destroy(), DIAL_MS); + s.on("connect", () => { + why = -4; // no CONNACK in time + const login = user ? [str(user), str(pass)] : []; + s.write(packet(0x10, str("MQTT"), Buffer.from([4, 0x02 | (user ? 0xc0 : 0), 0, KEEPALIVE_S]), str(clientId), ...login)); + }); + s.on("error", (e: NodeJS.ErrnoException) => void (unresolved = e.code === "ENOTFOUND" || e.code === "EAI_AGAIN")); + s.on("close", () => { + clearTimeout(timeout); + clearInterval(ping); + if (sock !== s) return; + if (!up) [failures, error] = [failures + 1, unresolved ? "cannot resolve broker" : `connect state ${why}`]; + [sock, up, state] = [null, false, why]; + later(); + }); + s.on("data", (chunk: Buffer) => { + inbox = Buffer.concat([inbox, chunk]); + for (;;) { + let [length, i, scale] = [0, 1, 1]; + for (; i < inbox.length && i < 5; i++, scale *= 128) { + length += (inbox[i] & 127) * scale; + if (inbox[i] < 128) break; + } + if (i === 5) return void s.destroy(); // a length past four bytes is not MQTT + if (i >= inbox.length || inbox.length < i + 1 + length) return; + const [type, flags, body] = [inbox[0] >> 4, inbox[0] & 15, inbox.subarray(i + 1, i + 1 + length)]; + inbox = inbox.subarray(i + 1 + length); + try { + if (type === CONNACK && !up && body[1]) return void ((why = body[1]), s.destroy()); // its return code is the state + if (type === CONNACK && !up) { + clearTimeout(timeout); + why = -3; // from here a close is a lost connection + // A PINGREQ each keepalive, and a broker that has not answered the last one is gone. + ping = setInterval(() => (waiting ? ((why = -4), s.destroy()) : ((waiting = true), s.write(packet(0xc0)))), KEEPALIVE_S * 1000); + connected(); + } else if (type === PUBLISH && up) { + const at = 2 + body.readUInt16BE(0); + received(body.subarray(2, at).toString(), body.subarray(at + (flags & 6 ? 2 : 0))); + } else if (type === PINGRESP) waiting = false; + } catch { + s.destroy(); + } + } + }); + } + + // subscribeAll() or subscribePublisherExtras(), then what every connection says. + function connected() { + [up, state, failures, error, heard] = [true, 0, 0, "", lanes(null)]; + const knobs = Array.from({ length: KNOB_COUNT }, (_, i) => [`knob/${i + 1}`, `param/${i + 1}`]).flat(); + const topics = ["message", "sleep", ...(role === "subscriber" ? [...knobs, "pattern"] : []), ...(showPrefix(prefix) ? ["snapshot"] : [])]; + for (const t of topics) send(packet(0x82, u16((id = (id % 0xffff) + 1)), str(`${prefix}/${t}`), Buffer.from([0]))); + publish("sleep/state", sim.consoleState().sleeping ? "1" : "0"); // once per connection (line 27) + if (role !== "publisher") return; + // The live burst, for a peer already listening. + const { knobs: clicks, params, paramActive } = sim.knobState(); + clicks.forEach((k, i) => publish(`knob/${i + 1}`, String(k))); + publish("pattern", sim.consoleState().patternName); + sent = params.map((v, i) => (paramActive[i] ? (publish(`param/${i + 1}`, String(v)), v) : null)); + paramAt = clock.now(); + snapshot(true); + } + + function received(topic: string, payload: Buffer) { + if (!topic.startsWith(`${prefix}/`)) return; + const [leaf, body] = [topic.slice(prefix.length + 1), trimEnd(payload.toString())]; + // message, sleep and an empty snapshot are obeyed in either role (line 32). + if (leaf === "message") return showBanner(payload.toString()); + if (leaf === "sleep") { + // toggle flips what sleep/state last said, which is the panel now; the change waits a frame, as requestSleep does. + const word = body.trim().toLowerCase(); + const want = word === "toggle" ? !sim.consoleState().sleeping : SLEEP.get(word); + return void (want !== undefined && clock.after(0, () => sim.setSleeping(want))); + } + if (leaf === "snapshot" && !payload.length) { + for (let i = 0; i < KNOB_COUNT; i++) sim.releaseRemoteParam(i); + return showBanner(""); + } + if (role !== "subscriber") return; + if (leaf === "snapshot") return snapshotIn(body); + if (leaf === "pattern") return pick(body); + const [, kind, n] = /^(knob|param)\/(\d)$/.exec(leaf) ?? []; + const i = Number(n) - 1; + if (!(i >= 0 && i < KNOB_COUNT)) return; + if (kind === "param") return void (pending[i] = body); // empty lets the channel go now (line 23) + // The difference from the last count heard, as clicks no hand made (line 39); the first is against the hand's. + const [count, from] = [toInt(body), heard[i] ?? sim.knobState().knobs[i]]; + heard[i] = count; + sim.nudge(i, count - from); + } + // findPatternByName(): the name, then the name in any case, then the slug. + function pick(name: string) { + const [list, lower] = [(sim as unknown as Inside).patterns, name.toLowerCase()]; + const found = + list.find((p) => p.name === name) ?? list.find((p) => p.name.toLowerCase() === lower) ?? list.find((p) => p.slug.toLowerCase() === lower); + if (name && found) sim.selectPattern(found.slug); + } + function snapshotIn(body: string) { + const snap = json(body); + if (typeof snap.pattern === "string") pick(snap.pattern); + // Straight onto the bus, as if each value had arrived on param/N (line 45). + for (const [i, v] of (Array.isArray(snap.param) ? snap.param : []).slice(0, KNOB_COUNT).entries()) { + if (typeof v !== "number") break; + sim.applyRemoteParam(i, v); + } + if (typeof snap.message === "string") showBanner(snap.message); + } + + // noteParams(): a Publisher mirrors each held channel, at most once a second and only past 20 units. + function noteParams() { + const { params, paramActive } = sim.knobState(); + const due = params.map((v, i) => paramActive[i] && (sent[i] === null || Math.abs(v - sent[i]!) >= PARAM_DELTA)); + sent = sent.map((v, i) => (paramActive[i] ? v : null)); + if (!due.includes(true) || clock.now() - paramAt < PARAM_MS) return; + due.forEach((d, i) => d && (publish(`param/${i + 1}`, String(params[i])), (sent[i] = params[i]))); + paramAt = clock.now(); + snapshot(false); + } + // Retained, every eight seconds unless forced. Its message is always empty, so a Subscriber's banner goes with it. + function snapshot(force: boolean) { + if (role !== "publisher" || !up || !showPrefix(prefix) || (!force && clock.now() - snapAt < HEARTBEAT_MS)) return; + snapAt = clock.now(); + publish("snapshot", JSON.stringify({ pattern: sim.consoleState().patternName, param: sim.knobState().params, message: "" }), true); + } + + clock.every(FRAME_MS, () => { + pending.forEach((v, i) => v !== null && (v ? sim.applyRemoteParam(i, toInt(v)) : sim.releaseRemoteParam(i))); + pending = lanes(null); + if (role !== "publisher" || !up) return; + noteParams(); + snapshot(false); + }); + // A hand's turn goes out as the knob's new count, unless the bus still holds that channel (update()). + on("turn", ({ knob }) => { + const { knobs, paramActive } = sim.knobState(); + if (role === "publisher" && !paramActive[knob - 1]) publish(`knob/${knob}`, String(knobs[knob - 1])); + }); + on("pattern", ({ name }) => { + if (role !== "publisher" || !up) return; + publish("pattern", name); + [sent, paramAt] = [lanes(null), -Infinity]; // a cue that only changed the name still sends its params + snapshot(true); + }); + on("sleep", ({ on: asleep }) => publish("sleep/state", asleep ? "1" : "0")); + // The panel's own: the banner a message put up, which /frame.png does not draw. + routes("*", "/vp/mqtt", () => ({ body: { banner: clock.now() < bannerUntil ? banner : null, ms: Math.max(0, bannerUntil - clock.now()) } })); + onClose(() => { + const s = sock; + redial?.(); + hangUp(); + if (s) return new Promise((done) => void s.once("close", () => done())); + }); + + return { + /** What GET /api/mqtt reads, by its field names. knobs is the last count heard on knob/N, else the hand's. */ + read: () => ({ + role, channel, state: !host ? "no broker configured" : !live() ? "off" : up ? "connected" : (STATES[state] ?? "connecting"), + host, port, user, prefix, pattern: sim.consoleState().patternName, error, connected: up, configured: host !== "", + hasPassword: pass !== "", forcesSub: forcesSub(channel), knobs: sim.knobState().knobs.map((k, i) => heard[i] ?? k), + }), + /** saveConfig(): a null password keeps the one set, and a preset prefix picks its channel. */ + saveConfig(nextHost: string, nextPort: number, nextUser: string, nextPass: string | null, nextPrefix: string) { + [host, port, user, prefix, pass, error] = [nextHost, nextPort || PORT, nextUser, nextPrefix, nextPass ?? pass, ""]; + const found = channelOf(prefix); + if (found !== "custom" || channel !== "off") channel = found; + if (forcesSub(channel) && role === "publisher") role = "subscriber"; + restart(); + }, + /** applyChannel(): off turns the role off, a preset sets the prefix, and channels 1 to 4 or an off role give Subscriber. */ + applyChannel(next: Channel, preferred: Role) { + [channel, role] = next === "off" ? ["off", "off"] : [next, forcesSub(next) || preferred === "off" ? "subscriber" : preferred]; + prefix = PRESETS[next] ?? prefix; + restart(); + }, + }; +} + +export default { + feature: "mqtt", + caps: ["mqtt"], + attach: (seam) => void clients.set(seam.sim, client(seam)), +} satisfies Transport; diff --git a/tools/virtual-panel/transports/osc.ts b/tools/virtual-panel/transports/osc.ts new file mode 100644 index 00000000..afdaf35c --- /dev/null +++ b/tools/virtual-panel/transports/osc.ts @@ -0,0 +1,145 @@ +/** + * docs/osc-spec.md's device side on node:dgram: plain OSC 1.0 in on 9001, out on 9000 to the host it learned + * (lines 13 to 20), what it tells that host (24 to 39) and the four addresses a host sends (43 to 50). The spec where + * it speaks, features/osc/core_osc.h where it is silent: only the first argument is read, a float rounds half away + * from zero, and what arrives is taken at the end of a frame, eight datagrams at most, so a delta reaches the pattern + * on the frame after. Replies go to the learned address on the oscReply port, 9000 unless --port moves it. There 0, + * which '*=0' gives, sends them back to the port the host sent from, so a suite needs one socket. + */ +import dgram from "node:dgram"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { SIM_IP, type SimPattern } from "@/lib/guide/deviceSim"; +import { KNOB_COUNT } from "@/lib/shared/constants"; + +import type { Transport } from "../panel"; + +// The feature's numbers, by the names osc_config.h and core_osc.h give them. +const [LOCAL_PORT, REMOTE_PORT] = [9001, 9000]; // PF_OSC_LOCAL_PORT, PF_OSC_REMOTE_PORT +const [PACKET_BYTES, RX_BUDGET, HEARTBEAT_MS] = [256, 8, 1000]; // sizeof(packet) and rxBuf, PF_OSC_RX_BUDGET, update() +const FRAME_MS = 1000 / 60; // the frame panel.ts ticks DeviceSim at; update() runs once a frame +const NET_CONFIG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../firmware/patternflow/net_config.h"); +const P = "/patternflow"; // every address lives under it (line 54) + +// DeviceSim keeps the pattern list private, since the guide picks by slug. +type Inside = { patterns: SimPattern[] }; + +// A string ends in a zero and is padded to four bytes. Every message the panel sends has one argument. +function pad(s: string) { + const b = Buffer.from(`${s}\0`); + return Buffer.concat([b, Buffer.alloc((4 - (b.length % 4)) % 4)]); +} +function message(address: string, arg: number | string) { + if (typeof arg === "string") return Buffer.concat([pad(address), pad(",s"), pad(arg)]); + const value = Buffer.alloc(4); + value.writeInt32BE(arg | 0); // the (int32_t) cast sendInt() is handed + return Buffer.concat([pad(address), pad(",i"), value]); +} + +const lround = (v: number) => Math.sign(v) * Math.round(Math.abs(v)); // lroundf(): 2.5 is 3 and -2.5 is -3 +/** handleDatagram(): an address that starts with a slash, then type tags, else null. arg is NaN unless the first tag is i or f. */ +function parse(b: Buffer) { + const string = (at: number): [string, number] | null => { + const end = b.indexOf(0, at); + return end < 0 ? null : [b.toString("utf8", at, end), (end + 4) & ~3]; + }; + const address = string(0); + const tags = address && string(address[1]); + if (!address?.[0].startsWith("/") || !tags?.[0].startsWith(",")) return null; // a #bundle stops here (line 15) + const [type, at] = [tags[0][1], tags[1]]; + const arg = b.length < at + 4 ? NaN : type === "i" ? b.readInt32BE(at) : type === "f" ? lround(b.readFloatBE(at)) : NaN; + return { address: address[0], arg }; +} + +export default { + feature: "osc", + caps: ["osc"], + async attach({ sim, clock, port, bound, on, onClose }) { + const version = /#define\s+PF_IMPROV_FW_VERSION\s+"([^"]*)"/.exec(fs.readFileSync(NET_CONFIG, "utf8"))?.[1] ?? ""; + const socket = dgram.createSocket("udp4"); + await new Promise((done, fail) => void socket.once("error", fail).bind(port("osc", LOCAL_PORT), () => done())); + bound.osc = socket.address().port; + onClose(() => new Promise((done) => socket.close(() => done()))); + const replyPort = port("oscReply", REMOTE_PORT); + const inbox: { b: Buffer; from: dgram.RemoteInfo }[] = []; + socket.on("message", (b, from) => void inbox.push({ b, from })); + + // Nothing goes out until a host has sent something the panel reads (line 18). A send that fails is dropped. + let host: { address: string; port: number } | null = null; + const send = (address: string, arg: number | string) => { + const b = message(address, arg); + if (host && b.length <= PACKET_BYTES) socket.send(b, host.port, host.address, () => {}); + }; + + let [announce, pick] = [false, -1]; // a ping or a new host, and the frame's last pattern/index + function receive({ b, from }: (typeof inbox)[number]) { + const m = b.length <= PACKET_BYTES ? parse(b) : null; + if (!m) return; + const knob = Number(/^\/patternflow\/knob\/(\d)\/delta$/.exec(m.address)?.[1]) - 1; + if (knob >= 0 && knob < KNOB_COUNT) { + // Clicks no hand made, one for one (line 46). They take no lane and free no channel, and asleep they wake nothing. + if (Number.isFinite(m.arg)) sim.nudge(knob, m.arg); + } else if (m.address === `${P}/pattern/index`) { + if (Number.isFinite(m.arg)) pick = m.arg; + } else if (m.address === `${P}/ping`) announce = true; + else if (m.address !== `${P}/content/toggle`) return; // ignored silently (line 50), so the sender is not learned + // Any of the four teaches the panel its host, and a new host gets the announce (line 39). + const to = { address: from.address, port: replyPort || from.port }; + if (host?.address !== to.address || host.port !== to.port) [host, announce] = [to, true]; + } + + // A hand's buttons, as the frame reads them: a press is the edge down, held is the state. + const [held, pressed] = [Array(KNOB_COUNT).fill(false), Array(KNOB_COUNT).fill(false)]; + on("button", ({ knob, down }) => { + if (down && !held[knob - 1]) pressed[knob - 1] = true; + held[knob - 1] = down; + }); + const appMode = (mode: string) => (mode === "select" ? 1 : 0); // MODE_SELECTING; every other screen draws over MODE_RUNNING + let [clicks, sentHeld] = [[...sim.knobState().knobs], [...held]]; + let [index, mode] = [sim.snapshot().patternIndex, appMode(sim.snapshot().mode)]; + + clock.every(FRAME_MS, () => { + for (const d of inbox.splice(0, RX_BUDGET)) receive(d); + // On SELECT the pattern is the one under the cursor, as currentPatternIdx is. + const shown = sim.snapshot(); + if (announce) { + announce = false; + send(`${P}/hello`, "Patternflow"); + send(`${P}/version`, version); + send(`${P}/ip`, SIM_IP); + send(`${P}/pattern/index`, shown.patternIndex); + send(`${P}/pattern/name`, shown.patternName); + send(`${P}/content/mode`, 0); + send(`${P}/app/mode`, appMode(shown.mode)); + [index, mode] = [shown.patternIndex, appMode(shown.mode)]; + } + // knobs counts a hand's clicks and nothing else's, so a lane or a nudge never shows here (line 26). + const now = sim.knobState().knobs; + for (let i = 0; i < KNOB_COUNT; i++) { + if (now[i] !== clicks[i]) { + send(`${P}/knob/${i + 1}/delta`, now[i] - clicks[i]); + send(`${P}/knob/${i + 1}/clicks`, now[i]); + } + if (pressed[i]) send(`${P}/button/${i + 1}/press`, 1); + if (held[i] !== sentHeld[i]) send(`${P}/button/${i + 1}/held`, Number(held[i])); + } + [clicks, sentHeld] = [[...now], [...held]]; + pressed.fill(false); + // Each when its own column says: a pattern change sends the pattern, a mode change the mode, and neither content/mode. + if (shown.patternIndex !== index) { + send(`${P}/pattern/index`, shown.patternIndex); + send(`${P}/pattern/name`, shown.patternName); + } + if (appMode(shown.mode) !== mode) send(`${P}/app/mode`, appMode(shown.mode)); + [index, mode] = [shown.patternIndex, appMode(shown.mode)]; + // takePattern() comes after observeFrame(), so the host hears of the switch on the next frame. + const list = (sim as unknown as Inside).patterns; + if (pick >= 0 && pick < list.length) sim.selectPattern(list[pick].slug); + pick = -1; + }); + // While a host is known, asleep or not (line 34). + clock.every(HEARTBEAT_MS, () => send(`${P}/heartbeat`, Math.floor(clock.now() / 1000))); + }, +} satisfies Transport; diff --git a/tools/virtual-panel/transports/rest-patterns.ts b/tools/virtual-panel/transports/rest-patterns.ts new file mode 100644 index 00000000..8438417f --- /dev/null +++ b/tools/virtual-panel/transports/rest-patterns.ts @@ -0,0 +1,117 @@ +/** + * docs/rest-api.md "Patterns": the list, select, and a module's files. The volume holds the Basics pack as + * basics.zip carries it. Installing, deleting and formatting are not here (step 4 plan, D11). + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import zlib from "node:zlib"; + +import type { SimPattern } from "@/lib/guide/deviceSim"; +import type { PatternRuntime } from "@/lib/pattern/harness"; +import { BASICS_PACK, packZipPath } from "@/lib/pattern/packs"; + +import type { Reply } from "../http"; +import type { Transport } from "../panel"; + +const ZIP = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../web/public", packZipPath(BASICS_PACK)); +const PRESETS = 1; // Origin, the one preset left (rest-api.md "Patterns") +const FS_TOTAL = 10_240_000; // FFat.totalBytes() of the ffat partition, off a real panel (console_demo.py) +const RIPE_MS = 2000; // a pattern left sooner leaves no picture (THUMB_MIN_RUN_MS, patternflow.ino) +const CORS = { "Access-Control-Allow-Origin": "*" }; + +// DeviceSim keeps its list, the canvas and load() private, since the guide reads none of them. +type Inside = { patterns: SimPattern[]; active: number; runtime: PatternRuntime; load(index: number): void }; + +/** Each file in a zip by name, inflated with Node's zlib. */ +function unzip(zip: Buffer) { + const files = new Map(); + const end = zip.lastIndexOf(Buffer.from("PK\x05\x06", "latin1")); + for (let at = zip.readUInt32LE(end + 16), n = zip.readUInt16LE(end + 10); n > 0; n--) { + const [method, size, nameLength, local] = [zip.readUInt16LE(at + 10), zip.readUInt32LE(at + 20), zip.readUInt16LE(at + 28), zip.readUInt32LE(at + 42)]; + const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28); + const data = zip.subarray(start, start + size); + files.set(zip.toString("utf8", at + 46, at + 46 + nameLength), method === 8 ? zlib.inflateRawSync(data) : data); + at += 46 + nameLength + zip.readUInt16LE(at + 30) + zip.readUInt16LE(at + 32); + } + return files; +} +const PACK = unzip(fs.readFileSync(ZIP)); + +/** The canvas as a .thumb: "PFT1", width and height as little-endian u16, then RGB565 pixels, little-endian as the ESP32 writes them. */ +function thumb({ width, height, data }: PatternRuntime) { + const out = Buffer.alloc(8 + width * height * 2); + out.write("PFT1", "latin1"); + out.writeUInt16LE(width, 4); + out.writeUInt16LE(height, 6); + for (let i = 0; i < width * height; i++) { + const [r, g, b] = [0, 1, 2].map((c) => (data[i * 4 + c] * data[i * 4 + 3]) / 255); + out.writeUInt16LE(((r & 0xf8) << 8) | ((g & 0xfc) << 3) | (b >> 3), 8 + i * 2); + } + return out; +} + +const transport: Transport = { + feature: null, + caps: [], // rest-state.ts reports patterns with the core's other caps + attach({ sim, clock, routes, status }) { + const inside = sim as unknown as Inside; + const list = () => inside.patterns.map((p, index) => ({ index, name: p.name, module: index < PRESETS ? null : p.slug })); + const modules = () => inside.patterns.slice(PRESETS).map((p) => p.slug); + + // A pattern's picture is the canvas as it was left (snapshotActiveIfRipe, patternflow.ino). Every switch, a hand's + // on SELECT or a socket's, goes through DeviceSim's load(), so that is where the canvas is read. + const thumbs = new Map(); + let [since, captures, captureMaxUs] = [clock.now(), 0, 0]; + const load = inside.load.bind(sim); + inside.load = (index) => { + const left = inside.patterns[inside.active]; + if (left && clock.now() - since >= RIPE_MS) { + const t0 = performance.now(); + thumbs.set(left.slug, thumb(inside.runtime)); + captureMaxUs = Math.max(captureMaxUs, Math.round((performance.now() - t0) * 1000)); + captures++; + } + since = clock.now(); + load(index); + }; + + const cluster = (bytes = 0) => Math.ceil(bytes / 4096) * 4096; + const used = () => [...modules().flatMap((s) => [PACK.get(`${s}.pfm`), PACK.get(`${s}.json`)]), ...thumbs.values()].reduce((n, f) => n + cluster(f?.length), 0); + // The volume's fields in /api/status. A panel saves a pattern's picture once a boot (savedThisBoot, core_thumbs.h). + status.push(() => ({ fsMounted: true, fsTotal: FS_TOTAL, fsUsed: used(), thumbs: { captures, reads: 0, writes: thumbs.size, captureMaxUs, ioMaxUs: 0 } })); + routes("GET", "/api/patterns", () => ({ + body: { active: sim.consoleState().patternIndex, presets: PRESETS, mounted: true, free: FS_TOTAL - used(), patterns: list(), pendingRev: 0, pending: [] }, + })); + + // step, then index, then name, the order the firmware reads them in. No pattern here is hidden from step. + const whole = (arg: string | null) => (arg !== null && /^\s*[+-]?\d+\s*$/.test(arg) ? Number(arg) : -1); + routes("GET", "/api/patterns/select", ({ args }) => { + const [all, at] = [list(), sim.consoleState().patternIndex]; + const step = parseInt(args.get("step") ?? "", 10) < 0 ? -1 : 1; + const pick = args.has("step") ? (at + step + all.length) % all.length : args.has("index") ? whole(args.get("index")) : all.findIndex((p) => p.name === args.get("name")); + if (!all[pick]) return { status: 404, headers: CORS, body: { ok: false, error: "no such pattern" } }; + // The panel takes the switch on its next frame, as loop() does. + const slug = inside.patterns[pick].slug; + clock.after(0, () => void sim.selectPattern(slug)); + return { headers: CORS, body: { ok: true, index: pick, name: all[pick].name } }; + }); + + const fail = (status: number, error: string): Reply => ({ status, body: { ok: false, error } }); + routes("GET", "/api/patterns/file", ({ args }) => { + // The slug is cut down the way an upload's file name is (slugFromFilename, core_patterns_http.h). + const raw = args.get("slug"); + if (raw === null) return fail(400, "missing slug"); + const base = `${raw}.pfm`.split(/[/\\]/).pop() ?? ""; + const slug = base.slice(0, base.lastIndexOf(".") > 0 ? base.lastIndexOf(".") : undefined).replace(/[^\w-]/g, "").slice(0, 39); + if (!slug) return fail(400, "invalid slug"); + const ext = (args.get("ext") ?? "pfm").toLowerCase(); + if (!["pfm", "json", "thumb"].includes(ext)) return fail(400, "ext must be pfm, json or thumb"); + const body = ext === "thumb" ? thumbs.get(slug) : modules().includes(slug) ? PACK.get(`${slug}.${ext}`) : undefined; + if (!body) return fail(404, "not found"); + const type = ext === "json" ? "application/json" : "application/octet-stream"; + return { headers: { "Content-Type": type, "Content-Disposition": `attachment; filename="${slug}.${ext}"`, "Cache-Control": "no-store" }, body }; + }); + }, +}; +export default transport; diff --git a/tools/virtual-panel/transports/rest-state.ts b/tools/virtual-panel/transports/rest-state.ts new file mode 100644 index 00000000..484824ed --- /dev/null +++ b/tools/virtual-panel/transports/rest-state.ts @@ -0,0 +1,190 @@ +/** + * The device state half of docs/rest-api.md: /api/status, /api/params, /api/sleep, /api/display, /api/knobs, + * DELETE /api/crash and the favicon. The spec where it speaks, the firmware where it is silent: an argument's + * bound or how a number is read come from core_status_http.h, core_display_http.h and core_knobs_http.h. + */ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { SIM_HOTSPOT, SIM_IP } from "@/lib/guide/deviceSim"; +import { PANEL_H, PANEL_W } from "@/lib/guide/panelScreens"; +import { BUS_MAX, EDGES_PER_DETENT, KNOB_COUNT } from "@/lib/shared/constants"; + +import type { Reply } from "../http"; +import type { Transport } from "../panel"; + +const SKETCH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../firmware/patternflow"); +/** A #define or a constexpr as the tree builds it, so the panel reports the firmware it stands in for. */ +function firmware(file: string, name: string) { + const found = fs.readFileSync(path.join(SKETCH, file), "utf8").match(new RegExp(`(?:#define\\s+${name}\\s+|${name}\\s*=\\s*)"?([\\w.]+)`)); + if (!found) throw new Error(`no ${name} in firmware/patternflow/${file}`); + return found[1].replace(/f$/, ""); +} + +const CAPS = ["patterns", "params", "sleep"]; // the core's own, first as in handleStatus +const VERSION = firmware("net_config.h", "PF_IMPROV_FW_VERSION"); +const HOST = firmware("net_config.h", "PF_OTA_HOSTNAME"); +const FRAME_US = Math.round(1e6 / 60); // the frame panel.ts ticks DeviceSim at +const GRACE_MS = 250; // ABSOLUTE_RELEASE_GRACE_MS, core_bus.h: rN does not free a channel written this recently +const IMMUTABLE = "public, max-age=31536000, immutable"; + +// Arduino's String::toInt() and toFloat(): leading digits, else 0. +const toInt = (v: string) => parseInt(v.trim(), 10) || 0; +const toFloat = (v: string) => parseFloat(v.trim()) || 0; +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); +const zeros = (...keys: K[]) => Object.fromEntries(keys.map((k) => [k, 0])) as Record; +const fail = (error: string, status = 400): Reply => ({ status, body: { ok: false, error } }); + +/** PFSend::hostCacheable: an IPv4 address, a name with no dot or a .local name, port or not, can only be the panel. */ +function panelHost(host = "") { + const name = host.replace(/:\d+$/, "").replace(/\.$/, "").toLowerCase(); + const ipv4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(name) && name.split(".").every((n) => Number(n) <= 255); + return name !== "" && (ipv4 || !name.includes(".") || (name.endsWith(".local") && name.length > 6)); +} + +export default { + feature: null, + caps: CAPS, + attach({ sim, clock, routes, caps, status }) { + // The board has no radio, heap or flash to measure: those read 0, and the NETWORK screen's address is the ip. + const build = createHash("sha256").update(`virtual-panel ${VERSION}`).digest("hex").slice(0, 8); + let timingFrom = 0; + routes("GET", "/api/status", ({ args }) => { + const [s, { patternCount }, now] = [sim.consoleState(), sim.snapshot(), clock.now()]; + const loops = Math.floor(((now - timingFrom) * 1000) / FRAME_US); + if (args.get("resetTiming") === "1") timingFrom = now; + const body: Record = { + version: VERSION, build, variant: "core", caps: [...new Set([...CAPS, ...caps])], featureNav: [], + uptime: Math.floor(now / 1000), resetReason: "poweron", panel: `${PANEL_W}x${PANEL_H}`, + wifi: true, ssid: "", ip: SIM_IP, rssi: 0, host: HOST, hostAlias: SIM_HOTSPOT, + hotspot: { mode: "off", up: false, ssid: SIM_HOTSPOT, ip: "", channel: 0, clients: 0, dns: 0 }, + viaHotspot: false, + network: { ...zeros("disconnects", "retries", "reconnectMs", "announcements"), namesReady: false }, + ...zeros("heapInternal", "heapLargest", "heapPsram"), + fsMounted: true, ...zeros("fsTotal", "fsUsed"), fsError: "", flashId: "000000", + patterns: patternCount, presets: 1, modules: patternCount - 1, + active: s.patternName, activeIsModule: s.patternIndex > 0, loading: false, + sleep: s.sleeping, brightness: s.level, + ...sim.knobState(), + consolePaused: false, busy: "", + frameUs: FRAME_US, presentUs: 0, loopCore: 1, httpCore: 0, + netMaintenance: zeros("calls", "maxGapMs"), + runtime: { loops, lastUs: FRAME_US, maxUs: FRAME_US, ...zeros("over50ms", "housekeepingMaxUs", "syncMaxUs") }, + ...zeros("loopStackMin", "netStackMin", "loopSyncServed", "loopSyncMaxUs", "loopAgeMs", "loopSyncGaveUp"), + loopStalled: false, + colorBits: 8, refreshHz: 60, loadError: "", + load: { + ...zeros("total", "read", "relocate", "setup", "internal", "psram", "resumeUs"), + code: s.patternIndex > 0 ? "psram" : "internal", resumed: false, + }, + moduleMemory: { + reserve: Number(firmware("src/core_module_memory.h", "PF_MODULE_INTERNAL_RESERVE")), runtimeLimit: 4 * 1024 * 1024, + ...zeros("serviceFree", "execLargest", "runtimeBytes", "runtimePeakBytes", "refusals", "loaderRetries"), + ...zeros("loaderStackBytes", "loaderStackMin"), codePolicy: 2, + resident: { slots: 16, ...zeros("count", "bytes", "resumes", "evictions") }, + }, + thumbs: zeros("captures", "reads", "writes", "captureMaxUs", "ioMaxUs"), + }; + // What the other transports add, last, as extraStatus is. + return { body: Object.assign(body, ...status.map((add) => add())) }; + }); + + // Every argument is read before any is applied. rN=1 is the firmware's, and the spec does not name it. + const writtenAt = Array(KNOB_COUNT).fill(-Infinity); + routes("POST", "/api/params", ({ args }) => { + const writes: (() => void)[] = []; + for (let i = 0; i < KNOB_COUNT; i++) { + const [p, d, r] = ["p", "d", "r"].map((x) => args.get(`${x}${i + 1}`)); + if (p !== null) { + const v = toInt(p); + if (!p.trim() || v < 0 || v > BUS_MAX) return fail(`p${i + 1} must be 0..${BUS_MAX}`); + writes.push(() => { + sim.applyRemoteParam(i, v); + writtenAt[i] = clock.now(); + }); + } + if (d !== null) { + const v = toInt(d); + if (v < -100 || v > 100) return fail(`d${i + 1} must be -100..100`); + writes.push(() => sim.nudge(i, v)); + } + if (r !== null && r !== "0") writes.push(() => void (clock.now() - writtenAt[i] >= GRACE_MS && sim.releaseRemoteParam(i))); + } + if (!writes.length) return fail("send at least one of p1..p4"); + writes.forEach((write) => write()); + const { params, paramActive } = sim.knobState(); + return { body: { ok: true, params, active: paramActive } }; + }); + + // Queued for the next frame, as loop() does it, so the reply carries the state before. + routes("POST", "/api/sleep", ({ args }) => { + const [on, before] = [(args.get("on") ?? "1").trim().toLowerCase(), sim.consoleState().sleeping]; + const wanted = on === "toggle" ? !before : ["1", "true", "sleep"].includes(on) ? true : ["0", "false", "wake"].includes(on) ? false : null; + if (wanted === null) return fail("on must be 1, 0, or toggle"); + clock.after(0, () => sim.setSleeping(wanted)); + return { body: { ok: true, requested: wanted, sleep: before } }; + }); + + // The colour and the test card are kept and reported; the frame does not show them. + const tune: Record = { sat: Number(firmware("config.h", "LED_SAT_BOOST")) }; + for (const c of "RGB") { + tune[`wb_${c.toLowerCase()}`] = Number(firmware("config.h", `LED_WB_${c}`)); + tune[`gamma_${c.toLowerCase()}`] = Number(firmware("config.h", `LED_GAMMA_${c}`)); + } + const card = { calib: 0, screen: 0, level: 255, power_limit: 1, power_budget: Number(firmware("src/core_power.h", "BUDGET_DEFAULT_MA")) }; + routes("GET", "/api/display", ({ args }) => { + const has = (k: string) => args.get(k) !== null; + const read = (k: string, lo: number, hi: number) => void (has(k) && (tune[k] = clamp(toFloat(args.get(k)!), lo, hi))); + for (const c of "rgb") { + read(`wb_${c}`, 0, 1.5); + read(`gamma_${c}`, 0.2, 5); + } + read("sat", 0, 4); + if (has("screen")) { + const screen = toInt(args.get("screen")!); // -1 takes the card away and leaves the screen it showed + card.calib = Number(screen >= 0); + if (screen >= 0) card.screen = Math.min(screen, 3); + } + if (has("level")) card.level = clamp(toInt(args.get("level")!), 8, 255); + const [brightness, budget] = [toInt(args.get("brightness") ?? ""), toInt(args.get("power_budget") ?? "")]; + if (brightness >= 5 && brightness <= 255) sim.setBrightnessLevel(brightness); + if (budget >= 800 && budget <= 5000) card.power_budget = budget; // core_power.h's BUDGET_MIN_MA and BUDGET_MAX_MA + if (has("power_limit")) card.power_limit = Number(args.get("power_limit") !== "0"); + const { level, sleeping } = sim.consoleState(); + const { wb_r, wb_g, wb_b, gamma_r, gamma_g, gamma_b, sat } = tune; + const reply = { wb_r, wb_g, wb_b, gamma_r, gamma_g, gamma_b, sat, brightness: level, ...card, sleep: Number(sleeping) }; + const power = { power_ma: 0, power_demand: 0, power_limiting: 0, power_applied: level }; // no LEDs draw current here + return { headers: { "Access-Control-Allow-Origin": "*" }, body: { ...reply, ...power } }; + }); + + // A setting changes how the next turn counts and leaves the clicks where they are. + const inv = Array(KNOB_COUNT).fill(firmware("config.h", "INVERT_ENCODER") !== "0"); + const sub = Array(KNOB_COUNT).fill(EDGES_PER_DETENT); + const knobs = (): Reply => { + const raw = sim.consoleState().knobs.map((detents) => detents * EDGES_PER_DETENT); + return { body: { ok: true, inv, sub, clicks: sim.knobState().knobs, raw } }; + }; + routes("GET", "/api/knobs", knobs); + routes("POST", "/api/knobs", ({ args }) => { + const [nextInv, nextSub] = [[...inv], [...sub]]; + let any = false; + for (let i = 0; i < KNOB_COUNT; i++) { + const [iv, sv] = [args.get(`inv${i}`) ?? args.get("inv"), args.get(`sub${i}`) ?? args.get("sub")]; + if (iv !== null) nextInv[i] = ["1", "true", "on"].includes(iv); + if (sv !== null && ![4, 2, 1].includes((nextSub[i] = toInt(sv)))) return fail("sub must be 4, 2 or 1"); + any ||= iv !== null || sv !== null; + } + if (!any) return fail("nothing to set: invN, subN, inv or sub"); + inv.splice(0, KNOB_COUNT, ...nextInv); + sub.splice(0, KNOB_COUNT, ...nextSub); + sim.setKnobSettings(inv, sub); + return knobs(); + }); + + // A panel that has never crashed has no record to clear. + routes("DELETE", "/api/crash", () => fail("no crash recorded", 404)); + routes("GET", "/favicon.ico", ({ headers }) => ({ status: 204, headers: { "Cache-Control": panelHost(headers.host) ? IMMUTABLE : "no-store" } })); + }, +} satisfies Transport; diff --git a/tools/virtual-panel/tsconfig.json b/tools/virtual-panel/tsconfig.json new file mode 100644 index 00000000..c88f4132 --- /dev/null +++ b/tools/virtual-panel/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../web/tsconfig.json", + "compilerOptions": { + "incremental": false, + "typeRoots": ["../../web/node_modules/@types"], + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 0668203d..90e18794 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -56,7 +56,7 @@ Cross-component landing state (active tab, virtual knob values, bloom toggle, th Mirrors the firmware; the same JS runs in the browser, in the community's sandbox iframe and in the lab's worker. - **`src/lib/presets/`** — JS pattern sources, one file per pattern. **Source of truth** for the firmware preset `.h` files (`firmware/patternflow/presets/`; the web has more presets than the firmware — the cut list is in `firmware/patternflow/README.md`). `_TEMPLATE.ts` is the skeleton; `index.ts` the registry. -- **`src/lib/pattern/harness.ts`** — runs pattern JS on a 128×64 virtual matrix with 4 virtual encoders (24 detents/turn), matching device semantics (`knobDeltas`, `btnPressed`/`btnHeld`); also the OKLab/OKLCH colour ramp math and the LUT builders. `public/pattern-sandbox.html` is **built from it** — `scripts/build-sandbox.ts` bundles `src/sandbox/sandbox.ts` (the canvas, the live loop, the postMessage protocol) with the runtime it imports, and `check:sandbox-sync` fails when the committed page is not that build. It was a hand-kept ES5 port until 3.9.4, and it drifted (`next.config.ts` records one such bug; another was 20 detents a turn against the lab's 24). +- **`src/lib/pattern/harness.ts`** — runs pattern JS on a 128×64 virtual matrix with 4 virtual encoders (24 detents/turn), matching device semantics (`knobDeltas`, `btnPressed`/`btnHeld`); also the OKLab/OKLCH colour ramp math and the LUT builders. `public/pattern-sandbox.html` is **built from it** — `scripts/build-sandbox.ts` bundles `src/sandbox/sandbox.ts` (the canvas, the live loop, the postMessage protocol) with the runtime it imports, and `check:sandbox-sync` fails when the committed page is not that build. It was a hand-kept ES5 port until 3.9.4, and it drifted (`next.config.ts` records one such bug; another was 20 detents a turn against the lab's 24). The 24 now comes from `src/lib/shared/constants.ts`, and so do the panel size and the knob count. `npm run gen:shared` writes that file from `shared/constants.json` at the repository root, the same source as the firmware's `core_shared.h`. - **Annotations** — a pattern's frame, ramp, knob ranges and layer stack travel *inside its code* as one-line comments so they survive every copy: `@matrix` (`src/lib/pattern/matrix.ts`), `@ramp` (`src/lib/pattern/ramp.ts`), `@knobs` (`src/lib/pattern/knobs.ts` — the lab's knob state in `src/lib/lab/annotations.ts` and the community's card reader in `src/lib/community/knobs.ts` both build on it), `@stack` (`src/lib/lab/stackShare.ts`). - **`src/lib/pattern/controls.ts`** — knob-scale constants shared with the firmware. - **`src/lib/pattern/share.ts`** — licence header + attribution footer, injected at export time only. @@ -77,7 +77,7 @@ A layered, dockable workspace: nine panels over a layer-stack project. A panel i Each panel owns its stylesheet (`panels/.module.css`); `PatternLab.module.css` keeps only what two or more files draw with, and `LabPanels.module.css` the dock chrome. The lab reaches the community through exactly one file, `community.ts` (the publish and send modals, the configured switches, the handoff, the `.pfs` reader) — ESLint refuses any other import of `@/components/community` or `@/lib/community` from under the lab. -Exports flatten the visible stack to one standalone pattern (`flatten.ts`); `hExport.ts` writes the C++ header; `HardwareModal.tsx` sends it to the build service or the panel. Persistence is `localStorage`: the project (`serialize.ts`, `patternflow_lab_project_v2`), the dock layout, a ring of parked sessions (`sessions.ts`), capture settings and shaders under their own keys, and the legacy v1 draft/gallery keys read by `src/lib/lab/legacyDraft.ts`. +Exports flatten the visible stack to one standalone pattern (`flatten.ts`); `hExport.ts` writes the C++ header; `HardwareModal.tsx` sends it to the build service or the panel. Persistence is `localStorage`: the project (`serialize.ts`, `patternflow_lab_project_v2`), the dock layout, a ring of parked sessions (`sessions.ts`), capture settings and shaders under their own keys, the generated gallery (`patternflow_lab_gallery_v1`, still the live key, read and written by `src/lib/lab/legacyDraft.ts`), and the draft and ramp keys the lab stopped writing at Pattern Lab v2 on 27 July 2026, which `migrateLegacyDraft` in `serialize.ts` lifts into a project when none restores. ## Community (`src/lib/community/` + `src/components/community/`) @@ -118,6 +118,6 @@ PostHog (`src/providers/PostHogProvider.tsx`, event helpers in `src/lib/posthogE - Styling: Tailwind v4 (`@import "tailwindcss"` in `globals.css`) + global custom CSS; larger components use CSS Modules (`*.module.css`). - **Layering:** `lib/` is the bottom. `app/` and `components/` import from it; it never imports from them (ESLint enforces it). A type a component and a serializer share belongs in `lib/` — `lib/community/cardTypes.ts` is the precedent. - Adding a preset: add the JS file under `src/lib/presets/`, register it in `index.ts`, then generate the firmware `.h` with the Pattern Lab's **To hardware** → *Copy the conversion prompt* flow. -- Tests are the `check:*` scripts in `package.json` — bespoke smoke suites under `scripts/*-smoke.ts`, one per subsystem — plus `check:panels`, a Vitest + jsdom + Testing Library harness (`vitest.config.ts`, `test/setup.ts`) for the Pattern Lab panels, where pointer gestures, keyboard shortcuts, undo and the Director's keyframes need a DOM. Those tests live beside the panels (`panels/*.test.tsx`) and read the store's buffers rather than the canvas, which jsdom does not have. `npm run check:ci` runs every one that needs nothing a CI runner lacks; `check:module` wants the Xtensa toolchain and stays local. -- CI (`.github/workflows/web-ci.yml`) lints, typechecks (`npm run typecheck`), builds and runs `check:ci` on every PR touching `web/`. +- Tests are the `check:*` scripts in `package.json` — bespoke smoke suites under `scripts/*-smoke.ts`, one per subsystem — plus `check:panels`, a Vitest + jsdom + Testing Library harness (`vitest.config.ts`, `test/setup.ts`) for the Pattern Lab panels, where pointer gestures, keyboard shortcuts, undo and the Director's keyframes need a DOM. Those tests live beside the panels (`panels/*.test.tsx`) and read the store's buffers rather than the canvas, which jsdom does not have. `npm run check:ci` runs every one that needs nothing a CI runner lacks; `check:module` wants the Xtensa toolchain and stays local. The drift checks are `check:*` scripts too, one file each in `scripts/drift/`. Each compares two things the tree keeps by hand, the BOM and the build guide's parts table for one, and `check:drift`, the last step of `check:ci`, runs them all. Five hold a contract against a running panel instead. `check:rest-vectors`, `check:osc-vectors`, `check:midi-vectors`, `check:mqtt-vectors` and `check:audio-vectors` replay the vectors beside each spec in `docs/` against the virtual panel in `tools/virtual-panel/`, which is `lib/guide/`'s `DeviceSim` behind the firmware's ports. A vector also fails when the spec sentence it quotes is gone. `check:virtual-panel` is not a vector suite: it runs every preset on that panel. A file there whose name starts with `_` is a library the suites share. `_broker.ts` among them is a test broker, because a Patternflow is a broker's client and the MQTT suite has to bring one. +- CI (`.github/workflows/web-ci.yml`) lints, typechecks (`npm run typecheck`, which reads `tools/virtual-panel/` too), builds and runs `check:ci` on every PR touching `web/`. `.github/workflows/drift.yml` runs `check:drift` on every PR, whatever it touches, and `check:human-lock` against the PR's base. It sets `PF_DRIFT_STRICT=1`: at a desk a check skips a side it cannot read (a tag the clone lacks, no `pdftotext`), and in CI that skip fails. - **Deployment weight:** Vercel meters storage per *retained deployment* — every one counts at its full output size, static files and function bundles both, with nothing shared between deployments — so size × push count is what the Hobby allowance (10 GB each) is spent on. Only `main` deploys: `vercel.json` turns deployments off for `dev`, which was three quarters of them and whose previews nobody opened. `public/` is ~90 % of a deployment's static weight (journal and build-map photos). Server functions must carry none of it: `outputFileTracingExcludes` in `next.config.ts` keeps `public/` out of every function, and a build that prints "Dynamic filesystem access causes tracing of the whole project" has found a new way in — the comment there has the history. diff --git a/web/README.md b/web/README.md index 1fc90892..97a545bd 100644 --- a/web/README.md +++ b/web/README.md @@ -14,11 +14,16 @@ npm run dev # http://localhost:3000 Other scripts: ```bash -npm run build # production build — must pass before merging (CI hard gate) -npm run lint # eslint -npm run typecheck # tsc --noEmit -npm run check:ci # every smoke suite CI runs (check:module wants the Xtensa toolchain and stays local) -npm start # serve the production build +npm run build # production build — must pass before merging (CI hard gate) +npm run lint # eslint +npm run typecheck # tsc --noEmit, here and in ../tools/virtual-panel +npm run check:ci # every smoke suite CI runs, then the drift checks (check:module wants the Xtensa toolchain and stays local) +npm run check:drift # the drift checks alone; check:bom, check:editions and the others run one each +npm run check:rest-vectors # a drift check: ../docs/rest-api-vectors against a virtual panel; osc-, midi-, mqtt- and audio-vectors the same +npm run check:virtual-panel # a drift check too, but no vectors: every preset runs two seconds on a virtual panel without throwing +npm run virtual-panel # a Patternflow with no board, until Ctrl-C; on Linux add -- --port http=8080 (../tools/virtual-panel/README.md) +npm run check:human-lock # fails if a word of the maintainer's own writing changed since the merge-base with origin/dev +npm start # serve the production build ``` Requires Node 20+ (matches CI). @@ -39,11 +44,11 @@ None are required — the site runs fully without them. The community host needs - `src/lib/presets/` — JS pattern library; **source of truth** for the firmware preset headers (`firmware/toolchain/check_presets.py` keeps the two in step). - `content/` — markdown/MDX site copy (`build.md`, `pattern.md`, `inside.md`) and the journal. The journal is the maintainer's own writing. - `public/flash/` — esp-web-tools manifest + firmware binaries for the browser flasher (there is no `/flash` page: the **Flash Patternflow** button lives on the Pattern tab and in the Play guide). `public/packs/` — the Basics pack. `public/guide/` — the guide's models, its flasher captures, and the live console and editor demos (`firmware/toolchain/console_demo.py`, `editor_demo.py`). -- `scripts/` — build-time helpers (`build-sandbox.ts`, `import-patterns.ts`, ...), the build worker, and the `check:*` smoke suites (`*-smoke.ts`, run with `tsx`); `test/setup.ts` — the setup for the Vitest component tests that sit beside the code (`src/**/*.test.ts(x)`, `npm run check:panels`); `drizzle/` — community DB migrations; `.env.example` — every variable the community host reads. +- `scripts/` — build-time helpers (`build-sandbox.ts`, `import-patterns.ts`, ...), the build worker, the `check:*` smoke suites (`*-smoke.ts`, run with `tsx`), and the drift checks in `scripts/drift/`, the contract-vector suites among them; `test/setup.ts` — the setup for the Vitest component tests that sit beside the code (`src/**/*.test.ts(x)`, `npm run check:panels`); `drizzle/` — community DB migrations; `.env.example` — every variable the community host reads. - `AGENTS.md` here is a block `next dev` writes for AI agents; `CLAUDE.md` is a one-line shim to it. The project's own agent context is the root `AGENTS.md`. ## Contributing -See the repo-level [CONTRIBUTING.md](../CONTRIBUTING.md). Web changes go through a PR into `main`; CI runs lint, typecheck, the `check:*` suites and `next build` on anything under `web/`. +See the repo-level [CONTRIBUTING.md](../CONTRIBUTING.md). Web changes go through a PR into `main`; CI runs `lint`, `typecheck`, `check:ci` and `build` on anything under `web/`, and the drift checks and `check:human-lock` on every pull request. Code in `web/` is MIT licensed ([LICENSE-MIT](../LICENSE-MIT)). diff --git a/web/content/journal/fine.en.mdx b/web/content/journal/fine.en.mdx new file mode 100644 index 00000000..3de343ec --- /dev/null +++ b/web/content/journal/fine.en.mdx @@ -0,0 +1,45 @@ +--- +title: "Fine" +date: "2026-10-13T15:45:00+09:00" +excerpt: "Pretty good, actually" +cover: "/journal/fine/interview.jpg" +coverCaption: "The view during the interview" +hideCover: true +draft: false +--- + +I kept putting off the blog, but I didn't want to let it go past a week, so I'm finally writing. Maybe it's because I've been using AI so much lately that my brain's gotten a little dull and I don't feel like writing. Last night I wrote by hand in my paper diary for the first time in ages, and it felt good. The last page was from March. So it's been almost half a year. Whether I'll move it over from digital, I don't know yet. + +The Seoul Station exhibition is done, and the San Francisco exhibition seems to be running without a hitch, which is nice. Even better, I was selected for Anthropic's Claude startup program. The monthly AI subscription was no joke and my tokens kept running out too fast, which was frustrating, but that won't happen anymore! So good. I've built all sorts of things over the past few days. By the way, information really feels like the thing that matters now. Wouldn't the people who applied late and got turned down have made it if they'd applied early? + +I've barely been reading. I think this is the first time this year I've read this little… which makes me a bit uneasy. It felt like training that kept me from getting dull, and now even that won't go in. Well, I'll read again with time. I've also nearly dropped the music side. Is it that I'm trying to raise the polish by developing a ton first? That doesn't quite hold up, since it works well enough, and I'm messing around with other things more than developing Patternflow. + +Time has become really important. When was it ever not, but the reason it feels even more so is definitely AI. The thought that you can make anything if you just put in the time, along with the feeling that not using it means throwing money away, and on top of that the sense that everyone else keeps moving forward and standing still means falling behind, has gotten stronger. It isn't good. My baseline expectations keep rising, and I need the ability to slow down and enjoy things. + +Making money by monetizing AI, using that to expand my AI systems, making more money with that… I've been daydreaming like this more often. And I'm losing energy to it for no reason. Even while thinking that focusing on Patternflow is the right direction, I end up messing around anyway, which I guess is just my nature. + +Back to Patternflow: yesterday I did an interview. It's for the Patternflow introduction video I've commissioned from a professional director. Originally I wanted to make it fun by combining videos that all the many people who've built Patternflow filmed on their own, rather than interviewing me, but only two people sent anything, so it couldn't be helped. Still, it wasn't bad, and was actually fun. We set up equipment in the studio at the Seoul Design Startup Center, where I'm a resident, and it was really novel. + +
+ The interview studio, with lights, tripods and cameras set up +
The view during the interview
+
+ +We talked about all sorts of things: why I started Patternflow, what I'd done before, what I want to do next, and so on. The director ran the interview too, reacting really well and asking questions that were easy to answer, so I think we went over an hour smoothly. At the end, when she asked if there was anything else I wanted to say, I thought hard about whether I had some great line or something special, and nothing came. So I closed by asking that if you've built a Patternflow, please share it. + +After that we filmed me working at the startup center and walking around outside, and filming in a crowded place really was embarrassing. Those ten minutes or so felt much longer and harder than the hour of interview. The shots of me working at the center are actually fake… I do almost all my work at home. It's a small studio apartment, and I'm writing this blog here right now; if I tip over sideways I land on the bed. To my left is a digital piano that's become a clothes rack, and laundry hung everywhere. The desk is too messy to film here anyway, honestly. + +In two days the Crowd Supply funding ends. I wish the influencer in Spain could post their Instagram content before then… probably not, though. If not, there was really no point sending them one, and it's such a waste. Should I have just given it to someone in the community? Actually that might not be a great idea either. People who built theirs from scratch definitely use it better. It isn't just about understanding and using the features; the will to spend energy on it in the first place seems to differ a lot. When I send a finished unit… even though it's clearly documented on the website or somewhere, they don't try to understand it and just ask me directly. That's a little, no, a lot tedious, and disappointing. + +Once the funding money is paid out, I need to buy a 3D printer with it and start production, but it's still uncertain whether I'll have space for it. That studio apartment still hasn't been renovated. Today too, after lunch at the cafeteria, I stopped by the real estate office on my way home. I drop in almost every week, and every time they just say they'll contact me soon and let me know after the renovation. This time at least they said specifically that they'd call this afternoon, so I hope they keep to it. There's nowhere else with a balcony at a decent price… it'd be a shame to give up on it. + +On the day the exhibition by visually impaired artists was taken down, I went to collect the Patternflow. When one of the artists stood in front of it with a helper's assistance to try it, I was nervous. But unexpectedly, they really enjoyed it. Was the sound enough on its own, even without the visual element? Or since the LEDs are quite bright, could they make out the big changes, brightening and dimming, the colors? I didn't ask, so I don't know, but watching them play with it for a good while, saying it was fun, made me really proud. I think even an instrument made for visually impaired people could get a pretty good response. + +
+ Holding a Patternflow in the exhibition hall +
Seoul Station exhibition, before the teardown
+
+ +I've bought parts for seven or eight Patternflows and I'm waiting for them to arrive. They're for the workshop I'm planning soon. Honestly there's no concrete plan. I don't even know if there's demand. But hey, I'll just try it. + +I think that's everything that comes to mind. diff --git a/web/content/journal/fine.mdx b/web/content/journal/fine.mdx new file mode 100644 index 00000000..d2650193 --- /dev/null +++ b/web/content/journal/fine.mdx @@ -0,0 +1,45 @@ +--- +title: "무난하다" +date: "2026-10-13T15:45:00+09:00" +excerpt: "꽤 좋다" +cover: "/journal/fine/interview.jpg" +coverCaption: "인터뷰할 때 보이던 장면" +hideCover: true +draft: false +--- + +블로그를 미루고 미루다 일주일을 넘기긴 싫어서 드디어 적어본다. 요즘 AI를 너무 많이 쓰다 보니 머리가 살짝 멍청해지고 글쓰기도 싫어진 탓일까. 어젯밤에 종이 일기장에 엄청 오랜만에 수기로 글을 적어보니 괜찮더라. 마지막 페이지는 3월이었다. 거의 반년 만에 적어본 거구, 그치만 디지털에서 옮길지는 아직 모르겠다. + +서울역 전시도 마쳤고, 샌프란시스코 전시도 무사히 작동하는 것 같구, 좋다. 더 좋은 일은 앤트로픽의 클로드 스타트업 프로그램에 선정됐다는 거다. 한 달 AI 구독비가 장난 아니기도 하고 토큰이 금방 닳아버려 아쉬웠는데, 이제는 그럴 일이 없다! 너무 좋다. 요 며칠간 이것저것 많이 만들어봤다. 그나저나 이제는 정말 정보력이 중요한 느낌이다. 늦게 했다가 거절당한 사람들도 빨리 신청했다면 되었지 않았을까? + +책을 거의 안 읽고 있다. 이 정도로 읽지 않는 건 올해 들어 처음인 것 같기도 한데… 그래서 조금은 마음이 불편하다. 멍청해지지 않게 도와주는 훈련 같은 느낌이었는데, 이젠 그것마저 안 읽히니. 뭐 시간 지나면 또 읽기는 할 거다. 더불어 음악 쪽도 거의 손을 놓은 상태다. 우선 개발을 많이많이 해서 완성도를 올리고자 하는 건가. 그렇다기엔 충분히 작동하고, 또 패턴플로우 개발보단 딴짓을 많이 하고 있다. + +시간이 정말 중요해졌다. 중요하지 않은 적이 언제 있겠냐만은, 그렇게 더 느껴지는 이유는 확실히 AI 때문이다. 시간만 들이면 뭐든지 만들 수 있다는 생각과 동시에, 쓰지 않으면 돈을 날린다는 느낌, 거기에 더하여 다른 사람들은 계속 나아가고 있고 가만히 있으면 뒤처지는 것 같은 기분이 강해졌다. 좋지는 않다. 기준치들이 너무 높아지고 있는데, 좀 여유롭게 즐기는 능력이 필요하다. + +AI로 수익화를 해서 돈을 벌고, 그걸로 AI 시스템을 더 확장하고, 그걸로 또 돈을 벌고… 이런 망상을 더 자주 하게 되었다. 그리고 거기에 쓸데없이 에너지를 빼앗기고 있다. 차라리 패턴플로우에 집중하는 게 맞는 방향이라고 생각하면서도 딴짓을 하게 되는 건 그냥 성향인가 보다. + +패턴플로우로 돌아와서, 어제 인터뷰를 진행했다. 전문 디렉터분께 의뢰해서 만들고 있는 패턴플로우 소개 영상에 들어갈 내용이다. 원래는 내 인터뷰보단 패턴플로우를 만든 수많은 사람들이 자발적으로 찍은 영상들을 합쳐서 재밌게 해보고 싶었는데, 2명밖에 보내주지 않아서 어쩔 수 없다. 그래도 나쁘지 않았고 오히려 재밌기도 했다. 입주해 있는 서울디자인창업센터의 스튜디오에 장비들을 설치하고 진행했는데 되게 새로웠다. + +
+ 조명과 삼각대, 카메라가 세워진 인터뷰 촬영 스튜디오 +
인터뷰할 때 보이던 장면
+
+ +왜 패턴플로우를 시작하게 되었는지, 그 전에는 어떤 걸 해왔고 앞으로는 무얼 하고 싶은지 등등 되게 다양한 내용을 이야기했다. 디렉터님이 인터뷰도 진행해 주셨는데, 반응도 되게 잘해주시고 질문도 답변하기 편하게 해줘서 1시간 넘게 수월하게 진행했던 것 같다. 마지막에 하고 싶은 말 있냐고 물어보셨을 때, 뭔가 멋진 말이나 특별한 무언가가 있을까 많이 고민해봐도 나오지 않았다. 그래서 패턴플로우를 만들었으면 공유해달라는 내용으로 마무리를 했다. + +이후 창업센터에서 일하는 모습이나 그 밖을 돌아다니는 모습을 찍곤 했는데, 사람 많은 곳에서 영상 찍는다는 게 정말 창피하긴 하더라. 그 10분 정도가 인터뷰 1시간보다 훨씬 길고 힘들었다. 센터에서 일하는 모습은 사실 가짜인 게… 거의 모든 작업은 집에서 한다. 작은 원룸이고 지금도 여기서 블로그를 적고 있는데, 옆으로 넘어지면 바로 침대다. 왼쪽에는 옷걸이가 되어버린 전자피아노와 널린 빨래들이 있다. 책상도 너무 더러워서 사실 여기서 찍긴 어렵긴 하지, 음음. + +이틀 뒤면 크라우드 서플라이 펀딩이 끝이 난다. 그 전에 스페인 쪽 인플루언서분이 인스타 콘텐츠 업로드해줄 수 있으면 좋겠는데… 안 되려나. 그러면 사실 그 사람한테는 보낸 이유가 없고 너무 아깝다. 그것도 그냥 커뮤니티 사람한테 줄 걸 그랬나. 사실 그것도 좋은 생각은 아닐지도 모른다. 처음부터 직접 만든 사람이 확실히 더 잘 쓰더라. 단순히 기능을 이해하고 활용하는 게 아니라, 애초에 거기에 에너지를 쓸 의지가 많이 차이 나는 듯하다. 완제품을 보내주면… 분명 웹사이트나 어딘가에 정리되어 있는데, 보고도 이해하려 노력하지 않고 나에게 바로 물어본다. 조금, 아니 많이 귀찮고 서운하다. + +펀딩액을 정산받고 그 돈으로 3D 프린터를 사서 제작에 들어가야 하는데, 둘 공간이 생길지 아직 불확실하다. 그놈의 원룸은 아직도 공사를 안 한다. 오늘도 점심에 학식 먹고 집 돌아오는 길에 부동산 들렀다 왔다. 거의 일주일마다 들르고 있는데, 매번 곧 연락하고 공사한 다음에 알려주겠다고만 한다. 이번에는 그래도 오후에 바로 연락해보겠다고 구체적으로 말했으니 지키면 좋겠다. 베란다 있고 가격 괜찮은 집이 거기 말곤 없어서… 포기하긴 아깝다. + +시각장애인 작가 전시 철거 날에 패턴플로우를 회수하러 갔었다. 작가분이 조력자의 도움을 받아 패턴플로우 앞에 서서 체험할 때 조마조마했다. 그치만 예상외로 정말 재밌어하시더라. 시각적인 요소가 없더라도 소리는 있으니 그것만으로도 충분했던 걸까. 아니면 LED 빛이 세긴 하니까 밝아졌다 어두워졌다나 색상 같은 큰 변화들은 볼 수 있었던 걸까. 물어보진 않아서 모르겠지만, 재밌다고 한동안 가지고 노시는 모습을 볼 땐 많이 뿌듯했다. 시각장애인용 악기를 만들어도 꽤나 반응이 좋을 것 같다. + +
+ 전시장에서 패턴플로우를 들고 있는 모습 +
서울역 전시, 철거 전
+
+ +한 7개인가 8개인가 패턴플로우 부품들을 구매했고, 배송을 기다리는 중이다. 곧 할 예정인 워크숍을 위함이다. 구체적인 계획은 사실 없다. 수요가 있을지도 모르겠다. 근데 뭐, 해보는 거지. + +이만하면 생각나는 것들은 전부 적은 것 같다. diff --git a/web/next.config.ts b/web/next.config.ts index 56ad253e..0fd9de6f 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -79,8 +79,9 @@ const nextConfig: NextConfig = { async redirects() { return [ // The shelf was /variants until 2026-09. The word the docs settled on is - // "edition" (docs/EDITIONS.md); the old URL is baked into every 3.8.0 - // console page and printed in release notes, so it stays forever. + // "edition" (docs/EDITIONS.md); the old URL is baked into every console + // page from 3.7.0 to 3.8.0 and printed in release notes, so it stays + // forever. { source: "/variants", destination: "/editions", permanent: true }, // There is no /flash page: the flasher is a button (the Pattern tab, the // Play guide), and /flash only holds its manifest and images. But the @@ -89,6 +90,16 @@ const nextConfig: NextConfig = { // to the guide's Flash chapter, where the button and the steps are. Only // the exact path: /flash/manifest.json and /flash/bin/* are untouched. { source: "/flash", destination: "/guide/play#flash", permanent: false }, + // From the journal's first post on 29 April 2026 until 11 May, its own + // links asked for English with ?lang=en. English moved to /en paths that + // day (c44b0d7a), and these two send the old links there. The query goes + // along, and proxy.ts takes it off and sets the language cookie. + // + // The first one reads /journal/en?lang=en as a post called "en" and + // answers /journal/en/en, which proxy.ts collapses back to /journal/en. + // /journal/en?lang=en is where the second one sends /journal?lang=en, + // and the language switch on the Korean index has linked it since + // 17 May. { source: "/journal/:slug", has: [{ type: "query", key: "lang", value: "en" }], diff --git a/web/package-lock.json b/web/package-lock.json index 9a994412..6337a046 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,10 +25,10 @@ "feed": "^5.2.1", "gray-matter": "^4.0.3", "mediabunny": "^1.55.2", - "next": "16.3.0", + "next": "16.4.1", "posthog-js": "^1.371.4", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "19.3.1", + "react-dom": "19.3.1", "react-markdown": "^10.1.0", "reading-time": "^1.5.0", "remark-gfm": "^4.0.1", @@ -47,7 +47,7 @@ "drizzle-kit": "^0.31.10", "esbuild": "^0.28.2", "eslint": "^9", - "eslint-config-next": "16.3.0", + "eslint-config-next": "16.4.1", "jsdom": "^26.1.0", "tailwindcss": "^4", "tsx": "^4.20.6", @@ -346,43 +346,39 @@ } }, "node_modules/@better-auth/core": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.23.tgz", - "integrity": "sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.9.tgz", + "integrity": "sha512-ZwkJRJOG0NkSoLudyDdy7PZUjpMxj5T5/AtNN6axJCwCXMEWYF1IX36yJJC2T0hIvrm/ORngDHd5QXh9DRRm2A==", "license": "MIT", "dependencies": { - "@opentelemetry/semantic-conventions": "^1.39.0", + "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", - "zod": "^4.3.6" + "zod": "^4.5.4" }, "peerDependencies": { - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1", - "@cloudflare/workers-types": ">=4", + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "@better-fetch/fetch": "1.3.2", "@opentelemetry/api": "^1.9.0", - "better-call": "1.3.7", + "better-call": "1.4.2", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - }, "@opentelemetry/api": { "optional": true } } }, "node_modules/@better-auth/drizzle-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.23.tgz", - "integrity": "sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.9.tgz", + "integrity": "sha512-pjKw09MM/RftcSLIPuvW9Xz7PlMoKZE9tXEbnNqsv2cqZQvEJKrCPSYi32d3CCIWj7509Kccll1v0AyV8K05GQ==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", - "@better-auth/utils": "0.4.2", - "drizzle-orm": "^0.45.2" + "@better-auth/core": "^1.7.9", + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "peerDependenciesMeta": { "drizzle-orm": { @@ -391,13 +387,13 @@ } }, "node_modules/@better-auth/kysely-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.23.tgz", - "integrity": "sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.9.tgz", + "integrity": "sha512-e5pHavuUGIFTgboE7/j7dxq4HVkbuEOdLo2y3KZCeH5RjDdiYt7gReqq001kyHJSKwxv6xLIjgpoVZq/tirFpA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", - "@better-auth/utils": "0.4.2", + "@better-auth/core": "^1.7.9", + "@better-auth/utils": "^0.4.2 || ^0.5.0", "kysely": "^0.28.17 || ^0.29.0" }, "peerDependenciesMeta": { @@ -407,23 +403,23 @@ } }, "node_modules/@better-auth/memory-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.23.tgz", - "integrity": "sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.9.tgz", + "integrity": "sha512-UoPx/V4Ddj/fjlILXRfcQNXutHWqoKqrC2z8jHqvCrfdwpytxIrp+A1CcG3vXjZWHIynLK34+AcIjvwMEQLNwg==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", - "@better-auth/utils": "0.4.2" + "@better-auth/core": "^1.7.9", + "@better-auth/utils": "^0.4.2 || ^0.5.0" } }, "node_modules/@better-auth/mongo-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.23.tgz", - "integrity": "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.9.tgz", + "integrity": "sha512-gEqaFxOW5QUZswgPdBL/JqunhruTF6wG1xbPilNXyVd8ZpbeMstyDewuLmTgOeI20M6zEWC4b7BURVU91Gg7+A==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", - "@better-auth/utils": "0.4.2", + "@better-auth/core": "^1.7.9", + "@better-auth/utils": "^0.4.2 || ^0.5.0", "mongodb": "^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { @@ -433,13 +429,13 @@ } }, "node_modules/@better-auth/prisma-adapter": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.23.tgz", - "integrity": "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.9.tgz", + "integrity": "sha512-L/6Bq1hLjBhexUa4JSk7pDYNDuGksuiedWYnyvJc0alzdYavk0pAWvWWt/WI3DvblBr6YUIbgr3HqeHvVueUrA==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", - "@better-auth/utils": "0.4.2", + "@better-auth/core": "^1.7.9", + "@better-auth/utils": "^0.4.2 || ^0.5.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, @@ -453,29 +449,29 @@ } }, "node_modules/@better-auth/telemetry": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.23.tgz", - "integrity": "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.9.tgz", + "integrity": "sha512-OaHj8vM6rQYrdONCiPMMyUHGIx3BaEUNfHHXEmpukcZFboAWfRYTRm0GVt+O8Sz3+rgIUBWiY+/H7SehCFA3dQ==", "license": "MIT", "peerDependencies": { - "@better-auth/core": "^1.6.23", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1" + "@better-auth/core": "^1.7.9", + "@better-auth/utils": "^0.4.2 || ^0.5.0", + "@better-fetch/fetch": "1.3.2" } }, "node_modules/@better-auth/utils": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", - "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", "license": "MIT", "dependencies": { "@noble/hashes": "^2.0.1" } }, "node_modules/@better-fetch/fetch": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", - "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.2.tgz", + "integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==", "license": "MIT" }, "node_modules/@csstools/color-helpers": { @@ -1575,9 +1571,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", "dev": true, "license": "MIT", "dependencies": { @@ -1587,7 +1583,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -1599,9 +1595,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1636,9 +1632,9 @@ } }, "node_modules/@google/genai": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.9.0.tgz", - "integrity": "sha512-3DRdSJ0LaKFig3FNGeRDn9BQxtjZm2qr0hNH2d771LKcG0HvUYddlN0LPdSp8XU7Ekb04i9q3+tt64uYqavUdw==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.30.0.tgz", + "integrity": "sha512-Gkv1ix8iZey/V3WF4lHaRS8C71P533jenDT/lA+fdPTCfXYfmCVHvo+0H5jTgzonqsmROQG7QpzLJkpoP/x7bg==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1736,9 +1732,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -1754,13 +1750,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -1776,20 +1772,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1799,9 +1795,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -1815,9 +1811,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -1831,9 +1827,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -1847,9 +1843,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -1863,9 +1859,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -1879,9 +1875,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -1895,9 +1891,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -1911,9 +1907,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -1927,9 +1923,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -1943,9 +1939,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -1959,9 +1955,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -1977,13 +1973,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -1999,13 +1995,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -2021,13 +2017,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -2043,13 +2039,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -2065,13 +2061,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -2087,13 +2083,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -2109,13 +2105,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -2131,17 +2127,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -2151,16 +2147,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2170,9 +2166,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -2189,9 +2185,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -2208,9 +2204,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -2426,15 +2422,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", - "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.1.tgz", + "integrity": "sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.0.tgz", - "integrity": "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.1.tgz", + "integrity": "sha512-2io6M+wAqe7nT5mt2gEVy6FOzye2DmwhjaYCpSvHfrIKL11SXNTwSARxpPHJacRmfgSBSDnNmnEXSp0DaJ23BQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2443,9 +2439,9 @@ } }, "node_modules/@next/mdx": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-16.2.4.tgz", - "integrity": "sha512-e/3bgla+/oF3vDlndI0eFPa0bnP47HPVA0InsAJi7Jr3DwV8WpEGuOcm/3PdI5/93FfNiBhMVeVHZpm1sFlmJw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-16.4.1.tgz", + "integrity": "sha512-OAP3HzsTIdII5S9hPjsLuGaJYYXevy9clIxXKp26xcCGqB1cahIKF5JQwDuZQQ6OBNPORDPw3VcDzJBWBsHz6A==", "license": "MIT", "dependencies": { "source-map": "^0.7.0" @@ -2464,9 +2460,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", - "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.1.tgz", + "integrity": "sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==", "cpu": [ "arm64" ], @@ -2480,9 +2476,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", - "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.1.tgz", + "integrity": "sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==", "cpu": [ "x64" ], @@ -2496,9 +2492,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", - "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.1.tgz", + "integrity": "sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==", "cpu": [ "arm64" ], @@ -2512,9 +2508,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", - "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.1.tgz", + "integrity": "sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==", "cpu": [ "arm64" ], @@ -2528,9 +2524,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", - "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.1.tgz", + "integrity": "sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==", "cpu": [ "x64" ], @@ -2544,9 +2540,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", - "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.1.tgz", + "integrity": "sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==", "cpu": [ "x64" ], @@ -2560,9 +2556,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", - "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.1.tgz", + "integrity": "sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==", "cpu": [ "arm64" ], @@ -2576,9 +2572,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", - "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.1.tgz", + "integrity": "sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==", "cpu": [ "x64" ], @@ -2604,9 +2600,9 @@ } }, "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -2668,260 +2664,44 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", - "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", - "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.208.0", - "@opentelemetry/otlp-transformer": "0.208.0", - "@opentelemetry/sdk-logs": "0.208.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", - "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.208.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", - "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.208.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.0.tgz", - "integrity": "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", - "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", - "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">=14" } }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", + "node_modules/@posthog/browser-common": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.5.tgz", + "integrity": "sha512-oXnBgZvM8UQ84IcBLfGP0WSGd9rA0PCGR37H8Pw0NszJRwxwbJnFXIUih0pvI/ze2eGmzkIEC9i3SwC+bMAgjQ==", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "@posthog/core": "^1.59.1", + "@posthog/types": "^1.416.0" } }, "node_modules/@posthog/core": { - "version": "1.27.3", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.27.3.tgz", - "integrity": "sha512-DFDKJ8tpuIZ3CNsoui+xCZsZqWDbaIBe1Kt0BNXQNWedkZ1AgO4shx11n9RkTmHGv1evObhhTDLmn0n4BwGzhw==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.59.1.tgz", + "integrity": "sha512-P39YgzQVDh6oz6wAzQXEnoHgTYpM7gifonmVKq1scAJco79h31XmmNVurRq9tTmmyIrr1uX8FHT0riCsyfV9AQ==", "license": "MIT", "dependencies": { - "@posthog/types": "1.371.4" + "@posthog/types": "^1.416.0" } }, "node_modules/@posthog/types": { - "version": "1.371.4", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.371.4.tgz", - "integrity": "sha512-6jpn4ObQQVmMJTobJuWEaX1pjv0FtsAjhqzs0gFYbssGVsITl4TNjTfvdia07ILk1BsL4wELeMtd9peQyxkK7w==", + "version": "1.416.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.416.0.tgz", + "integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==", "license": "MIT" }, "node_modules/@protobufjs/aspromise": { @@ -2989,9 +2769,9 @@ "license": "BSD-3-Clause" }, "node_modules/@react-three/drei": { - "version": "10.7.7", - "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", - "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "version": "10.7.9", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.9.tgz", + "integrity": "sha512-wP4Y6o58KFi+hT1zCfjJarRtPCT6WW/lo53WMiA/aCORuUvBzW916XsCtadnIB1KB+3IQNcDU5JeVh/gYb8MYw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", @@ -3029,18 +2809,18 @@ } }, "node_modules/@react-three/fiber": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", - "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", + "version": "9.8.2", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.8.2.tgz", + "integrity": "sha512-6s0ejw0bT1myQ/aLLugcil8/zBmNnnHw/AISWF1yfDLOxfX3yYaTS+0IkpJITEnm/SSMLtxje6CJDt0i5Wu1Sw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", - "its-fine": "^2.0.0", + "its-fine": "^2.1.1", "react-use-measure": "^2.1.7", - "scheduler": "^0.27.0", + "scheduler": "^0.28.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" @@ -3050,8 +2830,8 @@ "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", - "react": ">=19 <19.3", - "react-dom": ">=19 <19.3", + "react": ">=19 <19.4", + "react-dom": ">=19 <19.4", "react-native": ">=0.78", "three": ">=0.156" }, @@ -3077,31 +2857,21 @@ } }, "node_modules/@react-three/postprocessing": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz", - "integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.2.0.tgz", + "integrity": "sha512-VSwaG1cimKOF3d4LWtR5BumcI4k1B+CJ8HfbeJ7msuEgtZiOnkNBGyLfRQtai3evrup+9QNRbNIIZ4UsuyEh9A==", "license": "MIT", "dependencies": { - "maath": "^0.6.0", - "n8ao": "^1.9.4", - "postprocessing": "^6.36.6" + "maath": "^0.10.8", + "n8ao": "^2.0.0" }, "peerDependencies": { - "@react-three/fiber": "^9.0.0", - "react": "^19.0", + "@react-three/fiber": ">=9.7.0", + "postprocessing": "^6.36.0", + "react": "^19.0.0", "three": ">= 0.156.0" } }, - "node_modules/@react-three/postprocessing/node_modules/maath": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz", - "integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==", - "license": "MIT", - "peerDependencies": { - "@types/three": ">=0.144.0", - "three": ">=0.144.0" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -3466,58 +3236,58 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" } }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -3532,9 +3302,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -3549,9 +3319,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -3566,9 +3336,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -3583,9 +3353,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -3600,9 +3370,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -3617,9 +3387,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -3634,9 +3404,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -3651,9 +3421,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -3668,9 +3438,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3686,11 +3456,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -3698,9 +3468,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -3715,9 +3485,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -3732,23 +3502,23 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", - "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "postcss": "^8.5.6", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" } }, "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3972,9 +3742,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3987,22 +3757,22 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/react-reconciler": { @@ -4873,9 +4643,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5214,38 +4984,37 @@ } }, "node_modules/better-auth": { - "version": "1.6.23", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.23.tgz", - "integrity": "sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.6.23", - "@better-auth/drizzle-adapter": "1.6.23", - "@better-auth/kysely-adapter": "1.6.23", - "@better-auth/memory-adapter": "1.6.23", - "@better-auth/mongo-adapter": "1.6.23", - "@better-auth/prisma-adapter": "1.6.23", - "@better-auth/telemetry": "1.6.23", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1", - "@noble/ciphers": "^2.1.1", - "@noble/hashes": "^2.0.1", - "better-call": "1.3.7", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.9.tgz", + "integrity": "sha512-qJ+s5GxLKalCKzABtIFzhI9V5IWJ3vuTKnklU+bwIqzs08BZSeIVoli/py8UpCn2vbKmQ6d8i7mMPiqoc3z0+w==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.9", + "@better-auth/drizzle-adapter": "1.7.9", + "@better-auth/kysely-adapter": "1.7.9", + "@better-auth/memory-adapter": "1.7.9", + "@better-auth/mongo-adapter": "1.7.9", + "@better-auth/prisma-adapter": "1.7.9", + "@better-auth/telemetry": "1.7.9", + "@better-auth/utils": "0.5.0", + "@better-fetch/fetch": "1.3.2", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", + "better-call": "1.4.2", "defu": "^6.1.4", - "jose": "^6.1.3", + "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", - "nanostores": "^1.1.1", - "zod": "^4.3.6" + "nanostores": "^1.3.0", + "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "@sveltejs/kit": "^2.0.0", + "@sveltejs/kit": "^2.0.0 || ^3.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", - "better-sqlite3": "^12.0.0", - "drizzle-kit": ">=0.31.4", - "drizzle-orm": "^0.45.2", + "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", @@ -5255,7 +5024,7 @@ "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", - "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "peerDependenciesMeta": { @@ -5274,9 +5043,6 @@ "@tanstack/solid-start": { "optional": true }, - "better-sqlite3": { - "optional": true - }, "drizzle-kit": { "optional": true }, @@ -5319,15 +5085,15 @@ } }, "node_modules/better-call": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", - "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.2.tgz", + "integrity": "sha512-lOmFCTG1KcE60YWxoff2Wx0Fv7v7drP/fQTDYnhJUCdkA7V6QWXdLPUiMpOQCq7Fd0Ts3AkrRwD23xoiootlGA==", "license": "MIT", "dependencies": { - "@better-auth/utils": "^0.4.0", - "@better-fetch/fetch": "^1.1.21", - "rou3": "^0.7.12", - "set-cookie-parser": "^3.0.1" + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" @@ -6134,9 +5900,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", - "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -6149,9 +5915,9 @@ "license": "Apache-2.0" }, "node_modules/drizzle-kit": { - "version": "0.31.10", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", - "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "version": "0.31.11", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.11.tgz", + "integrity": "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -6622,9 +6388,9 @@ } }, "node_modules/drizzle-orm": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", - "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "version": "0.45.4", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.4.tgz", + "integrity": "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", @@ -6633,6 +6399,7 @@ "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", @@ -6675,6 +6442,9 @@ "@neondatabase/serverless": { "optional": true }, + "@netlify/db": { + "optional": true + }, "@op-engineering/op-sqlite": { "optional": true }, @@ -6794,14 +6564,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -7101,9 +6871,10 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { @@ -7112,8 +6883,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -7161,21 +6932,21 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.0.tgz", - "integrity": "sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.1.tgz", + "integrity": "sha512-6AsojbCfRdpWkP6BIUnMBv3MH4OvtfmH9p4hSfLh8ZgsW7nfhu7DpUMNBB7leisDyyMz+YYxTc+NAdBOQfovlA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.0", + "@next/eslint-plugin-next": "16.4.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", + "eslint-plugin-react-hooks": "^7.1.0", "globals": "16.4.0", - "typescript-eslint": "^8.46.0" + "typescript-eslint": "^8.56.0" }, "peerDependencies": { "eslint": ">=9.0.0", @@ -7708,9 +7479,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -9045,9 +8816,9 @@ } }, "node_modules/its-fine": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", - "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.1.1.tgz", + "integrity": "sha512-C5ZoVkeqSt0Aje0tzESO+iT/EQpNFNL39GkP7mCACtoZUFD6GcyerFCr6kp0ZtiII4W9HoYkY48Soy5xE/uHKw==", "license": "MIT", "dependencies": { "@types/react-reconciler": "^0.28.9" @@ -9057,9 +8828,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -9067,9 +8838,9 @@ } }, "node_modules/jose": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", - "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -9083,10 +8854,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10004,9 +9785,9 @@ } }, "node_modules/mediabunny": { - "version": "1.55.2", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.55.2.tgz", - "integrity": "sha512-EEx4O6qYddAdCyWPMZNDwI7uc5hewNHrPAf9jLcVhIbXoPsiqNQ+D9i1pfadmGkjN2V318jSrZljkpoziYm6Lg==", + "version": "1.61.4", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.61.4.tgz", + "integrity": "sha512-gZPBi40ldiZGeU2DJxT/Q3hlNiaxfpwNYTfmi0Maq3SoBJT64+W+ySc0Z7t+2JUGxNbkXnLFrDkSS2OQuweOhA==", "license": "MPL-2.0", "workspaces": [ ".", @@ -10855,9 +10636,9 @@ "license": "MIT" }, "node_modules/n8ao": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz", - "integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-2.0.1.tgz", + "integrity": "sha512-MoP1QKUdj4/3cKEnhFl917t0eQwWGER1l43ckz5mNOPyYWGRDGQbVoTOQz/VBvy01in6QVNG+AL6Xxx5AjZBYA==", "license": "ISC", "peerDependencies": { "postprocessing": ">=6.30.0", @@ -10883,9 +10664,9 @@ } }, "node_modules/nanostores": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.4.1.tgz", - "integrity": "sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.5.tgz", + "integrity": "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==", "funding": [ { "type": "github", @@ -10927,13 +10708,13 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", - "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.1.tgz", + "integrity": "sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0", - "@swc/helpers": "0.5.15", + "@next/env": "16.4.1", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", @@ -10946,15 +10727,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.0", - "@next/swc-darwin-x64": "16.3.0", - "@next/swc-linux-arm64-gnu": "16.3.0", - "@next/swc-linux-arm64-musl": "16.3.0", - "@next/swc-linux-x64-gnu": "16.3.0", - "@next/swc-linux-x64-musl": "16.3.0", - "@next/swc-win32-arm64-msvc": "16.3.0", - "@next/swc-win32-x64-msvc": "16.3.0", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.4.1", + "@next/swc-darwin-x64": "16.4.1", + "@next/swc-linux-arm64-gnu": "16.4.1", + "@next/swc-linux-arm64-musl": "16.4.1", + "@next/swc-linux-x64-gnu": "16.4.1", + "@next/swc-linux-x64-musl": "16.4.1", + "@next/swc-win32-arm64-msvc": "16.4.1", + "@next/swc-win32-x64-msvc": "16.4.1", + "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -11439,24 +11220,33 @@ } }, "node_modules/posthog-js": { - "version": "1.371.4", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.371.4.tgz", - "integrity": "sha512-ECyI1AlynGmLzKyXH3g8SHOSxCnpGbnwi5NRU98TOKrAY4NDCUj6+cxrb4XwFeXkZoXk54DV5sPqjnflAUNN3g==", - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/api-logs": "^0.208.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", - "@opentelemetry/resources": "^2.2.0", - "@opentelemetry/sdk-logs": "^0.208.0", - "@posthog/core": "1.27.3", - "@posthog/types": "1.371.4", - "core-js": "^3.38.1", - "dompurify": "^3.3.2", + "version": "1.438.10", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.438.10.tgz", + "integrity": "sha512-ShXOXaZ7gtqDAMvvJjWS7Wq+Sh62pedp+6gzeyVKKKCBEmd0tmh0LSYRVj8X7WzFB7QMNdl+FlhrKyZ2XvUBUg==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.9.5", + "@posthog/core": "^1.59.1", + "@posthog/types": "^1.416.0", + "core-js": "^3.49.0", + "dompurify": "^3.4.13", "fflate": "^0.4.8", - "preact": "^10.28.2", + "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^5.1.0" + "web-vitals": "^6.2.1", + "web-vitals-soft-navs": "npm:web-vitals@6.2.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } } }, "node_modules/posthog-js/node_modules/fflate": { @@ -11466,12 +11256,13 @@ "license": "MIT" }, "node_modules/postprocessing": { - "version": "6.39.1", - "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.1.tgz", - "integrity": "sha512-R2dG2zy+BAx3USl5EHw+PvnrlbT5PKnZVp3se0HCR0pWH8WQdh742yNG4YWOsq6c0bFpffk0Gd2RqPeoP/wKng==", + "version": "6.39.5", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.5.tgz", + "integrity": "sha512-YmH+t74J2X1lwYjKzK/tgs9C9kqWWPB9JwZiDF5mEf4BdCEAmEFbO8lXBA7NM0/j4g+x6FgD/Tt5/kT7ogbEDA==", "license": "Zlib", + "peer": true, "peerDependencies": { - "three": ">= 0.168.0 < 0.185.0" + "three": ">= 0.168.0 < 0.187.0" } }, "node_modules/potpack": { @@ -11481,13 +11272,21 @@ "license": "ISC" }, "node_modules/preact": { - "version": "10.29.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", - "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } } }, "node_modules/prebuild-install": { @@ -11690,24 +11489,24 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.3.1" } }, "node_modules/react-is": { @@ -12119,9 +11918,9 @@ } }, "node_modules/rou3": { - "version": "0.7.12", - "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", - "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", "license": "MIT" }, "node_modules/rrweb-cssom": { @@ -12260,9 +12059,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/section-matter": { @@ -12289,9 +12088,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", - "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", + "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", "license": "MIT" }, "node_modules/set-function-length": { @@ -12344,9 +12143,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -12361,31 +12160,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -12962,9 +12761,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -13295,9 +13094,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -14466,9 +14265,16 @@ } }, "node_modules/web-vitals": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz", - "integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz", + "integrity": "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", + "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", "license": "Apache-2.0" }, "node_modules/webgl-constants": { @@ -14738,9 +14544,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -14760,9 +14566,9 @@ } }, "node_modules/zustand": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", - "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/web/package.json b/web/package.json index d05a1187..6bbaf026 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p ../tools/virtual-panel", "worker": "tsx scripts/build-worker.ts", "build:enqueue": "tsx scripts/enqueue-test-build.ts", "build:status": "tsx scripts/build-status.ts", @@ -39,9 +39,30 @@ "check:pfstvectors": "tsx scripts/pfst-v2-vectors-smoke.ts", "check:ramp": "tsx scripts/ramp-oklab-smoke.ts", "check:panels": "vitest run", - "check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:workshop && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache", + "check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:workshop && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache && npm run check:drift", "build:sandbox": "tsx scripts/build-sandbox.ts", - "check:sandbox-sync": "tsx scripts/build-sandbox.ts --check" + "check:sandbox-sync": "tsx scripts/build-sandbox.ts --check", + "gen:shared": "tsx scripts/gen-shared.ts", + "doc-map": "tsx scripts/doc-map.ts", + "check:drift": "tsx scripts/drift.ts", + "check:links-absolute": "tsx scripts/drift.ts links-absolute", + "check:bom": "tsx scripts/drift.ts bom", + "check:bom-schematic": "tsx scripts/drift.ts bom-schematic", + "check:features": "tsx scripts/drift.ts features", + "check:editions": "tsx scripts/drift.ts editions", + "check:presets": "tsx scripts/drift.ts presets", + "check:roadmap": "tsx scripts/drift.ts roadmap", + "check:changelog": "tsx scripts/drift.ts changelog", + "check:shared": "tsx scripts/drift.ts shared", + "check:rest-vectors": "tsx scripts/drift.ts rest-vectors", + "check:mqtt-vectors": "tsx scripts/drift.ts mqtt-vectors", + "check:osc-vectors": "tsx scripts/drift.ts osc-vectors", + "check:midi-vectors": "tsx scripts/drift.ts midi-vectors", + "check:audio-vectors": "tsx scripts/drift.ts audio-vectors", + "check:virtual-panel": "tsx scripts/drift.ts virtual-panel", + "virtual-panel": "tsx ../tools/virtual-panel/main.ts", + "check:human-lock": "tsx scripts/human-lock.ts", + "check:voice": "tsx scripts/voice-lint.ts" }, "dependencies": { "@google/genai": "^2.9.0", @@ -61,10 +82,10 @@ "feed": "^5.2.1", "gray-matter": "^4.0.3", "mediabunny": "^1.55.2", - "next": "16.3.0", + "next": "16.4.1", "posthog-js": "^1.371.4", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "19.3.1", + "react-dom": "19.3.1", "react-markdown": "^10.1.0", "reading-time": "^1.5.0", "remark-gfm": "^4.0.1", @@ -83,7 +104,7 @@ "drizzle-kit": "^0.31.10", "esbuild": "^0.28.2", "eslint": "^9", - "eslint-config-next": "16.3.0", + "eslint-config-next": "16.4.1", "jsdom": "^26.1.0", "tailwindcss": "^4", "tsx": "^4.20.6", diff --git a/web/public/journal/fine/interview.jpg b/web/public/journal/fine/interview.jpg new file mode 100644 index 00000000..d7996109 Binary files /dev/null and b/web/public/journal/fine/interview.jpg differ diff --git a/web/public/journal/fine/seoul-station-exhibition.jpg b/web/public/journal/fine/seoul-station-exhibition.jpg new file mode 100644 index 00000000..142f3d56 Binary files /dev/null and b/web/public/journal/fine/seoul-station-exhibition.jpg differ diff --git a/web/public/pattern-sandbox.html b/web/public/pattern-sandbox.html index aae0aac8..28d5cdcf 100644 --- a/web/public/pattern-sandbox.html +++ b/web/public/pattern-sandbox.html @@ -19,7 +19,7 @@ diff --git a/web/scripts/doc-map.ts b/web/scripts/doc-map.ts new file mode 100644 index 00000000..ff718c7d --- /dev/null +++ b/web/scripts/doc-map.ts @@ -0,0 +1,456 @@ +/** + * The doc map: every link into this repository, and whether it lands. + * `npm run doc-map` in web/ writes docs/investigations/2026-10-doc-map.csv, + * and beside it the parity list, docs/investigations/2026-10-doc-parity.csv. + * + * Moving a file breaks the links that point at it, and only one kind of link + * is watched: the relative ones in Markdown (.github/scripts/check_links.py). + * The site points at the guides by absolute GitHub URL. The console pages the + * device serves point at docs and at the site. The redirect table sends old + * URLs to new ones. None of those fail a build when the file they name moves, + * so the cleanup (docs/rfc-cleanup.md §2.2) maps them before it moves anything. + * + * Sources are the tracked .md, .mdx, .ts, .tsx and .h files, the console + * pages, the .yml and .yaml files under .github/ (the issue forms and their + * config.yml link the repository by absolute URL), and the redirect table in + * next.config.ts. Vendored upstream code is skipped, as check_links.py skips + * it, and so are the *_index.h headers, which are the console pages again. + * Kinds: + * + * relative a path in Markdown, resolved against the file it is in + * absolute-blob github.com//Patternflow/blob|tree// + * absolute-raw raw.githubusercontent.com/..., or .../raw// + * site patternflow.work/, a root-relative Markdown link, or + * a redirect destination; checked against web/src/app, + * web/public and the redirect and rewrite sources + * console a root-relative link in a console page; checked against + * the paths the firmware registers with .on("/path") + * + * An anchor into a Markdown file has to match one of its headings, slugged + * the way GitHub slugs them. A ref other than main is read with git, and a + * shallow clone has no tags: run `git fetch --tags` first or those rows say no. + * + * `--check absolute` is check:links-absolute (docs/rfc-cleanup.md §2.4). It + * reads the same rows and writes nothing. It fails on an absolute row that does + * not resolve, and on a tracked file outside the sources above that carries a + * repository URL, since the map never reads that file. A row on a ref the clone + * lacks is skipped, and the check says which refs. With PF_DRIFT_STRICT=1 a + * skip fails as well. + * + * The parity list (docs/rfc-cleanup.md §2.3) is every text the repository + * keeps in both Korean and English, one row per pair, and whether a side is + * missing or short. Kinds: + * + * file a file beside its twin named for the other language: x.ko.md + * beside x.md, a journal post's x.en.mdx beside x.mdx + * route a page under web/src/app beside its ko/ or en/ twin; the row only + * says both exist, since the words are the copy rows + * copy an object in web/src with `en` and `ko`, walked side by side + * field a field in web/src beside its Ko or En twin: title/titleKo + * (bare English), nm/nmEn (bare Korean) + * + * The three kinds §2.3 names (x.md beside x.ko.md, console page pairs, Ko + * fields in features-data.ts) do not exist yet; when one lands it is a file or + * a field row and needs no change here. Copy a component picks with + * a ternary on the language is not read. en_chars and ko_chars count the + * characters that are not whitespace once markup is stripped; how state + * weighs them is at PER_SYLLABLE below. Nothing is translated: the list is + * the deliverable. + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); +const OUT = "docs/investigations/2026-10-doc-map.csv"; +const PARITY = "docs/investigations/2026-10-doc-parity.csv"; +const CONSOLE = "firmware/patternflow/console/"; +const REDIRECTS = "web/next.config.ts"; +const SKIP = /(^|\/)(hub75|webserver|pubsubclient)\//; +const CHECK = process.argv.includes("--check") ? (process.argv[process.argv.indexOf("--check") + 1] ?? "") : null; +if (CHECK !== null && CHECK !== "absolute") { + console.error(`doc-map: --check takes "absolute", not "${CHECK}"`); + process.exit(2); +} + +const git = (args: string[]) => + execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28, stdio: ["ignore", "pipe", "ignore"] }); +const tracked = git(["ls-files", "-z"]).split("\0").filter(Boolean); +const files = new Set(tracked); +const dirs = new Set([""]); +for (const f of tracked) for (let d = path.posix.dirname(f); d !== "."; d = path.posix.dirname(d)) dirs.add(d); + +// ── What a link can land on ── + +const read = (f: string) => fs.readFileSync(path.join(ROOT, f), "utf8"); +const attempt = (f: () => T, fallback: T): T => { + try { + return f(); + } catch { + return fallback; + } +}; +const decode = (p: string) => attempt(() => decodeURIComponent(p), p); +const trim = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p); + +const atRef = new Map(); +function contentAt(ref: string, p: string): string | null { + if (ref === "main" || ref === "HEAD") return files.has(p) ? read(p) : dirs.has(p) ? "" : null; + const key = `${ref}:${p}`; + if (!atRef.has(key)) atRef.set(key, attempt(() => git(["cat-file", "-p", key]), null)); + return atRef.get(key) ?? null; +} + +// GitHub's heading ids: rendered text, lower-cased, punctuation dropped, +// spaces to hyphens, a repeat numbered -1, -2. Plus any explicit id= or name=. +function anchorsOf(md: string): Set { + const out = new Set(); + const seen = new Map(); + for (const line of md.replace(/```[\s\S]*?```/g, "").split("\n")) { + const h = /^ {0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line); + if (!h) continue; + const text = h[1].replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*`~]/g, ""); + const base = text.toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "").replace(/ /g, "-"); + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + out.add(n ? `${base}-${n}` : base); + } + for (const m of md.matchAll(/\b(?:id|name)=["']([^"']+)["']/g)) out.add(m[1].toLowerCase()); + return out; +} + +// A route or redirect source as a regex; [[...x]], [...x], [x] and :x are its parameters. +const pattern = (route: string) => { + const marked = route.replace(/\[\[\.\.\.\w+\]\]/g, "\u0001").replace(/\[\.\.\.\w+\]/g, "\u0002").replace(/\[\w+\]|:\w+\*?/g, "\u0003"); + const escaped = marked.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\/?\u0001/g, "(?:/.*)?"); + return new RegExp(`^${escaped.replace(/\u0002/g, ".+").replace(/\u0003/g, "[^/]+")}$`); +}; + +const publicFiles = new Set(tracked.filter((f) => f.startsWith("web/public/")).map((f) => f.slice("web/public".length))); +const siteRoutes = [pattern("/sitemap.xml"), pattern("/robots.txt")]; +for (const f of tracked) { + const app = /^web\/src\/app(\/.*)?\/(?:page|route)\.(?:tsx?|jsx?|mdx?)$/.exec(f); + if (app) siteRoutes.push(pattern((app[1] ?? "").replace(/\/\([^/]+\)/g, "") || "/")); +} +// Redirect sources only: the headers() block has sources too, and they are not pages. +const redirects = read(REDIRECTS).split("async redirects()")[1]?.split("async rewrites()")[0] ?? ""; +for (const m of redirects.matchAll(/\bsource: "(\/[^"]*)"/g)) siteRoutes.push(pattern(m[1])); +for (const m of read("web/src/lib/campaignRoutes.ts").matchAll(/\bpath: "(\/[^"]*)"/g)) siteRoutes.push(pattern(m[1])); + +const deviceRoutes = new Set(); +for (const f of tracked.filter((f) => /^firmware\/patternflow\/.*\.(h|ino|cpp)$/.test(f))) + for (const m of read(f).matchAll(/\.on\(\s*"(\/[^"]*)"/g)) deviceRoutes.add(m[1]); + +// ── Reading links out of a file ── + +// ref: the branch or tag a file row was read at, which the CSV folds into `to`. +type Row = { from: string; to: string; kind: string; anchor: string; resolves: "yes" | "no"; ref?: string }; + +const REPO_URL = + /^https?:\/\/(?:www\.)?(?:github\.com\/(?:engmung|patternflow-lab)\/patternflow\/(blob|tree|raw|edit)|raw\.githubusercontent\.com\/(?:engmung|patternflow-lab)\/patternflow)\/([^/?#]+)\/?([^?#]*)/i; +const SITE_URL = /^https?:\/\/(?:www\.)?patternflow\.work(\/[^?#]*)?/i; +const URL_IN_TEXT = /https?:\/\/(?:www\.)?(?:github\.com|raw\.githubusercontent\.com|patternflow\.work)[^\s"'`<>)\]]*/gi; + +function classify(from: string, raw: string, markdown: boolean): Row | null { + const target = raw.replace(/^<|>$/g, "").replace(/[.,;:]+$/, ""); + if (target.includes("${")) return null; // a template the constants did not fill + const anchor = target.includes("#") ? decode(target.slice(target.indexOf("#") + 1)) : ""; + const row = (to: string, kind: string, ok: boolean): Row => ({ from, to, kind, anchor, resolves: ok ? "yes" : "no" }); + const fileRow = (ref: string, p: string, kind: string) => { + const body = contentAt(ref, p); + const ok = body !== null && (!anchor || !/\.mdx?$/.test(p) || /^L\d+/.test(anchor) || anchorsOf(body).has(anchor.toLowerCase())); + return { ...row(ref === "main" ? p : `${ref}:${p}`, kind, ok), ref }; + }; + const site = (p: string) => row(trim(p), "site", publicFiles.has(trim(p)) || siteRoutes.some((r) => r.test(trim(p)))); + + const repo = REPO_URL.exec(target); + if (repo) { + const kind = repo[1] && repo[1].toLowerCase() !== "raw" ? "absolute-blob" : "absolute-raw"; + const p = decode(repo[3]).replace(/\/$/, ""); + return p ? fileRow(repo[2], p, kind) : null; // the repository's front page, which no move breaks + } + const web = SITE_URL.exec(target); + if (web) return site(decode(web[1] ?? "/")); + if (/^[a-z][a-z0-9+.-]*:|^\/\/|^#/i.test(target)) return null; + const p = decode(target.split("#")[0].split("?")[0]); + if (from.startsWith(CONSOLE)) return p.startsWith("/") ? row(trim(p), "console", deviceRoutes.has(trim(p))) : null; + if (!markdown || !p) return null; + if (p.startsWith("/")) return site(p); + const local = path.posix.normalize(path.posix.join(path.posix.dirname(from), p)).replace(/\/$/, ""); + return local.startsWith("../") ? null : fileRow("main", local, "relative"); // ../../releases is GitHub's page, not a file +} + +// `const REPO = "https://github.com/…"`, `${REPO}/blob/main/x` and one-line +// helpers like `blob("x")` are how the site writes its GitHub links. +function expandConstants(text: string): string { + const consts = new Map(); + for (let pass = 0; pass < 3; pass++) { + for (const m of text.matchAll(/\bconst (\w+) = (["'`])(https?:\/\/[^"'`$]*)\2/g)) consts.set(m[1], m[3]); + text = text.replace(/\$\{(\w+)\}/g, (s, n: string) => consts.get(n) ?? s); + } + for (const m of text.matchAll(/\bconst (\w+) = \((\w+)(?:: string)?\) =>\s*`(https?:\/\/[^`$]*)\$\{\2\}`/g)) + text = text.replace(new RegExp(`\\b${m[1]}\\((["'])([^"']*)\\1\\)`, "g"), (_s, _q, arg: string) => m[3] + arg); + return text; +} + +function linksIn(f: string): Row[] { + const markdown = /\.mdx?$/.test(f); + let text = read(f); + const found: [string, boolean][] = []; + if (markdown) text = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, ""); + if (/\.tsx?$/.test(f)) text = expandConstants(text); + // In code, `a[i](x)` looks like a Markdown link; only a path that starts like one counts. + for (const m of text.matchAll(/\]\(\s*(<[^>]+>|[^)\s]+)/g)) found.push([m[1], markdown || /^\.{0,2}\//.test(m[1])]); + if (markdown) for (const m of text.matchAll(/^\s*\[(?!\^)[^\]]+\]:\s*(\S+)/gm)) found.push([m[1], true]); + for (const m of text.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) found.push([m[1], markdown]); + for (const m of text.matchAll(URL_IN_TEXT)) found.push([m[0], false]); + if (f === REDIRECTS) for (const m of text.matchAll(/\bdestination: "(\/[^"]*)"/g)) found.push([m[1], true]); + return found.map(([raw, md]) => classify(f, raw, md)).filter((r): r is Row => r !== null); +} + +// ── The map ── + +const sources = tracked.filter((f) => !SKIP.test(f) && (/\.(mdx?|tsx?)$/.test(f) || + (f.endsWith(".h") && !f.endsWith("_index.h")) || (f.startsWith(CONSOLE) && f.endsWith(".html")) || + (f.startsWith(".github/") && /\.ya?ml$/.test(f)))); +const key = (r: Row) => [r.from, r.to, r.anchor, r.kind].join("\0"); +const rows = new Map(); +for (const f of sources) for (const r of linksIn(f)) rows.set(key(r), r); + +const cell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v); +// Code-point order, not the locale's, so the file is the same on every machine. +const sorted = [...rows.values()].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0)); +const show = (r: Row) => ` ${r.from} → ${r.to}${r.anchor ? "#" + r.anchor : ""} (${r.kind})`; + +// ── The check ── + +if (CHECK) { + const strict = process.env.PF_DRIFT_STRICT === "1"; + const absolute = sorted.filter((r) => r.kind.startsWith("absolute-")); + const refOf = (r: Row) => r.ref ?? "main"; + const perRef = new Map(); + for (const r of absolute) perRef.set(refOf(r), (perRef.get(refOf(r)) ?? 0) + 1); + const lacking = new Set([...perRef.keys()].filter((ref) => + ref !== "main" && ref !== "HEAD" && !attempt(() => !!git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]), false))); + const unread = absolute.filter((r) => lacking.has(refOf(r))); + const broken = absolute.filter((r) => r.resolves === "no" && !lacking.has(refOf(r))); + + // A repository URL in a file the map does not read. The *_index.h headers + // are the console pages again, and vendored code is upstream's. + const mapped = new Set(sources); + const hits = attempt(() => git(["grep", "-z", "-l", "-I", "-i", "-E", + "github\\.com/(engmung|patternflow-lab)/patternflow/(blob|tree|raw|edit)/|raw\\.githubusercontent\\.com/(engmung|patternflow-lab)/patternflow/"]), ""); + const strays = hits.split("\0").filter((f) => f && !mapped.has(f) && !SKIP.test(f) && !f.endsWith("_index.h")) + .flatMap((f) => [...read(f).matchAll(URL_IN_TEXT)].map((m) => classify(f, m[0], false))) + .filter((r): r is Row => !!r?.kind.startsWith("absolute-")); + + const at = [...perRef].sort().map(([ref, n]) => `${ref === "main" ? "main at HEAD" : ref} (${n})`).join(", "); + console.log(`${absolute.length} absolute links from ${new Set(absolute.map((r) => r.from)).size} files, against ${at}`); + console.log(`${broken.length} do not resolve`); + for (const r of broken) console.log(show(r)); + console.log(`${strays.length} in files the map does not read`); + for (const r of strays) console.log(show(r)); + if (unread.length) { + console.log(`${unread.length} not read: this clone lacks ${[...lacking].sort().join(", ")}; run git fetch --tags`); + if (strict) console.log("PF_DRIFT_STRICT=1, so a link not read fails"); + } + const failed = broken.length > 0 || strays.length > 0 || (unread.length > 0 && strict); + console.log(`links-absolute: ${failed ? "failed" : unread.length ? "skipped" : "passed"}`); + process.exit(failed ? 1 : 0); +} + +const csv = ["from,to,kind,anchor,resolves", ...sorted.map((r) => [r.from, r.to, r.kind, r.anchor, r.resolves].map(cell).join(","))]; +fs.writeFileSync(path.join(ROOT, OUT), csv.join("\n") + "\n"); + +const broken = sorted.filter((r) => r.resolves === "no"); +console.log(`${sorted.length} links from ${new Set(sorted.map((r) => r.from)).size} files → ${OUT}`); +console.log(`${broken.length} do not resolve`); +for (const r of broken) console.log(show(r)); + +// ── The parity list ── + +type Side = string | null | undefined; // the side's prose; null: it does not exist; undefined: not measured +type Pair = { kind: string; en: string; ko: string; enText: Side; koText: Side }; +const pairs: Pair[] = []; + +// Prose with the markup gone; a path or a URL on its own is not prose. +const prose = (s: string) => + /^\s*(?:https?:\/\/|\/)\S*\s*$/.test(s) ? "" : s + .replace(/^---\n[\s\S]*?\n---\n/, "").replace(/```[\s\S]*?```/g, "").replace(//g, "") + .replace(/^(?:import|export) .*$/gm, "").replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "") + .replace(/https?:\/\/\S+/g, "").replace(/[*_~`#>|]/g, ""); +const chars = (s: Side) => (typeof s === "string" ? [...s.replace(/\s/g, "")].length : null); + +// Files and routes: the twin carries the language it adds, the bare one is the other. +const TWIN = /^(.*?)([./])(en|ko)\2(page\.tsx|[^./]+)$/; +const ROUTE = /^web\/src\/app\/([^/]+)\/.*page\.tsx$/; +// The journal is written post by post in Korean (x.mdx) and English (x.en.mdx): +// a post with one side is missing the other. +const PAIRED_FOLDERS = ["web/content/journal/"]; +const routeTwins = new Map(); // a route folder → the language its twins add +const twinned = new Set(); +for (const f of tracked) { + const m = TWIN.exec(f); + if (!m || (m[2] === "/" && m[4] !== "page.tsx")) continue; + const bare = `${m[1]}${m[2] === "/" ? "/" : "."}${m[4]}`; + const route = m[2] === "/"; + const [en, ko] = m[3] === "ko" ? [bare, f] : [f, bare]; + const text = (p: string) => (!files.has(p) ? null : route ? undefined : prose(read(p))); + pairs.push({ kind: route ? "route" : "file", en, ko, enText: text(en), koText: text(ko) }); + twinned.add(bare); + if (route) routeTwins.set(ROUTE.exec(f)?.[1] ?? "", m[3]); +} +for (const f of tracked) { + if (twinned.has(f) || TWIN.test(f)) continue; + const top = ROUTE.exec(f)?.[1]; + if (PAIRED_FOLDERS.some((d) => f.startsWith(d)) && /\.mdx?$/.test(f)) pairs.push({ kind: "file", en: f.replace(/(\.mdx?)$/, ".en$1"), ko: f, enText: null, koText: prose(read(f)) }); + else if (top && routeTwins.has(top) && !/\/(en|ko)\//.test(f)) { + const twin = f.replace(/page\.tsx$/, `${routeTwins.get(top)}/page.tsx`); + pairs.push(routeTwins.get(top) === "ko" + ? { kind: "route", en: f, ko: twin, enText: undefined, koText: null } + : { kind: "route", en: twin, ko: f, enText: null, koText: undefined }); + } +} + +// Code: an object with `en` and `ko` (walked side by side when both are +// objects), and a field beside its `Ko` or `En` twin (bare English beside Ko, +// bare Korean beside En). A list where some items have the twin and some +// do not has a missing side. +const unwrap = (e: ts.Expression): ts.Expression => + ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isParenthesizedExpression(e) ? unwrap(e.expression) : e; +const nameOf = (n: ts.PropertyName) => (ts.isIdentifier(n) || ts.isStringLiteral(n) ? n.text : null); +function propsOf(o: ts.ObjectLiteralExpression): Map { + const out = new Map(); + for (const p of o.properties) { + const n = p.name && nameOf(p.name); + if (!n) continue; + out.set(n, ts.isPropertyAssignment(p) ? unwrap(p.initializer) : ts.isShorthandPropertyAssignment(p) ? p.name : p); + } + return out; +} +// The strings in a value: a literal, a template's fixed parts, literals joined +// with +, or every literal in a function. +function textOf(n: ts.Node | undefined): Side { + if (!n) return null; + if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) return prose(n.text); + if (ts.isTemplateExpression(n)) return prose([n.head.text, ...n.templateSpans.map((s) => s.literal.text)].join("")); + if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const [l, r] = [textOf(unwrap(n.left)), textOf(unwrap(n.right))]; + return l === undefined && r === undefined ? undefined : (l ?? "") + (r ?? ""); + } + if (!ts.isFunctionLike(n)) return undefined; + const parts: string[] = []; + const walk = (c: ts.Node): void => { + if (ts.isStringLiteral(c) || ts.isNoSubstitutionTemplateLiteral(c) || ts.isTemplateLiteralToken(c)) parts.push(c.text); + ts.forEachChild(c, walk); + }; + walk(n); + return prose(parts.join(" ")); +} + +function codePairs(f: string) { + const sf = ts.createSourceFile(f, read(f), ts.ScriptTarget.Latest, true, f.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + const consts = new Map(); + for (const s of sf.statements) + if (ts.isVariableStatement(s)) + for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer) consts.set(d.name.text, d.initializer); + const deref = (n: ts.Node | undefined) => { + const e = n && ts.isIdentifier(n) ? (consts.get(n.text) ?? n) : n; + return e && ts.isExpression(e) ? unwrap(e) : e; + }; + const push = (kind: string, en: string, ko: string, enText: Side, koText: Side) => { + if (enText === undefined && koText === undefined) return; + if (!chars(enText) && !chars(koText)) return; // a path, a URL or an empty string on both sides + pairs.push({ kind, en: `${f}#${en}`, ko: `${f}#${ko}`, enText, koText }); + }; + + function sideBySide(base: string, rest: string, en: ts.Node | undefined, ko: ts.Node | undefined) { + const [a, b] = [deref(en), deref(ko)]; + if (a && b && ts.isObjectLiteralExpression(a) && ts.isObjectLiteralExpression(b)) { + const [pa, pb] = [propsOf(a), propsOf(b)]; + for (const k of new Set([...pa.keys(), ...pb.keys()])) sideBySide(base, `${rest}.${k}`, pa.get(k), pb.get(k)); + } else if (a && b && ts.isArrayLiteralExpression(a) && ts.isArrayLiteralExpression(b)) { + for (let i = 0; i < Math.max(a.elements.length, b.elements.length); i++) + sideBySide(base, `${rest}[${i}]`, a.elements[i], b.elements[i]); + } else push("copy", `${base}.en${rest}`, `${base}.ko${rest}`, textOf(a), textOf(b)); + } + + function fields(at: string, props: Map, twins: Set) { + for (const k of twins) { + const [bare, lang] = [k.slice(0, -2), k.slice(-2)]; + if (!props.has(bare) && !props.has(k)) continue; + const [en, ko] = lang === "Ko" ? [bare, k] : [k, bare]; + push("field", `${at}.${en}`, `${at}.${ko}`, textOf(props.get(en)), textOf(props.get(ko))); + } + } + const twinsIn = (props: Map) => + [...props.keys()].filter((k) => /[a-z](Ko|En)$/.test(k) && props.has(k.slice(0, -2))); + + function visit(n: ts.Node, at: string): void { + if ((ts.isVariableDeclaration(n) || ts.isFunctionDeclaration(n)) && n.name && ts.isIdentifier(n.name)) { + const here = at ? `${at}.${n.name.text}` : n.name.text; + ts.forEachChild(n, (c) => visit(c, here)); + } else if (ts.isObjectLiteralExpression(n)) { + const props = propsOf(n); + const paired = props.has("en") && props.has("ko"); + if (paired) sideBySide(at, "", props.get("en"), props.get("ko")); + if (!ts.isArrayLiteralExpression(n.parent)) fields(at, props, new Set(twinsIn(props))); + for (const [k, v] of props) if (!(paired && (k === "en" || k === "ko"))) visit(v, `${at}.${k}`); + } else if (ts.isArrayLiteralExpression(n)) { + const items = n.elements.map((e) => (ts.isObjectLiteralExpression(unwrap(e)) ? propsOf(unwrap(e) as ts.ObjectLiteralExpression) : null)); + const twins = new Set(items.flatMap((p) => (p ? twinsIn(p) : []))); + n.elements.forEach((e, i) => { + const id = items[i]?.get("id"); + const here = `${at}[${id && ts.isStringLiteral(id) ? id.text : i}]`; + if (items[i]) fields(here, items[i]!, twins); + visit(e, here); + }); + } else ts.forEachChild(n, (c) => visit(c, at)); + } + visit(sf, ""); +} +for (const f of tracked) if (/^web\/src\/.*\.tsx?$/.test(f) && !/\.(test|d)\.tsx?$/.test(f)) codePairs(f); + +// Korean says in one syllable what English says in about two and a half +// letters: over the journal's 37 pairs (2026-10-14) a Hangul syllable stands +// for 2.4 English characters, the other characters for one. Sides are compared +// at that size. A side under two thirds of the other is short; a line whose +// longer side is under a sentence (SENTENCE) is a label or a title, and the +// comparison means nothing there. The same sentence on both sides is a +// translation not made yet; the same short value is an id, a file name or a +// brand, not a translation, and is left out. +const HANGUL = /[\u1100-\u11ff\u3130-\u318f\uac00-\ud7a3]/; +const PER_SYLLABLE = 2.4; +const SENTENCE = 40; +const size = (s: string) => { + const hangul = [...s].filter((c) => HANGUL.test(c)).length; + return (chars(s) ?? 0) - hangul + PER_SYLLABLE * hangul; +}; +function state(p: Pair): string | null { + if (p.enText === null) return "missing-en"; + if (p.koText === null) return "missing-ko"; + if (p.enText === undefined || p.koText === undefined) return "ok"; + const [en, ko] = [size(p.enText), size(p.koText)]; + if (p.enText.trim() === p.koText.trim()) + return Math.max(en, ko) < SENTENCE || !/\s/.test(p.enText.trim()) ? null : HANGUL.test(p.enText) ? "missing-en" : "missing-ko"; + if (Math.max(en, ko) < SENTENCE) return "ok"; + if (ko < (2 / 3) * en) return "ko-short"; + if (en < (2 / 3) * ko) return "en-short"; + return "ok"; +} +const pkey = (p: Pair) => [p.kind, p.en, p.ko].join("\0"); +const listed = [...new Map(pairs.map((p) => [pkey(p), p])).values()].sort((a, b) => (pkey(a) < pkey(b) ? -1 : pkey(a) > pkey(b) ? 1 : 0)); +const num = (n: number | null) => (n === null ? "" : String(n)); +const parity = listed.flatMap((p) => { + const s = state(p); + return s ? [{ ...p, state: s }] : []; +}); +fs.writeFileSync( + path.join(ROOT, PARITY), + ["kind,en,ko,en_chars,ko_chars,state", ...parity.map((p) => [p.kind, p.en, p.ko, num(chars(p.enText)), num(chars(p.koText)), p.state].map(cell).join(","))].join("\n") + "\n", +); +const tally = new Map(); +for (const p of parity) tally.set(p.state, (tally.get(p.state) ?? 0) + 1); +console.log(`${parity.length} Korean/English pairs → ${PARITY}`); +console.log([...tally].sort().map(([s, n]) => `${n} ${s}`).join(", ")); diff --git a/web/scripts/drift.ts b/web/scripts/drift.ts new file mode 100644 index 00000000..5f8df4db --- /dev/null +++ b/web/scripts/drift.ts @@ -0,0 +1,39 @@ +/** + * The drift checks: `npm run check:drift` in web/, which check:ci runs, or + * `npm run check:` for one (docs/rfc-cleanup.md §2.4). + * + * A check is a file in scripts/drift/. It compares two things the tree keeps + * by hand, the BOM and the build guide's parts table for one, and fails when + * they disagree. This runs every file there whose name does not start with _ + * (those are shared code), each in a process of its own, so a check that + * throws stops no other. A name given here with no file behind it fails: + * package.json names every check step 2 plans + * (docs/investigations/2026-10-cleanup-step2-plan.md), and each file lands in + * a PR of its own. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { ROOT, SKIPPED } from "./drift/_lib"; + +const DIR = path.join(ROOT, "web/scripts/drift"); +const checks = fs.readdirSync(DIR).filter((f) => f.endsWith(".ts") && !f.startsWith("_")).map((f) => f.slice(0, -3)).sort(); +const asked = process.argv.slice(2); + +const results: [name: string, state: string][] = []; +for (const name of asked.length ? asked : checks) { + console.log(`── ${name}`); + if (!checks.includes(name)) { + console.log(`scripts/drift/${name}.ts is not written yet`); + results.push([name, "failed"]); + continue; + } + // process.execArgv carries tsx's loader, so the child reads TypeScript too. + const run = spawnSync(process.execPath, [...process.execArgv, path.join(DIR, `${name}.ts`)], { stdio: "inherit" }); + results.push([name, run.status === 0 ? "passed" : run.status === SKIPPED ? "skipped" : "failed"]); +} + +console.log(""); +for (const [name, state] of results) console.log(`${state.padEnd(8)} ${name}`); +if (results.some(([, state]) => state === "failed")) process.exitCode = 1; diff --git a/web/scripts/drift/_bom.ts b/web/scripts/drift/_bom.ts new file mode 100644 index 00000000..629a1b2a --- /dev/null +++ b/web/scripts/drift/_bom.ts @@ -0,0 +1,56 @@ +/** + * The BOM as the drift checks read it. hardware/bom/bom_v3.9.csv is the + * source of truth (hard rule 2 in AGENTS.md): check:bom holds it against the + * build guide's parts tables, check:bom-schematic against the schematic PDF. + */ +import { read } from "./_lib"; + +export const BOM = "hardware/bom/bom_v3.9.csv"; + +export type Row = Record; + +// RFC 4180: a quoted cell may hold commas, line breaks and "" for a quote. +function parse(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ""; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c === '"' && text[i + 1] === '"') cell += text[++i]; + else if (c === '"') quoted = false; + else cell += c; + } else if (c === '"') quoted = true; + else if (c === "," || c === "\n") { + row.push(cell); + cell = ""; + if (c === "\n") { + rows.push(row); + row = []; + } + } else if (c !== "\r") cell += c; + } + if (cell || row.length) rows.push([...row, cell]); + return rows; +} + +/** The BOM's rows, each keyed by the CSV's header line. */ +export function bom(): Row[] { + const [head, ...rows] = parse(read(BOM)); + return rows.filter((r) => r.some(Boolean)).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]))); +} + +/** + * The designators a ref cell names. "J1" is one, and "SW1-SW4" is four, with + * a hyphen or an en dash. Any other cell names none: "-" off the board, the + * guide's "—", and "U1 (sockets)", whose notes say it is not a refdes. + */ +export function designators(ref: string): string[] { + const m = ref.trim().match(/^([A-Z]+)(\d+)(?:\s*[-–]\s*([A-Z]+)(\d+))?$/); + if (!m) return []; + const [, prefix, from, prefixTo, to] = m; + if (!to) return [prefix + from]; + if (prefixTo !== prefix || +to <= +from) throw new Error(`${ref}: a range keeps one prefix and counts up`); + return Array.from({ length: +to - +from + 1 }, (_, i) => `${prefix}${+from + i}`); +} diff --git a/web/scripts/drift/_broker.ts b/web/scripts/drift/_broker.ts new file mode 100644 index 00000000..48a29948 --- /dev/null +++ b/web/scripts/drift/_broker.ts @@ -0,0 +1,130 @@ +/** + * check:mqtt-vectors' broker, and the Sender it hands _vectors.ts (docs/contract-vectors.md). A Patternflow + * is a broker's client, so each vector gets a broker of its own on a free port: MQTT 3.1.1 at QoS 0 on + * node:net, enough to connect, subscribe, publish and retain. A topic matches only itself, since a panel + * subscribes with no wildcards (docs/mqtt-spec.md line 12). open() points the panel at the broker with + * POST /api/mqtt and leaves the role off for the vector to pick. A step's mqtt goes to each connection + * subscribed to its topic, and heard is each PUBLISH the panel sent. + */ +import net, { type AddressInfo } from "node:net"; + +import type { Panel, Sender } from "./_vectors"; + +/** A step's mqtt, and what heard holds. A send keeps it on the broker with retain: true. */ +type Message = { topic: string; payload: string; retain?: boolean }; + +// The framing: a type byte, the length in groups of seven bits, the body. A string is two length bytes and UTF-8. +const u16 = (n: number) => Buffer.from([n >> 8, n & 255]); +const str = (s: string) => Buffer.concat([u16(Buffer.byteLength(s)), Buffer.from(s)]); +function packet(type: number, ...parts: Buffer[]) { + const [body, head] = [Buffer.concat(parts), [type]]; + let n = body.length; + do head.push((n % 128) | (n >= 128 ? 128 : 0)); + while ((n = Math.floor(n / 128))); + return Buffer.concat([Buffer.from(head), body]); +} +const [CONNECT, PUBLISH, SUBSCRIBE, PINGREQ, DISCONNECT] = [1, 3, 8, 12, 14]; +const SETTLE_MS = 2000; // the longest a dial may take before settle() gives up on it + +const isMessage = (m: unknown): m is Message => + typeof m === "object" && m !== null && Object.keys(m).every((k) => ["topic", "payload", "retain"].includes(k)) && + typeof (m as Message).topic === "string" && typeof (m as Message).payload === "string" && [undefined, true, false].includes((m as Message).retain); + +export const mqtt: Sender = async () => { + // Every connection, and once it has sent CONNECT, the topics it subscribed to. + const clients = new Map | null>(); + const retained = new Map(); + const heard: Message[] = []; + let [moved, panel] = [0, undefined as Panel | undefined]; // bytes in and out, which settle() watches + + const write = (s: net.Socket, b: Buffer) => ((moved += b.length), s.write(b)); + // To each subscriber with the retain flag clear, and kept for later ones when it is set. An empty one clears what was kept. + function publish(topic: string, payload: Buffer, retain: boolean) { + if (retain && payload.length) retained.set(topic, payload); + else if (retain) retained.delete(topic); + for (const [s, topics] of clients) if (topics?.has(topic)) write(s, packet(0x30, str(topic), payload)); + } + + function received(s: net.Socket, type: number, flags: number, body: Buffer) { + const topics = clients.get(s); + if (type === CONNECT) return void (clients.set(s, new Set()), write(s, packet(0x20, Buffer.from([0, 0])))); + if (!topics) return void s.destroy(); // anything before CONNECT + if (type === PUBLISH) { + const at = 2 + body.readUInt16BE(0); + const [topic, payload, retain] = [body.toString("utf8", 2, at), body.subarray(at + (flags & 6 ? 2 : 0)), (flags & 1) === 1]; + heard.push({ topic, payload: payload.toString(), retain }); + publish(topic, payload, retain); + // QoS 0 has no answer, and a PINGRESP is one a client takes for nothing. Bytes going back carry the TCP ACK + // at once, where the kernel would hold it 40 ms and the panel's next write behind it, by Nagle's rule. + return void write(s, packet(0xd0)); + } + if (type === SUBSCRIBE) { + // Each filter is granted QoS 0, then gets what was kept on its topic, with the retain flag set. + const filters: string[] = []; + for (let at = 2; at < body.length; at += 3 + body.readUInt16BE(at)) filters.push(body.toString("utf8", at + 2, at + 2 + body.readUInt16BE(at))); + filters.forEach((f) => topics.add(f)); + write(s, packet(0x90, body.subarray(0, 2), Buffer.alloc(filters.length))); + for (const f of filters) if (retained.has(f)) write(s, packet(0x31, str(f), retained.get(f)!)); + } else if (type === PINGREQ) write(s, packet(0xd0)); + else if (type === DISCONNECT) s.end(); + } + + const server = net.createServer((s) => { + clients.set(s, null); + s.setNoDelay(true); // with Nagle on, a write behind one not yet acknowledged waits out a 40 ms delayed ACK + let inbox: Buffer = Buffer.alloc(0); + s.on("error", () => s.destroy()); + s.on("close", () => clients.delete(s)); + s.on("data", (chunk: Buffer) => { + moved += chunk.length; + inbox = Buffer.concat([inbox, chunk]); + for (;;) { + let [length, i, scale] = [0, 1, 1]; + for (; i < inbox.length && i < 5; i++, scale *= 128) { + length += (inbox[i] & 127) * scale; + if (inbox[i] < 128) break; + } + if (i === 5) return void s.destroy(); // a length past four bytes is not MQTT + if (i >= inbox.length || inbox.length < i + 1 + length) return; + const [type, flags, body] = [inbox[0] >> 4, inbox[0] & 15, inbox.subarray(i + 1, i + 1 + length)]; + inbox = inbox.subarray(i + 1 + length); + try { + received(s, type, flags, body); + } catch { + return void s.destroy(); // a body shorter than its own fields say + } + } + }); + }); + await new Promise((done, fail) => server.once("error", fail).listen(0, "127.0.0.1", () => done())); + const port = (server.address() as AddressInfo).port; + + return { + heard, + async open(p) { + panel = p; + const r = await p.http("POST /api/mqtt", `host=127.0.0.1&port=${port}`); + if (r.status !== 200) throw new Error(`POST /api/mqtt answered ${r.status}, so the panel has no mqtt feature to point at the broker`); + }, + async send(what) { + if (!isMessage(what)) throw new Error(`mqtt sends { "topic", "payload" }, and "retain": true to keep it, not ${JSON.stringify(what)}`); + publish(what.topic, Buffer.from(what.payload), what.retain === true); + return undefined; + }, + // A dial, its CONNECT and CONNACK and the SUBSCRIBEs after them take real time, the panel's clock or not. Settled + // once the panel is not mid-dial and a round trip over HTTP moved nothing on the broker. + async settle() { + for (const from = Date.now(); ; ) { + const before = moved; + for (let i = 0; i < 3; i++) await new Promise((done) => setImmediate(done)); + const { state } = (await panel!.http("GET /api/mqtt")).body as { state?: string }; + if (state !== "connecting" && moved === before) return; + if (Date.now() - from > SETTLE_MS) throw new Error(`the panel's MQTT is still ${state} after ${SETTLE_MS} ms`); + } + }, + async close() { + for (const s of clients.keys()) s.destroy(); + await new Promise((done) => server.close(() => done())); + }, + }; +}; diff --git a/web/scripts/drift/_lib.ts b/web/scripts/drift/_lib.ts new file mode 100644 index 00000000..45597575 --- /dev/null +++ b/web/scripts/drift/_lib.ts @@ -0,0 +1,73 @@ +/** + * What the drift checks share. A check is a file beside this one: it reads + * two lists the tree keeps by hand, gives them to compare(), and ends with + * done(). scripts/drift.ts runs them (docs/rfc-cleanup.md §2.4). + * + * compare() prints both sides and what is on one side only, so a red run in + * CI says what drifted without anyone checking the branch out. A side a check + * cannot read where it runs (a tag the clone lacks, no gh login, no pdftotext) + * goes to skip(), which says why and passes. Under PF_DRIFT_STRICT=1, which + * the CI workflow sets, a skip fails. + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +export const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); + +/** How a check that skipped and found no drift exits. scripts/drift.ts reads it. */ +export const SKIPPED = 75; + +let files: string[] | undefined; +/** Tracked files, relative to the repository root, all of them or those under one folder. */ +export function tracked(under = ""): string[] { + files ??= execFileSync("git", ["ls-files", "-z"], { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28 }) + .split("\0") + .filter(Boolean); + return files.filter((f) => f.startsWith(under)); +} + +export const read = (file: string) => fs.readFileSync(path.join(ROOT, file), "utf8"); + +export type Side = { label: string; items: Iterable }; + +let failed = 0; +let skipped = 0; + +const counts = (items: Iterable) => { + const out = new Map(); + for (const x of items) out.set(x, (out.get(x) ?? 0) + 1); + return out; +}; +// What a has more times than b: an item listed twice against once is on one side once. +const extra = (a: Map, b: Map) => + [...a].flatMap(([x, n]) => Array(Math.max(0, n - (b.get(x) ?? 0))).fill(x)).sort(); +const list = (xs: string[]) => (xs.length ? xs.join(", ") : "(none)"); + +/** Prints both sides and what is on one side only. True when the two hold the same items. */ +export function compare(name: string, left: Side, right: Side): boolean { + const [l, r] = [counts(left.items), counts(right.items)]; + const [onlyLeft, onlyRight] = [extra(l, r), extra(r, l)]; + const all = (m: Map) => [...m].flatMap(([x, n]) => Array(n).fill(x)).sort(); + const ok = !onlyLeft.length && !onlyRight.length; + console.log(`${name}: ${ok ? "same" : "drift"}`); + console.log(` ${left.label} (${all(l).length}): ${list(all(l))}`); + console.log(` ${right.label} (${all(r).length}): ${list(all(r))}`); + if (onlyLeft.length) console.log(` only in ${left.label}: ${list(onlyLeft)}`); + if (onlyRight.length) console.log(` only in ${right.label}: ${list(onlyRight)}`); + if (!ok) failed++; + return ok; +} + +/** A side this check cannot read here. Says why; passes, or fails under PF_DRIFT_STRICT=1. */ +export function skip(name: string, why: string): void { + console.log(`${name}: skipped, ${why}`); + skipped++; +} + +/** The check's result as its exit code: 1 on drift or a strict skip, SKIPPED on a skip, 0 otherwise. */ +export function done(): void { + const strict = process.env.PF_DRIFT_STRICT === "1"; + if (!failed && skipped && strict) console.log("PF_DRIFT_STRICT=1, so a skip fails"); + process.exitCode = failed || (skipped && strict) ? 1 : skipped ? SKIPPED : 0; +} diff --git a/web/scripts/drift/_osc.ts b/web/scripts/drift/_osc.ts new file mode 100644 index 00000000..91aa7bfc --- /dev/null +++ b/web/scripts/drift/_osc.ts @@ -0,0 +1,91 @@ +/** + * The OSC sender check:osc-vectors gives _vectors.ts. A vector writes a message as its address and then its + * arguments (docs/osc-vectors/README.md): a whole number goes as an int32, any other number as a float32, a + * string as a string, and ["#bundle", message, …] wraps the messages in a bundle. One UDP socket a vector sends to + * the panel's osc port, and the panel, started with oscReply 0, answers to the port it was sent from. + */ +import dgram from "node:dgram"; + +import type { Sender } from "./_vectors"; + +// A string ends in a zero and is padded to four bytes. +function pad(s: string) { + const b = Buffer.from(`${s}\0`); + return Buffer.concat([b, Buffer.alloc((4 - (b.length % 4)) % 4)]); +} +function word(n: number, float = false) { + const b = Buffer.alloc(4); + if (float) b.writeFloatBE(n); + else b.writeInt32BE(n); + return b; +} + +function pack(m: unknown): Buffer { + if (!Array.isArray(m) || typeof m[0] !== "string") throw new Error(`osc sends ["/address", ...arguments], not ${JSON.stringify(m)}`); + const [address, ...args] = m as [string, ...unknown[]]; + // A time tag of 1 is "immediately". Each element is its size and then the message. + if (address === "#bundle") return Buffer.concat([pad(address), word(0), word(1), ...args.map(pack).flatMap((b) => [word(b.length), b])]); + const tags = args.map((a) => (typeof a === "string" ? "s" : Number.isInteger(a) ? "i" : typeof a === "number" ? "f" : "?")); + if (tags.includes("?")) throw new Error(`an OSC argument is a number or a string, not in ${JSON.stringify(m)}`); + const values = args.map((a, i) => (tags[i] === "s" ? pad(a as string) : word(a as number, tags[i] === "f"))); + return Buffer.concat([pad(address), pad(`,${tags.join("")}`), ...values]); +} + +/** A message as a vector writes it, or the bytes in hex when they are not one. */ +function read(b: Buffer): unknown { + const string = (at: number): [string, number] => { + const end = b.indexOf(0, at); + if (end < 0) throw new Error("no terminating zero"); + return [b.toString("utf8", at, end), (end + 4) & ~3]; + }; + try { + const [address, at] = string(0); + const [tags, start] = string(at); + const m: unknown[] = [address]; + let i = start; + for (const t of tags.slice(1)) { + if (t === "s") { + const [s, next] = string(i); + m.push(s); + i = next; + } else if (t === "i" || t === "f") { + m.push(t === "i" ? b.readInt32BE(i) : b.readFloatBE(i)); + i += 4; + } else throw new Error(`type tag ${t}`); + } + return m; + } catch { + return `not OSC: ${b.toString("hex")}`; + } +} + +const MARK = Buffer.from("settle"); + +export const osc: Sender = async () => { + const socket = dgram.createSocket("udp4"); + await new Promise((done, fail) => void socket.once("error", fail).bind(0, "127.0.0.1", () => done())); + const me = socket.address().port; + const send = (b: Buffer, port: number) => new Promise((done, fail) => socket.send(b, port, "127.0.0.1", (e) => (e ? fail(e) : done()))); + const [heard, marks] = [[] as unknown[], [] as (() => void)[]]; + socket.on("message", (b, from) => { + if (from.port === me) marks.shift()?.(); + else heard.push(read(b)); + }); + let panel = 0; + return { + open: async ({ ports }) => { + panel = ports.osc; + }, + send: async (what) => { + await send(pack(what), panel); + }, + heard, + // A datagram to itself. The socket reads in order, so once it is back, all the panel sent before it is in heard. + settle: () => + new Promise((done, fail) => { + marks.push(done); + send(MARK, me).catch(fail); + }), + close: () => new Promise((done) => socket.close(() => done())), + }; +}; diff --git a/web/scripts/drift/_rtpmidi.ts b/web/scripts/drift/_rtpmidi.ts new file mode 100644 index 00000000..61dde6a5 --- /dev/null +++ b/web/scripts/drift/_rtpmidi.ts @@ -0,0 +1,130 @@ +/** + * The RTP-MIDI sender check:midi-vectors gives _vectors.ts, an initiator shaped like + * tools/rtpmidi-probe/rtpmidi_probe.py. It invites the panel on its midi port from a control socket and + * on the next port from a data socket, then syncs clocks once. Each step's message goes in an RTP-MIDI + * packet of its own. A vector writes a message as its bytes (docs/midi-vectors/README.md), [176, 24, 65] + * for CC 24 at 65 on channel 1, and heard is each message the panel sent, read back into the same form. + */ +import { randomBytes } from "node:crypto"; +import dgram from "node:dgram"; + +import type { Sender } from "./_vectors"; + +const NAME = "check:midi-vectors"; +const ANSWER_MS = 2000; // the longest an invitation or a clock sync waits for the panel's answer +// Data octets after a channel status, by its high nibble. +const DATA: Record = { 0x8: 2, 0x9: 2, 0xa: 2, 0xb: 2, 0xc: 1, 0xd: 1, 0xe: 2 }; + +// A session packet is two 0xFF octets and a two-letter command (AppleMIDI). Its clock counts 100 µs. +const isSession = (b: Buffer) => b.length >= 4 && b.readUInt16BE(0) === 0xffff; +const ticks = () => Math.round(performance.now() * 10); +function session(command: string, ...words: number[]) { + const b = Buffer.alloc(4 + 4 * words.length); + b.writeUInt16BE(0xffff, 0); + b.write(command, 2, "latin1"); + words.forEach((w, i) => b.writeUInt32BE(w, 4 + 4 * i)); + return b; +} +// A clock sync: the initiator's SSRC, the count, three pad octets and three times. +function sync(ssrc: number, count: number, times: (number | bigint)[]) { + const b = Buffer.concat([session("CK", ssrc, count << 24), Buffer.alloc(24)]); + times.forEach((t, i) => b.writeBigUInt64BE(BigInt(t), 12 + 8 * i)); + return b; +} + +const isMessage = (m: unknown): m is number[] => + Array.isArray(m) && m.every((b, i) => Number.isInteger(b) && b >= (i ? 0 : 0x80) && b < (i ? 0x80 : 0xf0)) && m.length === 1 + DATA[m[0] >> 4]; + +/** Each channel message in an RTP-MIDI packet (RFC 6295), running status filled in. Anything else comes back in hex. */ +function read(b: Buffer): unknown[] { + const long = (b[12] & 0x80) !== 0; // B: a 12-bit length + let at = long ? 14 : 13; + const end = at + (long ? ((b[12] & 0x0f) << 8) | b[13] : b[12] & 0x0f); + if (b.length < at || b[0] >> 6 !== 2 || (b[1] & 0x7f) !== 0x61 || end > b.length) return [`not RTP-MIDI: ${b.toString("hex")}`]; + let [delta, running] = [(b[12] & 0x20) !== 0, 0]; // Z: the first message has a delta time too + const out: unknown[] = []; + while (at < end) { + if (delta) while (at < end && b[at++] & 0x80); // one to four octets, the last under 128 + delta = true; + const status = b[at] & 0x80 ? b[at++] : running; + const m = [status, ...b.subarray(at, (at += DATA[status >> 4] ?? end))]; + if (!isMessage(m)) return [...out, `not a channel message: ${b.toString("hex", 12, end)}`]; + out.push(m); + running = status; + } + return out; +} + +/** Sends a session packet and resolves with the answer of that command on the same socket. */ +function ask(socket: dgram.Socket, port: number, b: Buffer, command: string) { + const what = `${b.toString("latin1", 2, 4)} to port ${port}`; + return new Promise((done, fail) => { + const timer = setTimeout(() => (socket.off("message", hear), fail(new Error(`${what}: no answer in ${ANSWER_MS} ms`))), ANSWER_MS); + function hear(r: Buffer) { + if (!isSession(r) || ![command, "NO"].includes(r.toString("latin1", 2, 4))) return; + clearTimeout(timer); + socket.off("message", hear); + if (r.toString("latin1", 2, 4) === "NO") fail(new Error(`${what}: the panel said NO`)); + else done(r); + } + socket.on("message", hear); + socket.send(b, port, "127.0.0.1", (e) => e && fail(e)); + }); +} + +const MARK = Buffer.from("settle"); + +async function socket() { + const s = dgram.createSocket("udp4"); + await new Promise((done, fail) => void s.once("error", fail).bind(0, "127.0.0.1", () => done())); + return s; +} + +export const midi: Sender = async () => { + const [control, data] = [await socket(), await socket()]; + const me = data.address().port; + const [ssrc, token] = [randomBytes(4).readUInt32BE(0), randomBytes(4).readUInt32BE(0)]; + const [heard, marks] = [[] as unknown[], [] as (() => void)[]]; + data.on("message", (b, from) => { + if (from.port === me) marks.shift()?.(); + else if (!isSession(b)) heard.push(...read(b)); // session packets are open()'s + }); + const send = (b: Buffer, port: number) => new Promise((done, fail) => data.send(b, port, "127.0.0.1", (e) => (e ? fail(e) : done()))); + + let [panel, seq] = [0, randomBytes(2).readUInt16BE(0)]; + return { + // An invitation on control and then on data, which the panel keeps on the port after it, and one clock sync. + open: async ({ ports }) => { + const invite = Buffer.concat([session("IN", 2, token, ssrc), Buffer.from(`${NAME}\0`)]); + await ask(control, ports.midi, invite, "OK"); + await ask(data, ports.midi + 1, invite, "OK"); + const answer = await ask(data, ports.midi + 1, sync(ssrc, 0, [ticks(), 0, 0]), "CK"); + await send(sync(ssrc, 2, [answer.readBigUInt64BE(12), answer.readBigUInt64BE(20), ticks()]), ports.midi + 1); + panel = ports.midi; + }, + send: async (what) => { + if (!isMessage(what)) throw new Error(`midi sends one channel message, [status, ...data], not ${JSON.stringify(what)}`); + const b = Buffer.alloc(13); + b[0] = 0x80; // version 2 + b[1] = 0x61; // payload type 97 + b.writeUInt16BE(seq, 2); + b.writeUInt32BE(ticks() % 2 ** 32, 4); + b.writeUInt32BE(ssrc, 8); + b[12] = what.length; // a list under 16 octets has the short header + seq = (seq + 1) & 0xffff; + await send(Buffer.concat([b, Buffer.from(what)]), panel + 1); + }, + heard, + // A datagram to itself. The socket reads in order, so once it is back, all the panel sent before it is in heard. + settle: () => + new Promise((done, fail) => { + marks.push(done); + send(MARK, me).catch(fail); + }), + // BY, as the probe ends, since a device keeps two participants at most. + close: async () => { + if (panel) await new Promise((done) => control.send(session("BY", 2, token, ssrc), panel, "127.0.0.1", () => done())); + await Promise.all([control, data].map((s) => new Promise((done) => s.close(() => done())))); + }, + }; +}; diff --git a/web/scripts/drift/_vectors.ts b/web/scripts/drift/_vectors.ts new file mode 100644 index 00000000..e3af4130 --- /dev/null +++ b/web/scripts/drift/_vectors.ts @@ -0,0 +1,215 @@ +/** + * The replay the contract-vector suites share (docs/contract-vectors.md). replay() starts a fresh + * virtual panel (tools/virtual-panel/) for each .json in a vector folder, on a manual clock and free + * ports, runs its steps and gives compare() what the vector expects beside what the panel did. It + * reaches the panel only through its ports: http, hand and wait go over HTTP, and a suite passes in + * a Sender for each other kind, kept in a _ file of its own. A vector whose `says` is gone from its + * spec fails, so a spec edit brings its vectors along. + */ +import fs from "node:fs"; +import http from "node:http"; + +import { start } from "../../../tools/virtual-panel/panel"; + +import { compare, read, ROOT } from "./_lib"; + +/** What a suite's Sender opens for one vector. */ +export type Channel = { + /** Ports the panel starts with, over "*": 0, such as where its replies go. */ + ports?: Record; + /** Runs once the panel is up, before the first step. */ + open?(panel: Panel): Promise; + /** One step's send. What it returns is the step's reply. */ + send(what: unknown): Promise; + /** What the panel has sent on this kind, oldest first. The channel adds to it, and replay() times each. */ + heard: unknown[]; + /** Resolves once nothing is in flight. replay() waits on it after each send and each move of the clock. */ + settle?(): Promise; + close(): Promise; +}; +export type Sender = () => Promise; +export type Panel = { ports: Record; http(request: string, form?: string): Promise }; +export type Reply = { status: number; headers: Record; body: unknown }; + +type Out = Record & { after?: number; within?: number }; +type Step = { send: Record; expect?: { reply?: unknown; status?: unknown; input?: unknown; out?: Out[] } }; +type Vector = { contract: string; says: string; features: string[]; steps: Step[] }; + +const SENT = ["osc", "midi", "mqtt", "ws"]; // the kinds a suite brings, and the ones the panel sends on +const KINDS = ["http", "hand", "wait", ...SENT]; +const EXPECTS = ["reply", "status", "input", "out"]; + +const isObject = (x: unknown): x is Record => typeof x === "object" && x !== null && !Array.isArray(x); +const TYPES: Record boolean> = { + "": (x) => x === undefined, + "": (x) => x !== undefined, + "": (x) => typeof x === "number", + "": (x) => Number.isInteger(x), + "": (x) => typeof x === "string", + "": (x) => typeof x === "boolean", + "": (x) => Array.isArray(x), + "": isObject, +}; +const isType = (x: unknown): x is string => typeof x === "string" && Object.hasOwn(TYPES, x); +const show = (x: unknown) => (x === undefined ? "" : isType(x) ? x : JSON.stringify(x)); + +/** + * Each place a shape names, as "where shape", and the value there: the same text where it fits, the + * value where not. True when every place fits. + */ +function match(where: string, shape: unknown, value: unknown, want: string[] = [], got: string[] = []): boolean { + if (Array.isArray(shape) && Array.isArray(value) && shape.length === value.length) { + return shape.map((s, i) => match(`${where}[${i}]`, s, value[i], want, got)).every(Boolean); + } + if (isObject(shape) && isObject(value)) return Object.entries(shape).map(([k, s]) => match(`${where}.${k}`, s, value[k], want, got)).every(Boolean); + const fits = isType(shape) ? TYPES[shape](value) : shape === value; + want.push(`${where} ${show(shape)}`); + got.push(`${where} ${show(fits ? shape : value)}`); + return fits; +} + +// GitHub's heading ids, slugged the way scripts/doc-map.ts slugs them. +function anchors(md: string) { + const seen = new Map(); + return new Set( + [...md.replace(/```[\s\S]*?```/g, "").matchAll(/^ {0,3}#{1,6}[ \t]+(.*?)[ \t]*#*[ \t]*$/gm)].map(([, h]) => { + const text = h.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*`~]/g, ""); + const base = text.toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "").replace(/ /g, "-"); + seen.set(base, (seen.get(base) ?? -1) + 1); + return seen.get(base) ? `${base}-${seen.get(base)}` : base; + }), + ); +} +/** Markdown and line breaks taken out, so a sentence is found however the spec wraps or marks it. */ +const plain = (md: string) => md.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`|\\]/g, "").replace(/\s+/g, " ").trim(); + +/** Throws on a vector that checks less than it seems to: a misspelled key would pass, checking nothing. */ +function check(v: unknown): asserts v is Vector { + const only = (o: unknown, keys: string[]) => isObject(o) && Object.keys(o).every((k) => keys.includes(k)); + const ms = (x: unknown) => x === undefined || (typeof x === "number" && x >= 0); + const one = (o: Record, kinds: string[]) => Object.keys(o).filter((k) => kinds.includes(k)).length === 1; + const step = (s: Step) => + only(s, ["send", "expect"]) && only(s.send, KINDS) && one(s.send, KINDS) && only(s.expect ?? {}, EXPECTS) && ms(s.send.wait) && + [s.expect?.out ?? []].every(Array.isArray) && (s.expect?.out ?? []).every((o) => only(o, ["after", "within", ...SENT]) && one(o, SENT) && ms(o.after) && ms(o.within)); + const { contract, says, features, steps } = (isObject(v) ? v : {}) as Partial; + const why = [ + !only(v, ["contract", "says", "features", "steps"]) && "a vector has contract, says, features and steps", + !/^docs\/[^#]+\.md#.+$/.test(String(contract)) && "contract is a spec in docs/ and one of its headings, as docs/x.md#anchor", + !(typeof says === "string" && says.trim()) && "says is a sentence from that spec", + !Array.isArray(features) && "features lists what the panel is built with, [] for the default build", + !(Array.isArray(steps) && steps.length) && "steps lists one step or more", + ...(Array.isArray(steps) ? steps : []).map((s, i) => !step(s) && `step ${i + 1} is not a send of one kind and an expect of ${EXPECTS.join(", ")}`), + ].filter(Boolean); + if (why.length) throw new Error(why.join("; ")); +} + +/** One request, on a connection of its own. A JSON body comes back parsed. */ +async function request(port: number, line: string, form?: string, headers: Record = {}): Promise { + const [method, target] = String(line).split(" "); + if (!target?.startsWith("/")) throw new Error(`http sends "METHOD /path?query", not ${JSON.stringify(line)}`); + if (form !== undefined) headers = { "Content-Type": "application/x-www-form-urlencoded", ...headers }; + const res = await new Promise((done, fail) => { + http.request({ host: "127.0.0.1", port, method, path: target, headers, agent: false }, done).once("error", fail).end(form); + }); + let text = ""; + for await (const part of res) text += part; + const flat = Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v)]); + const json = res.headers["content-type"]?.startsWith("application/json"); + return { status: res.statusCode ?? 0, headers: Object.fromEntries(flat), body: json ? JSON.parse(text) : text }; +} + +async function run(file: string, senders: Record, want: string[], got: string[]) { + const v: unknown = JSON.parse(read(file)); + check(v); + const [spec, anchor] = v.contract.split("#"); + const text = read(spec); + want.push(`contract ${v.contract}`, `says ${spec}`); + got.push(anchors(text).has(anchor) ? `contract ${v.contract}` : `contract ${spec} has no #${anchor}`); + got.push(plain(text).includes(plain(v.says)) ? `says ${spec}` : `says not in ${spec}`); + + const kindOf = (o: Out) => Object.keys(o).find((k) => SENT.includes(k)) as string; + const kinds = new Set(v.steps.flatMap((s) => [...Object.keys(s.send), ...(s.expect?.out ?? []).map(kindOf)]).filter((k) => SENT.includes(k))); + const channels = new Map(); + let panel: Awaited> | undefined; + try { + for (const kind of kinds) { + if (!senders[kind]) throw new Error(`this suite brings no sender for ${kind}`); + channels.set(kind, await senders[kind]()); + } + panel = await start({ features: v.features, clock: "manual", ports: Object.assign({ "*": 0 }, ...[...channels.values()].map((c) => c.ports)) }); + const port = panel.ports.http; + for (const c of channels.values()) await c.open?.({ ports: panel.ports, http: (line, form) => request(port, line, form) }); + + // A message is timed by the panel's clock when replay() first sees it, after a send or a move of the clock. + let now = 0; + const times = new Map([...channels.keys()].map((k) => [k, [] as number[]])); + const drain = async () => { + for (let i = 0; i < 3; i++) await new Promise((done) => setImmediate(done)); + for (const c of channels.values()) await c.settle?.(); + for (const [k, c] of channels) while (times.get(k)!.length < c.heard.length) times.get(k)!.push(now); + }; + await drain(); + + for (const [i, s] of v.steps.entries()) { + const at = `step ${String(i + 1).padStart(2, "0")}`; + const [[kind, what]] = Object.entries(s.send); + const [t0, next] = [now, new Map([...channels].map(([k, c]) => [k, c.heard.length]))]; + let reply: unknown; + if (kind === "http") { + const h = (typeof what === "string" ? { request: what } : what) as { request: string; form?: string; headers?: Record }; + reply = await request(port, h.request, h.form, h.headers); + } else if (kind === "hand") { + const h = what as { knob: number; turn?: number; down?: boolean }; + const r = await request(port, "turn" in h ? `GET /vp/turn?knob=${h.knob}&detents=${h.turn}` : `GET /vp/button?knob=${h.knob}&down=${Number(h.down)}`); + if (r.status !== 200) throw new Error(`${at} hand: ${r.body}`); + } else if (kind !== "wait") reply = await channels.get(kind)!.send(what); + await drain(); + + // The clock runs to the end of a wait and on to the last edge an out names, stopping at each + // edge, so a message falls wholly inside or outside each window. + const out = s.expect?.out ?? []; + const span = kind === "wait" ? (what as number) : 0; + for (const ms of [span, ...out.flatMap((o) => [o.after ?? 0, o.within ?? span])].sort((a, b) => a - b)) { + if (t0 + ms <= now) continue; + const r = await request(port, `GET /vp/advance?ms=${t0 + ms - now}`); + if (r.status !== 200) throw new Error(`the clock: ${r.body}`); + now = (r.body as { now: number }).now; + await drain(); + } + + const e = s.expect ?? {}; + if ("reply" in e) match(`${at} reply`, e.reply, reply, want, got); + if ("status" in e) match(`${at} status`, e.status, (await request(port, "GET /api/status")).body, want, got); + if ("input" in e) match(`${at} input`, e.input, (await request(port, "GET /vp/state")).body, want, got); + // In order: each out is a message later than the one matched before it on its kind. + for (const o of out) { + const [k, after, within] = [kindOf(o), o.after ?? -1, o.within ?? span]; + const [heard, time, from] = [channels.get(k)!.heard, times.get(k)!, next.get(k)!]; + const window = heard.map((_, j) => j).filter((j) => j >= from && time[j] - t0 > after && time[j] - t0 <= within); + const hit = o[k] === "" ? (window.length ? undefined : from - 1) : window.find((j) => match("", o[k], heard[j])); + const say = (message: string) => `${at} out ${k} ${message} ${o.after === undefined ? "" : `after ${after} ms, `}by ${within} ms`; + want.push(say(show(o[k]))); + got.push(say(hit !== undefined ? show(o[k]) : window.map((j) => `${show(heard[j])} at ${time[j] - t0} ms`).join(", ") || "nothing")); + if (hit !== undefined) next.set(k, hit + 1); + } + } + } finally { + const closed = await Promise.allSettled([panel?.close(), ...[...channels.values()].map((c) => c.close())]); + for (const c of closed) if (c.status === "rejected") got.push(`close threw ${c.reason}`); + } +} + +/** Replays each vector in a folder, named from the repository root, with a suite's senders: one compare() a vector. */ +export async function replay(folder: string, senders: Record = {}) { + const files = fs.readdirSync(`${ROOT}/${folder}`).filter((f) => f.endsWith(".json")).sort(); + if (!files.length) throw new Error(`${folder} has no vectors`); + for (const f of files) { + const [want, got]: string[][] = [[], []]; + try { + await run(`${folder}/${f}`, senders, want, got); + } catch (e) { + got.push(`threw ${(e as Error).message}`); + } + compare(`${folder}/${f}`, { label: "the vector", items: want }, { label: "the panel", items: got }); + } +} diff --git a/web/scripts/drift/_ws.ts b/web/scripts/drift/_ws.ts new file mode 100644 index 00000000..ad501f26 --- /dev/null +++ b/web/scripts/drift/_ws.ts @@ -0,0 +1,83 @@ +/** + * The WebSocket sender check:audio-vectors gives _vectors.ts. Node 20, which CI runs, has no WebSocket client, so + * this is RFC 6455 on node:http's upgrade, as much as a client that only sends needs. One socket a vector, opened on + * the panel's ws port before the first step. A step's ws is the text of one frame, or { "close": 1000 } for a close + * frame with that code (docs/audio-ws-vectors/README.md). heard is each text frame the panel sent, and + * docs/audio-ws-spec.md says it sends none. + */ +import { createHash, randomBytes } from "node:crypto"; +import http from "node:http"; +import type { Socket } from "node:net"; + +import type { Sender } from "./_vectors"; + +const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // RFC 6455's, for Sec-WebSocket-Accept +const [TEXT, CLOSE, PING, PONG] = [0x1, 0x8, 0x9, 0xa]; +const SETTLE_MS = 2000; // the longest the panel may take to answer a ping or a close + +/** One piece, masked as a client masks every frame, its length in the seven bits that hold up to 125 bytes. */ +function frame(op: number, payload: Buffer) { + if (payload.length > 125) throw new Error(`a ws message is 125 bytes at most, not ${payload.length}`); + const mask = randomBytes(4); + return Buffer.concat([Buffer.from([0x80 | op, 0x80 | payload.length]), mask, payload.map((x, i) => x ^ mask[i % 4])]); +} + +export const ws: Sender = async () => { + const [heard, waits] = [[] as string[], [] as (() => void)[]]; + let [socket, closed, buf] = [undefined as Socket | undefined, false, Buffer.alloc(0)]; + const gone = () => { + closed = true; + waits.splice(0).forEach((done) => done()); + }; + // The panel's frames are unmasked and short. A pong ends the oldest wait, and a close ends them all. + function read(more: Buffer) { + buf = Buffer.concat([buf, more]); + while (buf.length >= 2 && buf.length >= 2 + (buf[1] & 0x7f)) { + const [op, payload] = [buf[0] & 0x0f, buf.subarray(2, 2 + (buf[1] & 0x7f))]; + buf = buf.subarray(2 + payload.length); + if (op === TEXT) heard.push(payload.toString("utf8")); + else if (op === PONG) waits.shift()?.(); + else if (op === CLOSE) return void (gone(), socket?.end()); + } + } + // Writes a frame and resolves on the panel's answer, or once the socket closes. + const answered = (b: Buffer) => + new Promise((done, fail) => { + if (closed) return done(); + const timer = setTimeout(() => fail(new Error(`the panel did not answer in ${SETTLE_MS} ms`)), SETTLE_MS); + waits.push(() => (clearTimeout(timer), done())); + socket!.write(b); + }); + + return { + open: async ({ ports }) => { + const key = randomBytes(16).toString("base64"); + const headers = { Connection: "Upgrade", Upgrade: "websocket", "Sec-WebSocket-Key": key, "Sec-WebSocket-Version": "13" }; + socket = await new Promise((done, fail) => { + const req = http.request({ host: "127.0.0.1", port: ports.ws, path: "/", headers, agent: false }); + req.once("response", (res) => fail(new Error(`the panel answered the handshake with ${res.statusCode}, not 101`))); + req.once("upgrade", (res, s: Socket, head: Buffer) => { + if (res.headers["sec-websocket-accept"] !== createHash("sha1").update(key + GUID).digest("base64")) return fail(new Error("Sec-WebSocket-Accept does not match the key")); + s.setNoDelay(true); // a ping behind a message not yet acknowledged would wait out a 40 ms delayed ACK + s.on("data", read).on("close", gone).on("error", () => s.destroy()); + done(s); + read(head); + }); + req.once("error", fail).end(); + }); + }, + send: async (what) => { + if (closed) throw new Error("the socket is closed"); + if (typeof what === "string") return void socket!.write(frame(TEXT, Buffer.from(what))); + const code = (what as { close?: unknown } | null)?.close; + if (!Number.isInteger(code) || Object.keys(what as object).length !== 1) throw new Error(`ws sends a frame's text or { "close": code }, not ${JSON.stringify(what)}`); + const b = Buffer.alloc(2); + b.writeUInt16BE(code as number); + await answered(frame(CLOSE, b)); // the panel's close frame back says it has read this one + }, + heard, + // A ping. The panel reads frames in order, so once its pong is back it holds every message sent before it. + settle: () => answered(frame(PING, Buffer.alloc(0))), + close: () => new Promise((done) => (socket && !socket.destroyed ? socket.once("close", () => done()).destroy() : done())), + }; +}; diff --git a/web/scripts/drift/audio-vectors.ts b/web/scripts/drift/audio-vectors.ts new file mode 100644 index 00000000..ae8dd4e6 --- /dev/null +++ b/web/scripts/drift/audio-vectors.ts @@ -0,0 +1,10 @@ +/** + * check:audio-vectors: docs/audio-ws-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md), built with the audio feature. The sender is + * _ws.ts, one WebSocket a vector. + */ +import { done } from "./_lib"; +import { replay } from "./_vectors"; +import { ws } from "./_ws"; + +replay("docs/audio-ws-vectors", { ws }).then(done); diff --git a/web/scripts/drift/bom-schematic.ts b/web/scripts/drift/bom-schematic.ts new file mode 100644 index 00000000..0e75e6c5 --- /dev/null +++ b/web/scripts/drift/bom-schematic.ts @@ -0,0 +1,37 @@ +/** + * check:bom-schematic: the BOM's designators against the schematic's. + * hardware/pcb/schematic.pdf is KiCad output in stroke fonts, and pdftotext + * (poppler) reads every designator out of its text layer all the same, so + * this is strict from its first day. + * + * A token in the PDF is a designator when it has a designator's prefix: U, + * SW, J and C are on the board, R, D, Q, L and F are what a part added later + * would carry, and USB is what the v3.0 board's USB-C footprint was (hard + * rule 6). Pin names (S1, IO4) and ESP32 carry none of them. Without + * pdftotext this skips. + */ +import { execFileSync } from "node:child_process"; +import path from "node:path"; + +import { BOM, bom, designators } from "./_bom"; +import { ROOT, compare, done, skip } from "./_lib"; + +const PDF = "hardware/pcb/schematic.pdf"; +const PREFIXES = ["U", "SW", "J", "C", "R", "D", "Q", "L", "F", "USB"]; + +let text: string | undefined; +try { + text = execFileSync("pdftotext", ["-layout", path.join(ROOT, PDF), "-"], { encoding: "utf8" }); +} catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + skip("designators", "pdftotext is not installed (poppler-utils)"); +} +if (text !== undefined) { + const token = new RegExp(`\\b(?:${PREFIXES.join("|")})\\d+\\b`, "g"); + compare( + "designators", + { label: BOM, items: bom().flatMap((r) => designators(r.ref)) }, + { label: PDF, items: new Set(text.match(token)) }, + ); +} +done(); diff --git a/web/scripts/drift/bom.ts b/web/scripts/drift/bom.ts new file mode 100644 index 00000000..64d4e8af --- /dev/null +++ b/web/scripts/drift/bom.ts @@ -0,0 +1,78 @@ +/** + * check:bom: the BOM against the two parts tables in section 1 of + * BUILD_GUIDE.md. Hard rule 2 derives the guide's tables from the BOM, so a + * designator, a part or a quantity on one side only is drift. + * + * On the board a row is its designators, its quantity and its MPN. The names + * are worded apart ("Rotary encoder w/ switch" for "Rotary encoder with push + * switch") and are not compared. The sockets row has no designator: the BOM + * writes "U1 (sockets)" and the guide "—". Off the board there is neither, so + * the rows pair in order, and the guide's part keeps every word of the BOM's: + * "Sacrificial USB cable (it gets cut)" for "USB cable (sacrificial)". A + * hyphen and an en dash are the same in a quantity, so 6-12 is 6–12. + */ +import { BOM, bom, designators, type Row } from "./_bom"; +import { compare, done, read } from "./_lib"; + +const GUIDE = "BUILD_GUIDE.md"; + +// Bold, code, emphasis and link targets out; a link keeps its text. +const plain = (s: string) => + s + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[*`]/g, "") + .trim(); +const dashes = (s: string) => s.replace(/[–—]/g, "-"); + +/** The first table under a heading of the guide, each row keyed by the table's header line. */ +function table(heading: string, columns: string[]): Row[] { + const lines = read(GUIDE).split("\n"); + const at = lines.findIndex((l) => l.trim() === heading); + if (at < 0) throw new Error(`${GUIDE} has no "${heading}"`); + const rows: string[][] = []; + for (const line of lines.slice(at + 1)) { + if (line.startsWith("|")) rows.push(line.trim().replace(/^\||\|$/g, "").split("|").map(plain)); + else if (rows.length || line.startsWith("#")) break; + } + const [head, ...body] = rows.filter((r) => !r.every((c) => /^:?-+:?$/.test(c))); + for (const c of columns) if (!head?.includes(c)) throw new Error(`${GUIDE}, ${heading}: no "${c}" column`); + return body.map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]))); +} + +const rows = bom(); + +// On the board: designators, quantity, MPN. The guide's MPN cell ends in the maker, "(Bourns)". +const ref = (cell: string) => { + const d = designators(dashes(cell)); + return d.length > 1 ? `${d[0]}-${d[d.length - 1]}` : (d[0] ?? "—"); +}; +const part = (r: string, qty: string, mpn: string) => `${ref(r)}: ${dashes(qty)} × ${mpn}`; +compare( + "on the board", + { label: BOM, items: rows.filter((r) => r.category === "pcb").map((r) => part(r.ref, r.qty, r.mpn)) }, + { + label: GUIDE, + items: table("### On the board", ["Ref", "Qty", "MPN (Manufacturer)"]).map((g) => + part(g.Ref, g.Qty, g["MPN (Manufacturer)"].replace(/\s*\([^)]*\)$/, "")), + ), + }, +); + +// Off the board: quantity and part, in order. A guide row is named as the BOM's +// row in its place when it holds every word of that row's part. +const words = (s: string) => s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []; +const off = rows.filter((r) => r.category === "off-board"); +console.log("off the board, rows pair in order, and a guide row with every word of its BOM row's part takes that name"); +compare( + "off the board", + { label: BOM, items: off.map((r) => `${dashes(r.qty)} × ${r.part}`) }, + { + label: GUIDE, + items: table("### Off the board", ["Qty", "Part"]).map((g, i) => { + const has = new Set(words(g.Part)); + const same = off[i] && words(off[i].part).every((w) => has.has(w)); + return `${dashes(g.Qty)} × ${same ? off[i].part : g.Part}`; + }), + }, +); +done(); diff --git a/web/scripts/drift/changelog.ts b/web/scripts/drift/changelog.ts new file mode 100644 index 00000000..894986f0 --- /dev/null +++ b/web/scripts/drift/changelog.ts @@ -0,0 +1,92 @@ +/** + * check:changelog: CHANGELOG.md against docs/releases/. Every release has a + * file there. The root keeps the three newest in full and one line for each + * older release, and release.py cut writes both (docs/RELEASING.md). So the + * newest three are written twice, and an edit to one copy alone is drift. + * + * A section in full equals its file once its relative links point two folders + * up. That is release.py's as_filed, and it is the only rewrite: a link with a + * scheme, an anchor or a leading slash stays as it is. A line under "Older + * releases" is what release.py's older_line makes of its file's heading, so + * the version, the file name and the date have to agree. Every vX.Y.Z.md is in + * the root one way or the other. The -notes and -launch files are not + * changelog sections and are not counted. + */ +import { compare, done, read, tracked } from "./_lib"; + +const CHANGELOG = "CHANGELOG.md"; +const RELEASES = "docs/releases/"; +const OLDER = "## Older releases"; + +// release.py reads \r\n as \n, so a checkout with CRLF compares the same. +const text = (file: string) => read(file).replace(/\r\n/g, "\n"); + +// A release section runs from its "## [X.Y.Z] - date" line to the next "## ", as SECTION in release.py. +const SECTION = /^## \[(?!Unreleased\])([^\]]+)\][^\n]*\n[\s\S]*?(?=^## |(?![\s\S]))/gm; + +// [3.11.0] and [v1.1.0] alike: the file name always carries the v. +const fileOf = (label: string) => `${RELEASES}v${label.replace(/^v+/, "")}.md`; + +// $& is the "](" itself, so this file holds no link for doc-map.ts to follow. +const asFiled = (section: string) => + section.replace(/\n+$/, "").replace(/\]\((?![a-z][a-z0-9+.-]*:|[#/])/g, "$&../../") + "\n"; + +function olderLine(file: string): string { + const m = /^## \[([^\]]+)\]([^\n]*)/.exec(text(file)); + if (!m) return `${file} has no "## [X.Y.Z]" heading`; + return `- [${m[1]}](${fileOf(m[1])})${m[2]}`; +} + +const root = text(CHANGELOG); +const rootLines = root.split("\n"); +const sections = [...root.matchAll(SECTION)]; +const releases = new Set(tracked(RELEASES).filter((f) => /^v\d+\.\d+\.\d+\.md$/.test(f.slice(RELEASES.length)))); + +const same: string[] = []; +for (const s of sections) { + const [label, file] = [s[1], fileOf(s[1])]; + if (!releases.has(file)) { + console.log(`[${label}]: ${file} does not exist`); + continue; + } + const want = asFiled(s[0]).split("\n"); + const have = text(file).split("\n"); + const at = Array.from({ length: Math.max(want.length, have.length) }, (_, i) => i).find((i) => want[i] !== have[i]); + if (at === undefined) { + same.push(label); + continue; + } + // The section's line numbers in the root, counted from 1. as_filed keeps every line where it was. + const start = root.slice(0, s.index).split("\n").length; + // An entry is one long line, so the run shows where in it the two part. + const [a, b] = [want[at], have[at]]; + let col = 0; + while (a !== undefined && b !== undefined && col < a.length && a[col] === b[col]) col++; + const from = Math.max(0, col - 30); + const show = (line: string | undefined) => + line === undefined + ? "(ends before this line)" + : (from ? "…" : "") + JSON.stringify(line.slice(from, from + 80)) + (line.length > from + 80 ? "…" : ""); + console.log(`[${label}]: CHANGELOG.md line ${start + at} and ${file} line ${at + 1} differ from column ${col + 1}`); + console.log(` CHANGELOG.md, links as filed: ${show(a)}`); + console.log(` ${file}: ${show(b)}`); +} +compare( + "sections in full against their files", + { label: CHANGELOG, items: sections.map((s) => s[1]) }, + { label: `${RELEASES}, word for word`, items: same }, +); + +const heading = rootLines.indexOf(OLDER); +const listed = []; +for (const line of heading < 0 ? [] : rootLines.slice(heading + 1)) { + if (line.startsWith("## ")) break; + if (line.startsWith("- ")) listed.push(line); +} +const inFull = new Set(sections.map((s) => fileOf(s[1]))); +compare( + "older releases against their files", + { label: `lines under "${OLDER.slice(3)}"`, items: listed }, + { label: `the other files in ${RELEASES}, by their headings`, items: [...releases].filter((f) => !inFull.has(f)).map(olderLine) }, +); +done(); diff --git a/web/scripts/drift/editions.ts b/web/scripts/drift/editions.ts new file mode 100644 index 00000000..5298ff33 --- /dev/null +++ b/web/scripts/drift/editions.ts @@ -0,0 +1,82 @@ +/** + * check:editions: the cards on /editions against the folders under + * firmware/bundles/. It checks names. Versions, and that each named image has + * its files, are check_versions.py's. + * + * Every card has a bundle, and every bundle is a card or a try-out on + * /features. Two kinds of card have no bundle. The default is the card whose + * id is the variant the core reports with no bundle, PF_VARIANT in + * net_config.h. A card with a github field is released from that repository + * (EditionCard.tsx reads its latest tag there), so it is built there too. + * Both are read from the tree and printed. + * + * The folders in web/public/flash/bin/ are the images the cards, the try-outs + * and the flasher's manifest.json name: shelf.sh stages one per name and + * version, and nothing else writes there. The EDITIONS tuples in release.py + * and check_versions.py are the cards built here bar the default, the list + * both scripts keep by hand. + */ +import path from "node:path"; + +import { EDITIONS } from "../../src/app/editions/editions-data"; +import { FEATURES } from "../../src/app/features/features-data"; +import { compare, done, read, tracked } from "./_lib"; + +const NET_CONFIG = "firmware/patternflow/net_config.h"; +const BUNDLES = "firmware/bundles/"; +const BIN = "web/public/flash/bin/"; +const MANIFEST = "web/public/flash/manifest.json"; +const SITE = new URL("https://patternflow.work/"); +const SCRIPTS = ["firmware/toolchain/release.py", "firmware/toolchain/check_versions.py"]; + +/** The folder under /flash/bin/ a URL on the site points into. Undefined for a URL hosted elsewhere. */ +function binFolder(url: string): string | undefined { + const u = new URL(url, SITE); + return u.origin === SITE.origin ? /^\/flash\/bin\/([^/]+)\//.exec(u.pathname)?.[1] : undefined; +} + +/** The first folder of each tracked path under dir. */ +const folders = (dir: string) => + new Set(tracked(dir).map((f) => f.slice(dir.length).split("/")).filter((parts) => parts.length > 1).map((parts) => parts[0])); + +const core = /^#define PF_VARIANT "([^"]+)"/m.exec(read(NET_CONFIG))?.[1]; +if (!core) throw new Error(`${NET_CONFIG}: no #define PF_VARIANT "..." line`); +const elsewhere = EDITIONS.filter((e) => e.github); +const built = EDITIONS.filter((e) => e.id !== core && !e.github).map((e) => e.id); +const tryOuts = FEATURES.flatMap((f) => (f.tryOut ? [f.tryOut.url] : [])); +// shelf.sh names a folder -. A try-out URL of another shape stays whole, so the run shows it. +const tryOutNames = tryOuts.map((url) => binFolder(url)?.replace(/-v\d+\.\d+\.\d+$/, "") ?? url); +console.log(`the default, by PF_VARIANT in net_config.h: ${core}`); +console.log(`cards released from another repository, by their github field: ${elsewhere.map((e) => e.id).join(", ") || "(none)"}`); +console.log(`try-outs, by tryOut in features-data.ts: ${tryOutNames.join(", ") || "(none)"}`); + +compare( + "cards and try-outs against bundles", + { label: "cards built here, and try-outs", items: new Set([...built, ...tryOutNames]) }, + { label: BUNDLES, items: folders(BUNDLES) }, +); + +const manifest = JSON.parse(read(MANIFEST)) as { builds: { parts: { path: string }[] }[] }; +// A part's path is relative to the manifest, which the site serves at /flash/manifest.json. +const manifestUrl = new URL("flash/manifest.json", SITE); +const named = [ + ...EDITIONS.flatMap((e) => (e.hosted ? [e.hosted.url] : [])), + ...tryOuts, + ...manifest.builds.flatMap((b) => b.parts.map((p) => new URL(p.path, manifestUrl).href)), +].flatMap((url) => binFolder(url) ?? []); +compare( + "images against what names them", + { label: BIN, items: folders(BIN) }, + { label: "cards, try-outs and manifest.json", items: new Set(named) }, +); + +for (const file of SCRIPTS) { + const tuple = /^EDITIONS = \(([^)]*)\)/m.exec(read(file))?.[1]; + if (tuple === undefined) throw new Error(`${file}: no EDITIONS = (...) line`); + compare( + `EDITIONS in ${path.basename(file)} against the cards`, + { label: file, items: [...tuple.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]) }, + { label: "cards built here", items: built }, + ); +} +done(); diff --git a/web/scripts/drift/features.ts b/web/scripts/drift/features.ts new file mode 100644 index 00000000..6c314734 --- /dev/null +++ b/web/scripts/drift/features.ts @@ -0,0 +1,29 @@ +/** + * check:features: the rows of /features against the feature folders. Every + * id in features-data.ts is a directory under firmware/patternflow/features/, + * and every directory there has a row. The ids that are not folders are the + * ones the comment on Feature.id names in quotes, today "core", the panel + * itself. They are read from there, not copied here. + */ +import ts from "typescript"; + +import { FEATURES } from "../../src/app/features/features-data"; +import { compare, done, read, tracked } from "./_lib"; + +const DATA = "web/src/app/features/features-data.ts"; +const DIR = "firmware/patternflow/features/"; + +const source = ts.createSourceFile(DATA, read(DATA), ts.ScriptTarget.Latest, true); +const type = source.statements.find((s): s is ts.TypeAliasDeclaration => ts.isTypeAliasDeclaration(s) && s.name.text === "Feature"); +const id = type && ts.isTypeLiteralNode(type.type) ? type.type.members.find((m) => m.name?.getText() === "id") : undefined; +const comment = id ? ts.getJSDocCommentsAndTags(id).map((d) => ts.getTextOfJSDocComment(d.comment) ?? "").join(" ") : ""; +const notFolders = new Set([...comment.matchAll(/"([^"]+)"/g)].map((m) => m[1])); +console.log(`ids that are not folders, by the comment on Feature.id: ${[...notFolders].join(", ") || "(none)"}`); + +const folders = tracked(DIR).map((f) => f.slice(DIR.length).split("/")).filter((parts) => parts.length > 1).map((parts) => parts[0]); +compare( + "ids against folders", + { label: DATA, items: FEATURES.map((f) => f.id).filter((x) => !notFolders.has(x)) }, + { label: DIR, items: new Set(folders) }, +); +done(); diff --git a/web/scripts/drift/links-absolute.ts b/web/scripts/drift/links-absolute.ts new file mode 100644 index 00000000..d55f65f4 --- /dev/null +++ b/web/scripts/drift/links-absolute.ts @@ -0,0 +1,15 @@ +/** + * check:links-absolute (docs/rfc-cleanup.md §2.4): every GitHub URL into a + * file of this repository, against the tree at HEAD, or against the tag it + * names. The doc map already finds and resolves each of them, so this runs + * its check mode, which writes nothing; doc-map.ts says what fails and what + * is skipped. + */ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const docMap = fileURLToPath(new URL("../doc-map.ts", import.meta.url)); +// execArgv carries tsx's loader, so doc-map.ts runs the way this file does. +const run = spawnSync(process.execPath, [...process.execArgv, docMap, "--check", "absolute"], { stdio: "inherit" }); +if (run.error) console.error(`links-absolute: ${run.error.message}`); +process.exit(run.status ?? 1); diff --git a/web/scripts/drift/markers.ts b/web/scripts/drift/markers.ts new file mode 100644 index 00000000..24cf696d --- /dev/null +++ b/web/scripts/drift/markers.ts @@ -0,0 +1,44 @@ +/** + * The markers check (docs/rfc-cleanup.md §2.5): no TODO or FIXME is added + * during the cleanup. First-party code had none when step 3 was planned, and + * none at seven commits sampled on dev from May to October (finding 11 of + * docs/investigations/2026-10-cleanup-step3-plan.md). So no marker is allowed, + * and one found is drift. `npm run check:drift` runs it with the others, and + * `npx tsx scripts/drift.ts markers` alone. + * + * It reads every tracked text file under firmware/, web/src/, web/scripts/, + * tools/ and integrations/. The two words count in capitals, at the start of a + * word: the guide's data-s="todo" names a step not reached yet, and MASTODON + * holds no marker. Vendored code is upstream's, and doc-map.ts skips the same + * three folders. hub75/ keeps upstream's one TODO, which its UPSTREAM.md + * records, because editing it would add a hunk to patternflow.patch. This file + * names both words, so it skips itself as well. + */ +import { compare, done, read, tracked } from "./_lib"; + +const UNDER = ["firmware/", "web/src/", "web/scripts/", "tools/", "integrations/"]; +const VENDORED = /(^|\/)(hub75|webserver|pubsubclient)\//; +const SELF = "web/scripts/drift/markers.ts"; +const MARKER = /(^|[^A-Za-z])(TODO|FIXME)/; + +const files = UNDER.flatMap((under) => tracked(under)).filter((f) => f !== SELF); +const vendored = files.filter((f) => VENDORED.test(f)); +let binary = 0; +const found: string[] = []; +for (const f of files) { + if (VENDORED.test(f)) continue; + const text = read(f); + // A NUL is how git tells a binary file from text, and a PNG has one. + if (text.includes("\0")) { + binary++; + continue; + } + text.split("\n").forEach((line, i) => { + if (MARKER.test(line)) found.push(`${f}:${i + 1}`); + }); +} + +console.log(`read ${files.length - vendored.length - binary} files under ${UNDER.join(", ")}`); +console.log(`not read: ${vendored.length} vendored, ${binary} binary, and this file`); +compare("TODO and FIXME", { label: "markers found", items: found }, { label: "markers allowed", items: [] }); +done(); diff --git a/web/scripts/drift/midi-vectors.ts b/web/scripts/drift/midi-vectors.ts new file mode 100644 index 00000000..fd1c38cc --- /dev/null +++ b/web/scripts/drift/midi-vectors.ts @@ -0,0 +1,10 @@ +/** + * check:midi-vectors: docs/midi-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md), built with the midi feature. The sender is + * _rtpmidi.ts, one RTP-MIDI session a vector. + */ +import { done } from "./_lib"; +import { midi } from "./_rtpmidi"; +import { replay } from "./_vectors"; + +replay("docs/midi-vectors", { midi }).then(done); diff --git a/web/scripts/drift/mqtt-vectors.ts b/web/scripts/drift/mqtt-vectors.ts new file mode 100644 index 00000000..25d30449 --- /dev/null +++ b/web/scripts/drift/mqtt-vectors.ts @@ -0,0 +1,10 @@ +/** + * check:mqtt-vectors: docs/mqtt-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md), and a broker of its own for each (_broker.ts). + * HTTP, a hand and the clock are _vectors.ts's own. + */ +import { mqtt } from "./_broker"; +import { done } from "./_lib"; +import { replay } from "./_vectors"; + +replay("docs/mqtt-vectors", { mqtt }).then(done); diff --git a/web/scripts/drift/osc-vectors.ts b/web/scripts/drift/osc-vectors.ts new file mode 100644 index 00000000..4f05c300 --- /dev/null +++ b/web/scripts/drift/osc-vectors.ts @@ -0,0 +1,10 @@ +/** + * check:osc-vectors: docs/osc-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md), built with the osc feature. The sender is + * _osc.ts, one UDP socket a vector. + */ +import { done } from "./_lib"; +import { osc } from "./_osc"; +import { replay } from "./_vectors"; + +replay("docs/osc-vectors", { osc }).then(done); diff --git a/web/scripts/drift/presets.ts b/web/scripts/drift/presets.ts new file mode 100644 index 00000000..de7e7774 --- /dev/null +++ b/web/scripts/drift/presets.ts @@ -0,0 +1,69 @@ +/** + * check:presets: how a web preset reaches a panel. Each pattern is a file in + * web/src/lib/presets/ that index.ts imports, so a file it does not import + * never shows on the site, and an import with no file is a preset that is + * gone. A preset with a firmware twin then gets onto a board one of two ways: + * pattern_registry.h compiles it in (Origin, the failsafe), or it ships in the + * Basics pack, whose basics.json keys each module on its twin's pattern + * number, the num make_pack.py reads out of the web file. A twin in neither, + * or a pack entry whose number no web preset has, is drift. A twin compiled + * in and packed too is on the panel twice, and shows as one extra. + * + * Which presets have no twin on purpose is check_presets.py's to say, in + * WEB_ONLY and FIRMWARE_ONLY, with a reason for each. The two sets are read + * out of that file, not copied here. Whether the twins are in step with each + * other stays its job; firmware-checks.yml runs it. + */ +import ts from "typescript"; + +import { compare, done, read, tracked } from "./_lib"; + +const WEB = "web/src/lib/presets/"; +const INDEX = `${WEB}index.ts`; +const TWINS = "firmware/toolchain/check_presets.py"; +const REGISTRY = "firmware/patternflow/pattern_registry.h"; +const PACK = "web/public/packs/basics.json"; + +// The web spells a slug with dashes and firmware with underscores. Both are +// compared in the firmware spelling, the one check_presets.py lists. +const slug = (s: string) => s.replace(/-/g, "_"); + +/** One of check_presets.py's set literals, NAME = {"0516", ...}. */ +function pySet(name: string): Set { + const m = read(TWINS).match(new RegExp(`^${name}\\s*=\\s*\\{([^}]*)\\}`, "m")); + if (!m) throw new Error(`${TWINS} has no ${name} = {...}`); + return new Set([...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1])); +} +const webOnly = pySet("WEB_ONLY"); +const firmwareOnly = pySet("FIRMWARE_ONLY"); +console.log(`web-only, by ${TWINS}: ${[...webOnly].sort().join(", ")}`); +console.log(`firmware-only, by ${TWINS}: ${[...firmwareOnly].sort().join(", ")}`); + +const files = tracked(WEB) + .map((f) => f.slice(WEB.length).match(/^pattern-([^/]+)\.ts$/)?.[1]) + .filter((x): x is string => x !== undefined); +const index = ts.createSourceFile(INDEX, read(INDEX), ts.ScriptTarget.Latest); +const imports = index.statements + .filter(ts.isImportDeclaration) + .map((s) => (ts.isStringLiteral(s.moduleSpecifier) ? s.moduleSpecifier.text.match(/^\.\/pattern-(.+)$/)?.[1] : undefined)) + .filter((x): x is string => x !== undefined); +compare("files against index.ts", { label: `${WEB}pattern-*.ts`, items: files.map(slug) }, { label: `${INDEX} imports`, items: imports.map(slug) }); + +// The pack's link back to a web preset, read the way make_pack.py reads it. +const byNum = new Map(); +for (const f of files) { + const num = read(`${WEB}pattern-${f}.ts`).match(/^\s*num:\s*([0-9.]+)/m)?.[1]; + if (num) byNum.set(Number(num), slug(f)); +} +const compiled = [...read(REGISTRY).matchAll(/^\s*#include\s+"presets\/preset_([^"]+)\.h"/gm)].map((m) => m[1]); +const { presets } = JSON.parse(read(PACK)) as { presets?: Record }; +if (!presets) throw new Error(`${PACK} has no "presets"`); +const packed = Object.entries(presets).map(([module, num]) => byNum.get(num) ?? `${module} (num ${num}, no web preset)`); +console.log(`compiled in by ${REGISTRY}: ${compiled.join(", ")}`); +console.log(`in ${PACK} by number: ${packed.length}`); +compare( + "twins against what reaches a panel", + { label: "web presets with a firmware twin", items: files.map(slug).filter((x) => !webOnly.has(x)) }, + { label: `${REGISTRY} and ${PACK}`, items: [...compiled.filter((x) => !firmwareOnly.has(x)), ...packed] }, +); +done(); diff --git a/web/scripts/drift/rest-vectors.ts b/web/scripts/drift/rest-vectors.ts new file mode 100644 index 00000000..1c7aca6c --- /dev/null +++ b/web/scripts/drift/rest-vectors.ts @@ -0,0 +1,9 @@ +/** + * check:rest-vectors: docs/rest-api-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md). REST needs no sender of its own: _vectors.ts + * speaks HTTP, and a hand and the clock are HTTP too. + */ +import { done } from "./_lib"; +import { replay } from "./_vectors"; + +replay("docs/rest-api-vectors").then(done); diff --git a/web/scripts/drift/roadmap.ts b/web/scripts/drift/roadmap.ts new file mode 100644 index 00000000..3557e090 --- /dev/null +++ b/web/scripts/drift/roadmap.ts @@ -0,0 +1,78 @@ +/** + * check:roadmap: the status of each /roadmap node against the state of the + * issues it names. A done node with an open issue is drift, and so is a + * planned node with a closed one. The issues are in the repository the page's + * own links point at, REPO in roadmap-data.ts, and gh reads each state. An + * issue GitHub answers 404 or 410 for is drift too: the page names an issue + * nobody can open. + * + * An issue can stay open after its node is done, when the work shipped and the + * issue holds something back for later. Such an issue goes in OPEN_ON_PURPOSE + * below, with the maintainer's reason, and an entry whose issue has closed is + * drift as well. A gh that cannot read the repository here (not installed, no + * login, no network, no access) skips. + */ +import { execFileSync } from "node:child_process"; + +import { NODES } from "../../src/app/roadmap/roadmap-data"; +import { compare, done, read, skip } from "./_lib"; + +const DATA = "web/src/app/roadmap/roadmap-data.ts"; + +/** Issues on done nodes that stay open on purpose, each with the maintainer's reason. */ +const OPEN_ON_PURPOSE: Record = {}; + +const href = /^const REPO = '([^']+)'/m.exec(read(DATA))?.[1]; +if (!href) throw new Error(`${DATA}: no const REPO = '...' line`); +const repo = new URL(href).pathname.replace(/^\/|\/$/g, ""); + +/** gh's own words when it fails: the first line of what it printed. */ +const why = (e: unknown) => + String((e as { stderr?: string }).stderr ?? (e as Error).message) + .split("\n") + .find((line) => line.trim()) ?? "gh failed"; + +const numbers = [...new Set(NODES.flatMap((n) => n.issues ?? []))].sort((a, b) => a - b); +const state = new Map(); +const ask = (path: string, ...args: string[]) => + execFileSync("gh", ["api", path, ...args], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }).trim(); +let readable = true; +try { + ask(`repos/${repo}`, "--silent"); + for (const n of numbers) { + try { + state.set(n, ask(`repos/${repo}/issues/${n}`, "--jq", ".state")); + } catch (e) { + // A 404 or a 410 says the issue is not there, which is the page's to fix. Any other failure is gh's. + if (!/\(HTTP (404|410)\)/.test(why(e))) throw e; + state.set(n, why(e)); + } + } +} catch (e) { + readable = false; + skip("issues", (e as NodeJS.ErrnoException).code === "ENOENT" ? "gh is not installed" : `gh cannot read ${repo}: ${why(e)}`); +} + +if (readable) { + console.log(`issues on ${repo}, by gh:`); + for (const n of numbers) { + const nodes = NODES.filter((node) => node.issues?.includes(n)).map((node) => `${node.id} (${node.status})`); + console.log(` #${n} ${state.get(n)}: ${nodes.join(", ")}`); + } + + const kept = Object.entries(OPEN_ON_PURPOSE).map(([n, reason]) => `#${n} (${reason})`); + console.log(`open on purpose, by OPEN_ON_PURPOSE: ${kept.join(", ") || "(none)"}`); + + const on = (status: string) => [...new Set(NODES.filter((n) => n.status === status).flatMap((n) => n.issues ?? []))]; + compare( + "done nodes with an open issue", + { label: "issues on done nodes, not closed", items: on("done").filter((n) => state.get(n) !== "closed").map((n) => `#${n}`) }, + { label: "OPEN_ON_PURPOSE in roadmap.ts", items: Object.keys(OPEN_ON_PURPOSE).map((n) => `#${n}`) }, + ); + compare( + "planned nodes with a closed issue", + { label: "issues on planned nodes", items: on("planned").map((n) => `#${n}`) }, + { label: "of those, open", items: on("planned").filter((n) => state.get(n) === "open").map((n) => `#${n}`) }, + ); +} +done(); diff --git a/web/scripts/drift/shared.ts b/web/scripts/drift/shared.ts new file mode 100644 index 00000000..c8b32504 --- /dev/null +++ b/web/scripts/drift/shared.ts @@ -0,0 +1,58 @@ +/** + * check:shared: the two files gen:shared writes from shared/constants.json, + * against what it writes now. A hand edit to core_shared.h or constants.ts + * fails here, and so does a change to the JSON nobody regenerated. + * + * Two copies cannot be generated, so this reads them. abi/pf_params.h clamps + * the bus to a literal 1000 and is frozen: installed modules read it without + * recompiling. build_module.py names the panel's 128 and 64 itself, and the + * module cache hashes it. The copies written for other readers, the REST + * contract's 24 and the Ableton bridge's 48, stay by hand and are not read. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { render, SOURCE } from "../gen-shared"; +import { compare, done, read, ROOT } from "./_lib"; + +let stale = 0; +for (const [file, want] of Object.entries(render())) { + const have = fs.existsSync(path.join(ROOT, file)) ? read(file) : ""; + if (have === want) { + console.log(`${file}: same as gen:shared writes`); + continue; + } + stale++; + console.log(`${file}: stale, run npm run gen:shared in web/`); + const [a, b] = [want.split("\n"), have.split("\n")]; + const lines = [...Array(Math.max(a.length, b.length)).keys()].filter((i) => a[i] !== b[i]); + for (const i of lines.slice(0, 10)) { + console.log(` line ${i + 1}, gen:shared writes: ${a[i] ?? "(no line)"}`); + console.log(` line ${i + 1}, the file has: ${b[i] ?? "(no line)"}`); + } + if (lines.length > 10) console.log(` and ${lines.length - 10} more lines`); +} + +const data = JSON.parse(read(SOURCE)); + +const ABI = "firmware/patternflow/abi/pf_params.h"; +const body = read(ABI) + .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "") + .match(/\bunit1000\s*\([^)]*\)\s*\{([^}]*)\}/)?.[1]; +if (body === undefined) console.log(`no unit1000() in ${ABI}`); +compare( + "the bus maximum", + { label: `${SOURCE} BUS_MAX`, items: [String(data.bus.BUS_MAX)] }, + { label: `the literals in unit1000(), ${ABI}`, items: new Set(body?.match(/\b\d+(\.\d+)?f?\b/g)?.map((n) => String(parseFloat(n)))) }, +); + +const BUILD = "firmware/toolchain/build_module.py"; +const panel = data.panel.PANEL_GEOMETRIES[data.panel.PANEL_GEOM_DEFAULT]; +compare( + "the default panel's canvas", + { label: `${SOURCE} ${data.panel.PANEL_GEOM_DEFAULT}`, items: [`PANEL_W ${panel.physW * panel.chain}`, `PANEL_H ${panel.physH}`] }, + { label: BUILD, items: [...read(BUILD).matchAll(/^(PANEL_[WH]) = (\d+)/gm)].map((m) => `${m[1]} ${m[2]}`) }, +); + +done(); +if (stale) process.exitCode = 1; diff --git a/web/scripts/drift/virtual-panel.ts b/web/scripts/drift/virtual-panel.ts new file mode 100644 index 00000000..9a45888f --- /dev/null +++ b/web/scripts/drift/virtual-panel.ts @@ -0,0 +1,115 @@ +/** + * check:virtual-panel: the virtual panel runs every preset in + * firmware/patternflow/presets/ without a thrown error, the half of step 4's + * "done when" that no vector suite covers (docs/rfc-cleanup.md §2.6). + * + * The presets are the headers there, less the FIRMWARE_ONLY check_presets.py + * names, and each has to be on the list GET /api/patterns gives on the + * default build with the Basics pack. A preset compiled in is listed by the + * NAME in its header. A module is listed by its slug, which basics.json keys + * on its web twin's num, the link check:presets reads. Each listed preset then + * gets a panel of its own: select it over REST, two seconds of panel time, a + * 128×64 PNG from /frame.png and no error in /vp/state. Everything goes + * through the panel's HTTP port. + */ +import zlib from "node:zlib"; + +import { PANEL_GEOM_DEFAULT, PANEL_GEOMETRIES } from "@/lib/shared/constants"; + +import { start } from "../../../tools/virtual-panel/panel"; +import { compare, done, read, tracked } from "./_lib"; + +const HEADERS = "firmware/patternflow/presets/"; +const WEB = "web/src/lib/presets/"; +const TWINS = "firmware/toolchain/check_presets.py"; +const PACK = "web/public/packs/basics.json"; +const { resW: WIDTH, resH: HEIGHT } = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT]; +const RUN_MS = 2000; + +type Listed = { index: number; name: string; module: string | null }; +type State = { mode: string; pattern: { index: number }; error: string | null }; + +const literal = read(TWINS).match(/^FIRMWARE_ONLY\s*=\s*\{([^}]*)\}/m)?.[1]; +if (literal === undefined) throw new Error(`${TWINS} has no FIRMWARE_ONLY = {...}`); +const firmwareOnly = new Set([...literal.matchAll(/"([^"]+)"/g)].map((m) => m[1])); +console.log(`firmware-only, by ${TWINS}: ${[...firmwareOnly].sort().join(", ")}`); +const headers = tracked(HEADERS) + .map((f) => f.slice(HEADERS.length).match(/^preset_([^/]+)\.h$/)?.[1]) + .filter((x): x is string => x !== undefined && !firmwareOnly.has(x)); + +// What the panel lists a header under: its NAME for a preset, its num for a module. +const byName = new Map(); +const byNum = new Map(); +for (const h of headers) { + const name = read(`${HEADERS}preset_${h}.h`).match(/\bNAME\s*=\s*"([^"]*)"/)?.[1]; + if (name !== undefined) byName.set(name, h); + const twin = `${WEB}pattern-${h.replace(/_/g, "-")}.ts`; + const num = tracked(twin).length ? read(twin).match(/^\s*num:\s*([0-9.]+)/m)?.[1] : undefined; + if (num) byNum.set(Number(num), h); +} +const { presets: nums } = JSON.parse(read(PACK)) as { presets: Record }; +const header = ({ name, module }: Listed) => + module === null ? (byName.get(name) ?? `${name} (no header has this NAME)`) : (byNum.get(nums[module]) ?? `${module} (no header by num)`); + +/** A fresh panel on the manual clock, any free port, closed once talk() is done with it. */ +async function panel(talk: (get: (path: string) => Promise) => Promise): Promise { + const { ports, close } = await start({ clock: "manual", ports: { "*": 0 } }); + try { + return await talk((path) => fetch(`http://127.0.0.1:${ports.http}${path}`)); + } finally { + await close(); + } +} +const json = async (reply: Promise) => (await reply).json() as Promise; + +/** Width, height and lit pixels of an 8-bit RGB PNG with unfiltered rows, the one tools/virtual-panel/png.ts writes. */ +function readPng(file: Buffer) { + if (file.toString("latin1", 1, 4) !== "PNG" || file.toString("latin1", 12, 16) !== "IHDR") throw new Error("not a PNG"); + const [width, height] = [file.readUInt32BE(16), file.readUInt32BE(20)]; + if (file[24] !== 8 || file[25] !== 2) throw new Error(`bit depth ${file[24]}, colour type ${file[25]}, not 8-bit RGB`); + const idat: Buffer[] = []; + for (let at = 8; at < file.length; at += 12 + file.readUInt32BE(at)) + if (file.toString("latin1", at + 4, at + 8) === "IDAT") idat.push(file.subarray(at + 8, at + 8 + file.readUInt32BE(at))); + const rows = zlib.inflateSync(Buffer.concat(idat)); + const stride = width * 3 + 1; + if (rows.length !== stride * height) throw new Error(`${rows.length} bytes of rows for ${width}×${height}`); + let lit = 0; + for (let y = 0; y < height; y++) { + if (rows[y * stride]) throw new Error(`row ${y} is filtered`); + for (let x = 0; x < width; x++) lit += Number(rows.subarray(y * stride + 1 + x * 3, y * stride + 4 + x * 3).some(Boolean)); + } + return { width, height, lit }; +} + +async function main() { + const listed = await panel(async (get) => (await json<{ patterns: Listed[] }>(get("/api/patterns"))).patterns); + compare("headers against the panel's list", { label: HEADERS, items: headers }, { label: "GET /api/patterns", items: listed.map(header) }); + + const ran: string[] = []; + for (const p of listed) { + const h = header(p); + const why = await panel(async (get) => { + const picked = await get(`/api/patterns/select?index=${p.index}`); + if (!picked.ok) return `select answered ${picked.status}`; + const state = await json(get(`/vp/advance?ms=${RUN_MS}`)); + if (state.pattern.index !== p.index) return `running index ${state.pattern.index}, not ${p.index}`; + if (state.mode !== "run") return `the panel is in ${state.mode}`; + if (state.error !== null) return `threw: ${state.error.split("\n")[0]}`; + const frame = await get("/frame.png"); + if (frame.headers.get("content-type") !== "image/png") return `/frame.png is ${frame.headers.get("content-type")}`; + const png = readPng(Buffer.from(await frame.arrayBuffer())); + if (png.width !== WIDTH || png.height !== HEIGHT) return `/frame.png is ${png.width}×${png.height}`; + console.log(` ${p.index} ${h}: ${RUN_MS} ms, ${png.width}×${png.height}, ${png.lit} lit`); + return null; + }).catch((e: Error) => e.message); + if (why) console.log(` ${p.index} ${h}: ${why}`); + else ran.push(h); + } + compare( + "presets that run", + { label: "on the panel's list", items: listed.map(header) }, + { label: `${RUN_MS} ms, ${WIDTH}×${HEIGHT}, no error`, items: ran }, + ); + done(); +} +void main(); diff --git a/web/scripts/gen-shared.ts b/web/scripts/gen-shared.ts new file mode 100644 index 00000000..e28df517 --- /dev/null +++ b/web/scripts/gen-shared.ts @@ -0,0 +1,106 @@ +/** + * gen:shared: writes shared/constants.json out as the two files that read it, + * firmware/patternflow/src/core_shared.h and web/src/lib/shared/constants.ts + * (docs/rfc-cleanup.md §2.5). The lab counted 20 detents a turn until 31 July + * 2026, against the encoder's 24, and the community sandbox until 4 September. + * Each copy was found and fixed by hand. + * + * A key in the JSON is the constant's name on both sides: DETENTS_PER_TURN is + * PF_SHARED_DETENTS_PER_TURN in the header and DETENTS_PER_TURN in the module. + * The prefix keeps the macros clear of the names config.h already defines, and + * of every feature's PF__ flags, which check_boundaries.py refuses in + * src/. A key "//" is a note, and each group's note goes over its lines. + * + * check:shared (scripts/drift/shared.ts) imports render() and compares, so the + * files on disk must be exactly what this writes. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +export const SOURCE = "shared/constants.json"; +export const HEADER = "firmware/patternflow/src/core_shared.h"; +export const MODULE = "web/src/lib/shared/constants.ts"; + +type Group = Record; + +const fail = (what: string): never => { + throw new Error(`${SOURCE}: ${what}`); +}; +const whole = (v: unknown, at: string) => + Number.isInteger(v) && (v as number) > 0 ? (v as number) : fail(`${at} is ${JSON.stringify(v)}, not a whole number above 0`); + +/** A note as comment lines, wrapped at 80 columns. */ +function comment(note: unknown): string[] { + if (typeof note !== "string") return []; + const lines: string[] = []; + let line = "//"; + for (const word of note.split(/\s+/)) { + if (line !== "//" && line.length + 1 + word.length > 80) { + lines.push(line); + line = "//"; + } + line += ` ${word}`; + } + return [...lines, line]; +} + +const BANNER = [ + `// GENERATED by web/scripts/gen-shared.ts from ${SOURCE}. Do not edit.`, + ...comment("Change the JSON and run `npm run gen:shared` in web/. check:shared fails while this file says anything else."), +]; + +/** Both files as gen:shared writes them, keyed by their path from the repository root. */ +export function render(json = fs.readFileSync(path.join(ROOT, SOURCE), "utf8")): Record { + const data = JSON.parse(json) as Record; + const h = [...BANNER, "//", "// License: MIT", "#pragma once"]; + const ts = [...BANNER]; + const names = new Set(["PANEL_GEOMETRIES", "PANEL_GEOM_DEFAULT"]); + + const panel = data.panel ?? fail("no panel group"); + const geometries = Object.entries((panel.PANEL_GEOMETRIES ?? fail("no panel.PANEL_GEOMETRIES")) as Record); + const fallback = panel.PANEL_GEOM_DEFAULT; + if (!geometries.some(([name]) => name === fallback)) fail(`panel.PANEL_GEOM_DEFAULT ${JSON.stringify(fallback)} is not in PANEL_GEOMETRIES`); + const ids = new Set(); + h.push("", ...comment(panel["//"])); + ts.push("", ...comment(panel["//"]), "export const PANEL_GEOMETRIES = {"); + for (const [name, g] of geometries) { + if (!/^\w+$/.test(name)) fail(`the geometry ${JSON.stringify(name)} is not a C name`); + const at = `panel.PANEL_GEOMETRIES.${name}`; + const [id, physW, physH, chain] = (["id", "physW", "physH", "chain"] as const).map((k) => whole(g[k], `${at}.${k}`)); + if (ids.has(id)) fail(`${at}.id ${id} is taken by another geometry`); + ids.add(id); + // config.h refuses a build where these two disagree with the physical size. + const fields = { physW, physH, chain, resW: physW * chain, resH: physH }; + h.push(`#define PF_SHARED_PANEL_GEOM_${name} ${id}`); + for (const [k, v] of Object.entries(fields)) h.push(`#define PF_SHARED_PANEL_GEOM_${name}_${k.replace(/[A-Z]/g, "_$&").toUpperCase()} ${v}`); + ts.push(` "${name}": { id: ${id}, ${Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join(", ")} },`); + } + h.push(`#define PF_SHARED_PANEL_GEOM_DEFAULT PF_SHARED_PANEL_GEOM_${fallback}`); + ts.push("} as const;", `export const PANEL_GEOM_DEFAULT = "${fallback}";`); + + for (const [group, values] of Object.entries(data)) { + if (group === "//" || group === "panel") continue; + h.push("", ...comment(values["//"])); + ts.push("", ...comment(values["//"])); + for (const [name, v] of Object.entries(values)) { + if (name === "//") continue; + if (!/^[A-Z][A-Z0-9_]*$/.test(name)) fail(`${group}.${name} is not a constant's name`); + if (names.has(name)) fail(`${group}.${name} is named twice`); + names.add(name); + const n = whole(v, `${group}.${name}`); + h.push(`#define PF_SHARED_${name} ${n}`); + ts.push(`export const ${name} = ${n};`); + } + } + return { [HEADER]: `${h.join("\n")}\n`, [MODULE]: `${ts.join("\n")}\n` }; +} + +if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + for (const [file, text] of Object.entries(render())) { + fs.mkdirSync(path.dirname(path.join(ROOT, file)), { recursive: true }); + fs.writeFileSync(path.join(ROOT, file), text); + console.log(`wrote ${file}`); + } +} diff --git a/web/scripts/human-lock.ts b/web/scripts/human-lock.ts new file mode 100644 index 00000000..bfd412a2 --- /dev/null +++ b/web/scripts/human-lock.ts @@ -0,0 +1,245 @@ +/** + * The lock on the maintainer's own writing: `npm run check:human-lock` in web/ + * (docs/rfc-cleanup.md §2.9). A pull request may change the markup, layout and + * link and image paths in locked text, not a word of it. A typo is reported in + * the PR description, not fixed. + * + * Locked: web/content/journal/, docs/manifesto.md, docs/releases/*.md, three + * README sections (README_SECTIONS, each to the next ## heading), Part 1 of + * each docs/rfc-*.md, each roadmap node's detail and detailKo (keyed by its id), + * and each path in docs/.human-written, a directory locking what is under it. + * + * Each is compared as plain text, the base's (scripts/lib/pr-base.ts) against + * the working tree's, with the Markdown and MDX syntax and all whitespace gone: + * marks, fences (not the code in them), pipes, rules, comments, entities, tags, + * link targets, URLs and front matter keys. A tag's alt, title and aria-label + * stay, and so do front matter values that are not a path or a URL. + * + * A locked file the base lacks is new, and passes. Text that leaves its place + * passes only if it turns up word for word in a file this diff adds or changes: + * a move, which needs the maintainer's approval on the PR. Taking a path off + * docs/.human-written fails, since only the maintainer does that. + */ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +import { ROOT, changedSince, findBase, git, readAt, readNow } from "./lib/pr-base"; + +const README_SECTIONS = ["Where it began", "The community responded", "Story so far"]; +const ROADMAP = "web/src/app/roadmap/roadmap-data.ts"; +const LIST = "docs/.human-written"; + +// ── Plain text ── +// The characters kept, and the offset of each in the text they came from. +// Syntax is blanked to spaces rather than cut, so every offset stays where it +// was and a changed word can be shown in its line. +type Plain = { text: string; at: number[] }; +const blank = (s: string) => s.replace(/[^\n]/g, " "); +const keep = (_m: string, open: string, text: string, close: string) => blank(open) + text + blank(close); +// A tag goes, but the words in its alt, title and aria-label stay. +const tag = (m: string) => + m.replace(/\b(alt|title|aria-label)=(["'])([^"']*)\2|[^\n]/g, (_x, a?: string, q?: string, w?: string) => (a ? blank(`${a}=${q}`) + w + blank(q!) : " ")); +function frontMatter(m: string, body: string): string { + const values = body.split("\n").map((line) => { + const kv = /^(\s*[\w-]+:\s*)(["']?)(.*?)(\2\s*)$/.exec(line); + return !kv ? line : blank(kv[1] + kv[2]) + (/^(?:\.{0,2}\/|https?:)/.test(kv[3]) ? blank(kv[3]) : kv[3]) + blank(kv[4]); + }); + return blank(m.slice(0, m.indexOf("\n") + 1)) + values.join("\n") + blank(m.slice(m.lastIndexOf("\n"))); +} + +const SYNTAX: [RegExp, (m: string, ...g: string[]) => string][] = [ + [/^\uFEFF?---[ \t]*\n([\s\S]*?)\n---(?=[ \t]*(?:\n|$))/, frontMatter], + [//g, blank], + [/^[ \t]*(?:```|~~~).*$/gm, blank], // the fences; the code between them is text + [/^[ \t]*\[[^\]\n]+\]:[ \t]*\S.*$/gm, blank], // reference definitions + [/<\/?[A-Za-z][^>]*>/g, tag], // tags and autolinks + [/(!\[)([^\]]*)(\]\([^)]*\))/g, keep], // images, then the links that may hold them + [/(\[)([^\]]*)(\](?:\([^)]*\)|\[[^\]]*\]))/g, keep], + [/https?:\/\/[^\s)\]>]+/g, blank], + [/^ {0,3}#{1,6}(?=\s)/gm, blank], + [/^[ \t]*(?:>[ \t]?)+/gm, blank], + [/^[ \t]*(?:[-*+]|\d+[.)])(?=[ \t])/gm, blank], + [/^[ \t|:=*_-]*$/gm, blank], // rules, setext underlines, table delimiter rows + [/&(?:#x[\da-f]+|#\d+|[a-z]+);/gi, blank], + [/\\(?=[!-/:-@[-`{-~])/g, blank], + [/[*~`|]/g, blank], + [/(? string] = [/^(?:import|export)\s.*$/gm, blank]; + +function plain(src: string, mdx: boolean): Plain { + let s = src.replace(/\r/g, " "); + for (const [re, f] of mdx ? [MDX, ...SYNTAX] : SYNTAX) s = s.replace(re, f as (m: string, ...g: string[]) => string); + const at: number[] = []; + for (let i = 0; i < s.length; i++) if (!/\s/.test(s[i])) at.push(i); + return { text: at.map((i) => s[i]).join(""), at }; +} +function slice(p: Plain, from: number, to: number): Plain { + const [i, j] = [from, to].map((x) => p.at.findIndex((a) => a >= x)).map((k) => (k < 0 ? p.at.length : k)); + return { text: p.text.slice(i, j), at: p.at.slice(i, j) }; +} + +// ── What is locked ── +type Unit = { key: string; file: string; src: string; line0: number; plain: Plain }; + +const listOf = (text: string | null) => + (text ?? "").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).map((l) => l.replace(/^\.?\//, "")); + +function kindOf(f: string, listed: string[]): string | null { + if (listed.some((p) => f === p || f.startsWith(p.replace(/\/*$/, "/")))) return "listed"; + if (f.startsWith("web/content/journal/")) return "journal"; + if (/^docs\/releases\/[^/]+\.md$/.test(f)) return "release"; + if (f === "docs/manifesto.md") return "manifesto"; + if (f === "README.md") return "readme"; + if (/^docs\/(?:[^/]+\/)*rfc-[^/]+\.md$/.test(f)) return "rfc"; + return f === ROADMAP ? "roadmap" : null; +} + +// The headings outside code fences that `wanted` picks, each to the next heading of its level or higher. +function sections(src: string, wanted: (level: number, title: string) => boolean) { + const out: { title: string; from: number; to: number }[] = []; + let open: { level: number; title: string; from: number } | null = null; + let [fence, at] = [false, 0]; + for (const line of src.split("\n")) { + if (/^ {0,3}(?:```|~~~)/.test(line)) fence = !fence; + const h = fence ? null : /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line); + if (h && open && h[1].length <= open.level) { + out.push({ ...open, to: at }); + open = null; + } + if (h && !open && wanted(h[1].length, h[2])) open = { level: h[1].length, title: h[2], from: at }; + at += line.length + 1; + } + if (open) out.push({ ...open, to: src.length }); + return out; +} + +// Each node's detail and detailKo, read with the TypeScript parser. A string +// that is not a plain literal is not read, and so is reported removed. +function roadmapStrings(src: string): Unit[] { + const sf = ts.createSourceFile(ROADMAP, src, ts.ScriptTarget.Latest, true); + const literal = (e?: ts.Expression) => (e && (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) ? e.text : null); + const out: Unit[] = []; + const visit = (n: ts.Node): void => { + if (ts.isObjectLiteralExpression(n)) { + const props = n.properties.filter(ts.isPropertyAssignment); + const named = (k: string) => props.find((p) => (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === k)?.initializer; + for (const field of ["detail", "detailKo"]) { + const [text, e] = [literal(named(field)), named(field)]; + const line = e ? sf.getLineAndCharacterOfPosition(e.getStart()).line + 1 : 0; + const key = `${ROADMAP} › ${literal(named("id")) ?? `line ${line}`}.${field}`; + if (text !== null) out.push({ key, file: ROADMAP, src: text, line0: line, plain: plain(text, false) }); + } + } + ts.forEachChild(n, visit); + }; + visit(sf); + return out; +} + +function unitsOf(f: string, src: string, kind: string): Unit[] { + if (kind === "roadmap") return roadmapStrings(src); + const whole = plain(src, f.endsWith(".mdx")); + const cut = (key: string, s: { from: number; to: number }): Unit => ({ key, file: f, src, line0: 1, plain: slice(whole, s.from, s.to) }); + if (kind === "readme") return sections(src, (l, t) => l === 2 && README_SECTIONS.includes(t)).map((s) => cut(`${f} › ${s.title}`, s)); + if (kind === "rfc") return sections(src, (_, t) => /^Part 1\b/.test(t)).map((s) => cut(`${f} › Part 1`, s)); + return [{ key: f, file: f, src, line0: 1, plain: whole }]; +} + +// ── Showing a change ── +// The source around plain characters [p, q), 30 of them each side, with the stretch marked «…». +function excerpt(u: Unit, p: number, q: number): string { + const { src, plain: { at } } = u; + const markFrom = p < q ? at[p] : p > 0 ? at[p - 1] + 1 : (at[0] ?? 0); + const markTo = p < q ? at[q - 1] + 1 : markFrom; + const from = Math.min(markFrom, at[Math.max(0, p - 30)] ?? markFrom); + const to = Math.max(markTo, (at[Math.min(at.length, q + 30) - 1] ?? markTo - 1) + 1); + const flat = (s: string) => s.replace(/\s+/g, " "); + const marked = flat(src.slice(markFrom, markTo)); + const line = u.line0 + (src.slice(0, markFrom).match(/\n/g)?.length ?? 0); + return `${u.file}:${line} …${flat(src.slice(from, markFrom))}«${marked.length > 200 ? `${marked.slice(0, 200)}…` : marked}»${flat(src.slice(markTo, to))}…`; +} +function difference(a: Unit, b: Unit): [string, string] { + const [x, y] = [a.plain.text, b.plain.text]; + let [p, s] = [0, 0]; + while (p < x.length && p < y.length && x[p] === y[p]) p++; + while (s < x.length - p && s < y.length - p && x[x.length - 1 - s] === y[y.length - 1 - s]) s++; + return [excerpt(a, p, x.length - s), excerpt(b, p, y.length - s)]; +} + +// ── The check ── +const strict = process.env.PF_DRIFT_STRICT === "1"; +const base = findBase(); +if (base.sha === null) { + console.log(`check:human-lock: ${base.fatal ? base.why : `skipped, ${base.why}${strict ? "\nPF_DRIFT_STRICT=1, so a skip fails" : ""}`}`); + process.exit(base.fatal || strict ? 1 : 0); +} +const BASE = base.sha; +const plural = (k: number, one: string) => `${k} ${one}${k === 1 ? "" : "s"}`; + +const [listedBefore, listedNow] = [listOf(readAt(BASE, LIST)), listOf(readNow(LIST))]; +const listed = [...new Set([...listedBefore, ...listedNow])]; +const unlisted = listedBefore.filter((p) => !listedNow.includes(p)); + +// What is locked at HEAD, counted, so a run says what it looked at. +const count = new Map(); +for (const f of git(["ls-files", "-z"]).split("\0")) { + const kind = kindOf(f, listedNow); + if (kind && kind !== "listed") count.set(kind, (count.get(kind) ?? 0) + (["readme", "rfc", "roadmap"].includes(kind) ? unitsOf(f, readNow(f) ?? "", kind).length : 1)); +} +const n = (kind: string) => count.get(kind) ?? 0; +console.log(`base: ${BASE.slice(0, 7)} (${base.from})`); +console.log(`locked: ${n("journal")} journal files, ${n("release")} release files, docs/manifesto.md, ${n("readme")} README sections, Part 1 of ${n("rfc")} RFCs, ${n("roadmap")} roadmap strings, ${plural(listedNow.length, "path")} in ${LIST}`); +const changes = changedSince(BASE); +const touched = changes.filter((c) => kindOf(c.path, listed)); +console.log(`this diff touches ${plural(touched.length, "locked file")}`); +if (touched.length || unlisted.length) console.log(""); +for (const p of unlisted) console.log(`unlisted ${LIST} no longer names ${p}; only the maintainer takes a path off it`); + +// Where vanished text may have gone: every file this diff adds or changes. +let corpus: { file: string; text: string }[] | undefined; +function whereIs(text: string): string | null { + corpus ??= changes.flatMap(({ path: f, status }) => { + const raw = status !== "deleted" && fs.existsSync(path.join(ROOT, f)) ? fs.readFileSync(path.join(ROOT, f)) : null; + if (!raw || raw.includes(0)) return []; + const parts = kindOf(f, listed) === "roadmap" ? roadmapStrings(raw.toString("utf8")).map((u) => u.plain.text) : []; + return [plain(raw.toString("utf8"), f.endsWith(".mdx")).text, ...parts].map((t) => ({ file: f, text: t })); + }); + return corpus.find((c) => c.text.includes(text))?.file ?? null; +} + +let [failed, moved] = [0, 0]; +for (const c of touched) { + const kind = kindOf(c.path, listed)!; + const units = (src: string | null) => new Map((src === null ? [] : unitsOf(c.path, src, kind)).map((u) => [u.key, u])); + const [old, cur] = [units(c.status === "added" ? null : readAt(BASE, c.path)), units(c.status === "deleted" ? null : readNow(c.path))]; + let same = 0; + for (const [key, a] of old) { + const b = cur.get(key); + if (b ? a.plain.text === b.plain.text : !a.plain.text) same++; + else if (b) { + const [was, is] = difference(a, b); + console.log(`changed ${key}\n base ${was}\n head ${is}`); + failed++; + } else { + const to = whereIs(a.plain.text); + console.log(to ? `moved ${key} → ${to}, word for word` : `removed ${key}: its words are in no file this diff adds or changes`); + if (to) moved++; + else failed++; + } + } + const fresh = [...cur.keys()].filter((k) => !old.has(k)).length; + const what = kind === "roadmap" ? "strings" : "sections"; + if (same) console.log(`same ${c.path}${old.size > 1 ? ` (${same} of ${old.size} locked ${what})` : ""}`); + if (fresh) console.log(`new ${c.path}${cur.size > 1 ? ` (${fresh} locked ${what})` : ""}`); +} + +console.log(""); +if (moved) console.log("Moving locked text needs the maintainer's approval on the PR (docs/rfc-cleanup.md §2.9)."); +const wrong = [failed && `${plural(failed, "locked text")} changed`, unlisted.length && `${plural(unlisted.length, "path")} taken off ${LIST}`]; +if (failed || unlisted.length) { + console.log(`check:human-lock: ${wrong.filter(Boolean).join(", ")}`); + if (failed) console.log("Markup, layout and link and image paths may change in locked text; words may not. A typo is reported in the PR description, not fixed."); + process.exitCode = 1; +} else console.log("check:human-lock: no locked words changed"); diff --git a/web/scripts/lib/pr-base.ts b/web/scripts/lib/pr-base.ts new file mode 100644 index 00000000..26b14b56 --- /dev/null +++ b/web/scripts/lib/pr-base.ts @@ -0,0 +1,47 @@ +/** + * The commit a pull request starts from, and the files it changes since, for + * scripts/human-lock.ts and scripts/voice-lint.ts. PF_LOCK_BASE names it (CI + * passes the pull request's base SHA); at a desk it is the merge-base with + * origin/dev, then origin/main. With none of the three, the check skips + * (docs/investigations/2026-10-cleanup-step2-plan.md, D5). + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +export const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); +export const git = (args: string[]) => + execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28, stdio: ["ignore", "pipe", "ignore"] }); +const attempt = (f: () => T, fallback: T): T => { + try { return f(); } catch { return fallback; } +}; +const commit = (ref: string) => attempt(() => git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).trim(), ""); + +export type Base = { sha: string; from: string } | { sha: null; why: string; fatal: boolean }; + +/** The base and where it came from, or why there is none: fatal when PF_LOCK_BASE names a commit this clone lacks. */ +export function findBase(): Base { + const named = process.env.PF_LOCK_BASE?.trim(); + if (named && !commit(named)) return { sha: null, why: `PF_LOCK_BASE is ${named}, and this clone has no such commit (check out with fetch-depth: 0)`, fatal: true }; + if (named) return { sha: commit(named), from: "PF_LOCK_BASE" }; + for (const ref of ["origin/dev", "origin/main"]) { + const sha = commit(ref) && attempt(() => git(["merge-base", "HEAD", ref]).trim(), ""); + if (sha) return { sha, from: `the merge-base with ${ref}` }; + } + return { sha: null, why: "no PF_LOCK_BASE, and no merge-base with origin/dev or origin/main", fatal: false }; +} + +export type Change = { path: string; status: "added" | "modified" | "deleted" }; + +/** What the working tree changes since base, untracked files included. A move is a deletion and an addition. */ +export function changedSince(base: string): Change[] { + const out = new Map(); + const fields = git(["diff", "--name-status", "--no-renames", "-z", base]).split("\0").filter(Boolean); + for (let i = 0; i + 1 < fields.length; i += 2) out.set(fields[i + 1], fields[i] === "A" ? "added" : fields[i] === "D" ? "deleted" : "modified"); + for (const f of git(["ls-files", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean)) out.set(f, "added"); + return [...out].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([p, status]) => ({ path: p, status })); +} + +/** A file as it was at a commit, or as it is in the working tree; null if it is not there. */ +export const readAt = (ref: string, file: string) => attempt(() => git(["cat-file", "-p", `${ref}:${file}`]), null); +export const readNow = (file: string) => attempt(() => fs.readFileSync(path.join(ROOT, file), "utf8"), null); diff --git a/web/scripts/performance-smoke.ts b/web/scripts/performance-smoke.ts index 217a89c0..1caf63b2 100644 --- a/web/scripts/performance-smoke.ts +++ b/web/scripts/performance-smoke.ts @@ -13,7 +13,6 @@ import { PFST_CUE_BYTES, decodePfst, encodePfst, - normalizePerformance, pfsFilename, serializePerformance, validatePerformance, diff --git a/web/scripts/voice-lint.ts b/web/scripts/voice-lint.ts new file mode 100644 index 00000000..2dddda20 --- /dev/null +++ b/web/scripts/voice-lint.ts @@ -0,0 +1,271 @@ +/** + * The voice lint: `npm run check:voice` in web/ (docs/rfc-cleanup.md §2.10). + * New or rewritten text over 20 lines goes through it before the pull request + * opens, and a person still reads the text before merge. CI does not run it. + * + * With files as arguments it reads all of each (`-` reads Markdown from + * stdin, for a PR description). With none it reads the lines this branch adds + * since the base the lock uses (scripts/lib/pr-base.ts), untracked files + * included. Each finding is printed as file:line, the rule and the words. + * + * banned a word or phrase in docs/voice-banned.txt. An English entry + * matches whole words and their endings; a Korean one, anywhere. + * threes three adjectives or clauses in a row, joined by commas. + * opener a sentence that says what the text is about. + * closer a closing summary, the kind that starts "In conclusion". + * + * Only the prose of a file is read: Markdown without code, tags, link targets + * or URLs; an HTML page's text; a script's comments, strings and JSX text; the + * comments of C, Python, shell and YAML. Text in double quotes or 「」 is + * someone's words being quoted, and is skipped. So is a section under a + * "Writing voice" heading, which has to quote what it forbids. + */ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +import { ROOT, changedSince, findBase, git } from "./lib/pr-base"; + +const BANNED = "docs/voice-banned.txt"; +// Files the rules would read as their own examples, each with the reason. +const EXEMPT: Record = { + [BANNED]: "it is the list the banned rule reads", +}; +const VOICE_HEADING = /^ {0,3}(#{1,6})\s+(?:[\d.]+\s+)?Writing voice\b/i; + +// ── Prose ── +// Each reader returns its file with everything but the prose blanked to +// spaces, newlines kept, so a match's offset still names its line. +const blank = (s: string) => s.replace(/[^\n]/g, " "); +type Reader = (src: string, file: string) => string; +type Range = [from: number, to: number]; + +function only(src: string, keep: Range[]): string { + const out = blank(src).split(""); + for (const [a, b] of keep) for (let i = a; i < b; i++) out[i] = src[i]; + return out.join(""); +} +// The matches of re that keep() accepts, trimmed by the delimiters it returns. +const pick = (re: RegExp, keep: (m: string) => number | null): Reader => (src) => + only(src, [...src.matchAll(re)].flatMap((m): Range[] => { + const cut = keep(m[0]); + return cut === null ? [] : [[m.index + cut, m.index + m[0].length - cut]]; + })); + +function markdown(src: string, mdx: boolean): string { + let [fence, quiet, front] = [false, 0, src.startsWith("---\n")]; + const lines = src.split("\n").map((line, i) => { + if (front) { + if (i > 0 && /^---\s*$/.test(line)) front = false; + return i > 0 && front ? line.replace(/^\s*[\w-]+:/, blank) : ""; // front matter: the values are text + } + const fenced = /^ {0,3}(?:```|~~~)/.test(line); + if (fenced) fence = !fence; + if (fence || fenced) return ""; + const h = /^ {0,3}(#{1,6})\s/.exec(line); + if (h && quiet && h[1].length <= quiet) quiet = 0; + quiet ||= VOICE_HEADING.exec(line)?.[1].length ?? 0; + return quiet || (mdx && /^(?:import|export)\s/.test(line)) ? "" : line; + }); + return lines.join("\n") + .replace(/^ {0,3}\[[^\]\n]+\]:.*$/gm, blank) + .replace(/`[^`\n]*`|\]\([^)\n]*\)|\]\[[^\]\n]*\]|<\/?[A-Za-z][^>\n]*>||&(?:#x[\da-f]+|#\d+|[a-z]+);/gi, blank); +} + +const html: Reader = (src) => + src.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>|<[^>]*>|&(?:#x[\da-f]+|#\d+|[a-z]+);/gi, blank); + +// A script's comments, strings and JSX text, found by the TypeScript parser so +// a regular expression or a template cannot pass for a comment. +function script(src: string, file: string): string { + const kind = /x$/.test(file) ? ts.ScriptKind.TSX : /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS; + const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, kind); + const keep: Range[] = []; + const seen = new Set(); + const comment = (pos: number, end: number) => { + if (!seen.has(pos)) keep.push([pos, end]); + seen.add(pos); + }; + const visit = (n: ts.Node): void => { + if (n.kind >= ts.SyntaxKind.FirstJSDocNode && n.kind <= ts.SyntaxKind.LastJSDocNode) return; + if (ts.isJsxText(n)) { + keep.push([n.pos, n.end]); + return; + } + ts.forEachLeadingCommentRange(src, n.pos, comment); + ts.forEachTrailingCommentRange(src, n.end, comment); + if (ts.isTaggedTemplateExpression(n) && n.tag.getText(sf) === "String.raw") return; // a pattern or a path + if (ts.isStringLiteral(n)) keep.push([n.getStart(sf) + 1, n.end - 1]); + else if (ts.isNoSubstitutionTemplateLiteral(n) || ts.isTemplateHead(n) || ts.isTemplateMiddle(n) || ts.isTemplateTail(n)) keep.push([n.getStart(sf), n.end]); + n.getChildren(sf).forEach(visit); + }; + visit(sf); + return only(src, keep); +} + +const STRINGS = String.raw`"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'`; +const cLike = pick(new RegExp(String.raw`\/\*[\s\S]*?\*\/|\/\/[^\n]*|${STRINGS}`, "g"), (m) => (m[0] === "/" ? 0 : null)); +const hash = pick(new RegExp(String.raw`${STRINGS}|#[^\n]*`, "g"), (m) => (m[0] === "#" ? 0 : null)); +const python = pick(new RegExp(String.raw`"""[\s\S]*?"""|'''[\s\S]*?'''|${STRINGS}|#[^\n]*`, "g"), (m) => (m[0] === "#" ? 0 : /^("""|''')/.test(m) ? 3 : null)); +const asMarkdown: Reader = (src, file) => markdown(src, file.endsWith(".mdx")); + +const plain: Reader = (s) => s; +const READERS: Record = { + ".md": asMarkdown, ".mdx": asMarkdown, ".txt": plain, ".html": html, + ".ts": script, ".tsx": script, ".js": script, ".jsx": script, ".mjs": script, ".cjs": script, + ".h": cLike, ".hpp": cLike, ".c": cLike, ".cpp": cLike, ".ino": cLike, ".kt": cLike, ".java": cLike, + ".py": python, ".sh": hash, ".yml": hash, ".yaml": hash, ".toml": hash, +}; + +// Not prose: URLs, paths, and quoted words (double quotes on one paragraph, +// or 「」 and 『』). Runs of blanks shrink to one space; the newlines stay. +const prose = (src: string, read: Reader, file: string) => + read(src.replace(/\r/g, ""), file) + .replace(/https?:\/\/[^\s)\]>"'`]+|[\w.@~-]*\/[\w./@~-]+/g, blank) + .replace(/"(?:[^"\n]|\n(?![ \t]*\n)){1,300}?"|“[^”]{1,300}”|「[^」]{1,300}」|『[^』]{1,300}』/g, blank) + .replace(/[^\S\n]+/g, " "); + +// ── The rules ── +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +// "ensure" catches "ensures", "ensured" and "ensuring"; "robust", "robustly". +function forms(w: string): string { + if (!/^[a-z]+$/.test(w)) return escape(w); + if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}(?:y|ies|ied|ying|ily|iness)`; + if (w.endsWith("e")) return `${w.slice(0, -1)}(?:e|es|ed|ing|ely|eness|ation|ations)`; + return `${w}(?:s|es|ed|ing|ly|ness|ment|ments)?`; +} +function bannedRule(): RegExp { + const entries = fs.readFileSync(path.join(ROOT, BANNED), "utf8").split("\n") + .map((l) => l.trim().toLowerCase().replace(/’/g, "'")).filter((l) => l && !l.startsWith("#")); + const korean = entries.filter((e) => /\p{Script=Hangul}/u.test(e)).map(escape); + const english = entries.filter((e) => !/\p{Script=Hangul}/u.test(e)).map((e) => { + const words = e.split(/\s+/); + return [...words.slice(0, -1).map(escape), forms(words[words.length - 1])].join(String.raw`\s+`).replace(/'/g, "['’]").replace(/-/g, String.raw`[-\s]`); + }); + const alt = [english.length && String.raw`(? String.raw`${EN_DET}${first}[a-z'’-]*(?:\s${EN_WORD}){${n - 1}}`; +const EN_THREE = (n: number) => String.raw`${EN_ITEM(n, "[A-Za-z]")},\s${EN_ITEM(n)},\s(?:and|or)\s${EN_ITEM(n)}`; +const KO_WORD = String.raw`[\p{Script=Hangul}]+`; +const KO_JOIN = String.raw`${KO_WORD}(?:고|며|하게|롭게|한|적인|로운|있는|없는|된)`; +const KO_ITEM = (last: string) => String.raw`(?:${KO_WORD}\s){0,2}${last}`; +// Patterns are regex literals, so a run on this file does not read them as prose. +const OPENERS = [ + /(?:This|The following)\s(?:document|doc|file|page|guide|section|chapter|post|article|note|README|PR|pull request|commit|change|RFC|plan|report|tutorial|module|script|spec|specification|repository|repo)\s(?:describes|explains|covers|introduces|provides|contains|outlines|presents|documents|details|lists|summari[sz]es|discusses|defines|walks|aims|is about|will)\b/u, + /In\sthis\s(?:document|doc|file|page|guide|section|chapter|post|article|README|PR|pull request|RFC|plan|report|tutorial),?\s(?:we|you|I)\b/u, + /(?:이|본|이번)\s(?:문서|글|페이지|파일|가이드|안내서|절|장|섹션|PR|커밋|스크립트|모듈|계획서|보고서|RFC)\s?(?:은|는|에서는|에서)\s[^.!?\n]{0,60}?(?:설명|소개|다루|다룹|다룬|안내|살펴|담고|담았)/u, +]; +const CLOSERS = /(?:In conclusion|In summary|To summari[sz]e|To sum up|All in all|In closing|Overall,|TL;\s?DR)/u; +const CLOSERS_KO = /결론적으로|요약하면|요약하자면|정리하자면|종합하면|종합하자면|요컨대|마치며/u; +const RULES: [name: string, re: RegExp, why: string][] = [ + ["banned", bannedRule(), `a word ${BANNED} lists. A sentence that needs one to stand up is a sentence to cut`], + ["threes", new RegExp([ + String.raw`(? r.source).join("|")})`, "gmu"), "says what the text is about. Start with the thing"], + ["closer", new RegExp(`${START}${CLOSERS.source}|${CLOSERS_KO.source}`, "gmu"), "a closing summary. A paragraph stops when it is said"], +]; + +// ── What is read ── +type Target = { file: string; src: string; read: Reader; lines: Set | null }; +const plural = (k: number, one: string) => `${k} ${one}${k === 1 ? "" : "s"}`; +const strict = process.env.PF_DRIFT_STRICT === "1"; +const args = process.argv.slice(2); +const targets: Target[] = []; +const skipped: string[] = []; + +function add(file: string, lines: Set | null, explicit: boolean, abs = path.join(ROOT, file)) { + const read = READERS[path.extname(file).toLowerCase()] ?? (explicit ? plain : undefined); + const raw = read && fs.existsSync(abs) && fs.statSync(abs).isFile() ? fs.readFileSync(abs) : null; + if (explicit && !raw && !EXEMPT[file]) { + console.log(`check:voice: no such file: ${file}`); + process.exit(1); + } + if (EXEMPT[file]) skipped.push(`${file}: ${EXEMPT[file]}`); + else if (read && raw && !raw.includes(0)) targets.push({ file, src: raw.toString("utf8"), read, lines }); +} + +if (args.length) { + const cwd = process.env.INIT_CWD ?? process.cwd(); + for (const a of args) { + if (a === "-") targets.push({ file: "(stdin)", src: fs.readFileSync(0, "utf8"), read: asMarkdown, lines: null }); + else { + const abs = path.resolve(cwd, a); + const rel = path.relative(ROOT, abs); + add(rel.startsWith("..") ? a : rel.split(path.sep).join("/"), null, true, abs); + } + } +} else { + const base = findBase(); + if (base.sha === null) { + console.log(`check:voice: ${base.fatal ? base.why : `skipped, ${base.why}${strict ? "\nPF_DRIFT_STRICT=1, so a skip fails" : ""}`}`); + process.exit(base.fatal || strict ? 1 : 0); + } + console.log(`base: ${base.sha.slice(0, 7)} (${base.from})`); + // Each file's added lines: all of a new file, the + side of each hunk otherwise. + const added = new Map | null>(); + for (const c of changedSince(base.sha)) if (c.status === "added") added.set(c.path, null); + let file = ""; + for (const line of git(["-c", "core.quotePath=false", "diff", "-U0", "--no-renames", "--no-color", "--no-ext-diff", base.sha]).split("\n")) { + const f = /^\+\+\+ (?:b\/(.*?)\t?|\/dev\/null)$/.exec(line); + const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (f) file = f[1] ?? ""; + else if (h && file && added.get(file) !== null) { + const set = added.get(file) ?? new Set(); + for (let i = 0; i < Number(h[2] ?? 1); i++) set.add(Number(h[1]) + i); + added.set(file, set); + } + } + for (const [f, lines] of added) if (lines === null || lines.size) add(f, lines, false); +} + +// ── The lint ── +type Finding = { at: string; rule: string; words: string }; +const findings: Finding[] = []; +let count = 0; +for (const t of targets) { + const lines = prose(t.src, t.read, t.file).replace(/\n$/, "").split("\n"); + const wanted = (i: number) => t.lines === null || t.lines.has(i + 1); + count += lines.filter((_, i) => wanted(i)).length; + // Runs of wanted lines, matched as one text so a phrase may wrap. + for (let i = 0; i < lines.length; i++) { + if (!wanted(i)) continue; + let j = i; + while (j + 1 < lines.length && wanted(j + 1)) j++; + const text = lines.slice(i, j + 1).join("\n"); + for (const [rule, re] of RULES) + for (const m of text.matchAll(re)) { + const line = i + 1 + (text.slice(0, m.index).match(/\n/g)?.length ?? 0); + const words = m[0].replace(/\s+/g, " ").trim(); + findings.push({ at: `${t.file}:${line}`, rule, words: words.length > 70 ? `${words.slice(0, 70)}…` : words }); + } + i = j; + } +} +findings.sort((a, b) => a.at.localeCompare(b.at, undefined, { numeric: true })); + +console.log(`read: ${plural(count, args.length ? "line" : "added line")} in ${plural(targets.length, "file")}`); +for (const s of skipped) console.log(`skipped ${s}`); +if (findings.length) console.log(""); +const width = Math.max(0, ...findings.map((f) => f.at.length)); +for (const f of findings) console.log(`${f.at.padEnd(width)} ${f.rule} ${f.words}`); +console.log(""); +if (!findings.length) console.log("check:voice: nothing found"); +else { + const files = new Set(findings.map((f) => f.at.replace(/:\d+$/, ""))).size; + console.log(`check:voice: ${plural(findings.length, "finding")} in ${plural(files, "file")}`); + for (const [rule, , why] of RULES) if (findings.some((f) => f.rule === rule)) console.log(` ${rule} ${why}`); + console.log("The rules are docs/rfc-cleanup.md §2.10; the voice is docs/voice.md. A person reads the text before merge either way."); + process.exitCode = 1; +} diff --git a/web/src/app/api/community/builds/route.ts b/web/src/app/api/community/builds/route.ts index 57ec4523..d1241176 100644 --- a/web/src/app/api/community/builds/route.ts +++ b/web/src/app/api/community/builds/route.ts @@ -77,8 +77,13 @@ async function handlePost(request: Request) { return Response.json({ error: "Invalid JSON body." }, { status: 400 }); } - // "bin" is still accepted as a word so an older page gets an explanation - // rather than a validation error it cannot act on. + // "bin" asked for a whole firmware image. The community built those from 25 + // July 2026 (c5e0907) until 4c13474 removed them on 12 August, and since + // then the word gets a 410 that says where to go instead of the 400 below. + // No page here ever sent it. BuildFirmwareModal, the one dialog that asked + // for an image, sent no format, and a missing format meant "bin" until 12 + // August. The 410 is for a caller outside this repository that names the + // format, as this route's header told it to from 27 July (d7cd193). const rawFormat = (body as Record).format ?? "pfm"; if (rawFormat === "bin") { return Response.json( diff --git a/web/src/app/build/breadboard/page.tsx b/web/src/app/build/breadboard/page.tsx index e28fce33..f5c2fc43 100644 --- a/web/src/app/build/breadboard/page.tsx +++ b/web/src/app/build/breadboard/page.tsx @@ -391,6 +391,7 @@ export default function BreadboardBuildPage() { ↑ Ground (GND) pins on both sides of all four rotary encoders land on the Ground (−) rail of the breadboard.

+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} Encoders mounted with GND wires connected to the breadboard rail
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} ESP32-S3 connected to the HUB75E LED matrix via ribbon cable
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} All encoder signal wires routed routed into the case alongside the LED panel
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} Stripped USB and LED panel power wires being twisted onto jumpers (
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} {img.label} (SHARE_TOOLS in lib/pattern/share.ts). PATTERN_GUIDE.md + // and the roadmap print the address too. Send the visitor straight to the + // community host rather than parking them on a page whose only content is + // another link. Every path lands on the community's front page, a fork + // credit's included. if (!communityEnabled()) { const target = communityHomeUrl(); let targetHost: string | null = null; diff --git a/web/src/app/editions/EditionCard.tsx b/web/src/app/editions/EditionCard.tsx index a8e45119..ad450d9f 100644 --- a/web/src/app/editions/EditionCard.tsx +++ b/web/src/app/editions/EditionCard.tsx @@ -29,15 +29,6 @@ import type { Edition } from "./editions-data"; // Core is the cheapest entry on the shelf: its images already sit under // /flash/bin with CORS headers, so its install button needs nothing new. -// Why this firmware is not simply the default. Three different answers, and -// somebody choosing needs to know which — picking up something unfinished is -// not the same as picking up something deliberately still. -const REASON_LABEL: Record, string> = { - "not-ready": "not ready for everyone", - "not-universal": "not right as a default", - frozen: "pinned and not moving", -}; - const STATUS_LABEL: Record = { available: "available", building: "in progress", diff --git a/web/src/app/features/features-data.ts b/web/src/app/features/features-data.ts index bfd7a9cd..f618868a 100644 --- a/web/src/app/features/features-data.ts +++ b/web/src/app/features/features-data.ts @@ -126,7 +126,7 @@ export const FEATURES: Feature[] = [ where: "In the Audio firmware.", whereHref: "/editions#audio", links: [ - { label: "Audio guide", href: blob("AUDIO_GUIDE.md") }, + { label: "Audio guide", href: blob("docs/AUDIO_GUIDE.md") }, { label: "Source", href: tree("firmware/patternflow/features/audio_in") }, ], reel: { diff --git a/web/src/app/roadmap/RoadmapMap.tsx b/web/src/app/roadmap/RoadmapMap.tsx index cca6cee5..065a5d02 100644 --- a/web/src/app/roadmap/RoadmapMap.tsx +++ b/web/src/app/roadmap/RoadmapMap.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import styles from './Roadmap.module.css'; import { EDGES, @@ -249,7 +249,7 @@ export default function RoadmapMap() { return { x: left, y, w: right - left, h }; }, [nodes]); - const clampPan = (x: number, y: number, z = zoom) => { + const clampPan = useCallback((x: number, y: number, z = zoom) => { const el = canvasRef.current; const cw = el ? el.clientWidth : width; const ch = el ? el.clientHeight : HEIGHT; @@ -267,20 +267,7 @@ export default function RoadmapMap() { x: Math.min(maxX, Math.max(minX, x)), y: Math.min(maxY, Math.max(minY, y)), }; - }; - - const zoomTo = (next: number) => { - const nz = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(next * 10) / 10)); - if (nz === zoom) return; - const el = canvasRef.current; - const cx = el ? el.clientWidth / 2 : 0; - const cy = el ? el.clientHeight / 2 : 0; - // Keep the point at the canvas center fixed while zooming. - setPan( - clampPan(cx - ((cx - pan.x) / zoom) * nz, cy - ((cy - pan.y) / zoom) * nz, nz), - ); - setZoom(nz); - }; + }, [width, zoom]); useEffect(() => { const el = canvasRef.current; @@ -312,7 +299,7 @@ export default function RoadmapMap() { return () => { el.removeEventListener('wheel', handleWheel); }; - }, [width, zoom]); + }, [width, zoom, clampPan]); const onPointerDown = (event: React.PointerEvent) => { dragRef.current = { diff --git a/web/src/app/roadmap/roadmap-data.ts b/web/src/app/roadmap/roadmap-data.ts index d15c2131..1b175028 100644 --- a/web/src/app/roadmap/roadmap-data.ts +++ b/web/src/app/roadmap/roadmap-data.ts @@ -463,7 +463,7 @@ export const NODES: RoadmapNode[] = [ 'v2.0.0 unified the versioning: project, firmware, PCB and case became one Patternflow version instead of four. It carried the GPIO0 cold-boot fix, the custom-pattern workflow doc, canonical pattern names (Origin, Wave Saw), and a web platform that was by then substantially complete — flasher, Live Editor, journal and build map.', detailKo: 'v2.0.0에서 버전 체계를 하나로 통일했습니다. 프로젝트·펌웨어·PCB·케이스가 각자 버전을 갖는 대신 하나의 패턴플로우 버전으로 묶였습니다. GPIO0 콜드부트 수정, 커스텀 패턴 제작 문서, 패턴 이름 정규화(Origin, Wave Saw)가 들어갔고, 이 시점의 웹(플래셔, 라이브 에디터, 저널, 빌드 맵)은 이미 상당 부분 완성된 상태였습니다.', - links: [{ label: 'CHANGELOG (2.0.0)', href: `${REPO}/blob/main/CHANGELOG.md` }], + links: [{ label: 'CHANGELOG (2.0.0)', href: `${REPO}/blob/main/docs/releases/v2.0.0.md` }], }, { id: 'fw-foundation', diff --git a/web/src/components/community/FeedControls.tsx b/web/src/components/community/FeedControls.tsx index c4cff61d..cd553be6 100644 --- a/web/src/components/community/FeedControls.tsx +++ b/web/src/components/community/FeedControls.tsx @@ -48,8 +48,11 @@ export default function FeedControls({ const hrefWith = (patch: Record) => { const next = new URLSearchParams(params.toString()); - // Parameters from the paginated era — scrub them off any link followed - // from an old bookmark. + // Parameters from the paged feed: page and size from 22 July 2026 + // (e146de6), view from the Large/Small card toggle on 25 July (5e5d50c). + // Infinite scroll retired all three on 2 August (c4a57f1). A bookmark + // saved in those days still carries them, so every link followed from it + // drops them. next.delete("page"); next.delete("size"); next.delete("view"); diff --git a/web/src/components/community/PatternCard.tsx b/web/src/components/community/PatternCard.tsx index f797b5bd..bcd5d8ff 100644 --- a/web/src/components/community/PatternCard.tsx +++ b/web/src/components/community/PatternCard.tsx @@ -5,7 +5,6 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { authClient } from "@/lib/community/auth-client"; import AuthModal from "@/components/community/AuthModal"; -import { useMediaQuery } from "@/lib/hooks/useMediaQuery"; import { renderPatternThumb } from "@/lib/community/thumbs"; import { knobSetupFromCode } from "@/lib/community/knobs"; import { diff --git a/web/src/components/guide/copy/audio.ts b/web/src/components/guide/copy/audio.ts index 988667d9..a4fa90fc 100644 --- a/web/src/components/guide/copy/audio.ts +++ b/web/src/components/guide/copy/audio.ts @@ -15,9 +15,10 @@ import type { GuideLang } from "../store"; // The small print every guide shares is copy.ts's `ui`. The words of the // captures are AudioShots.tsx's, the editor window's are EditorWindow.tsx's. // -// Sources for every fact, and nothing else: AUDIO_GUIDE.md, docs/midi-ableton.md, -// docs/midi-spec.md, docs/audio-ws-spec.md, tools/patternflow-audio-extension -// (its README, popup and editor), the console pages +// Sources for every fact, and nothing else: docs/AUDIO_GUIDE.md, +// docs/midi-ableton.md, docs/midi-spec.md, docs/audio-ws-spec.md, +// tools/patternflow-audio-extension (its README, popup and editor), the +// console pages // (firmware/patternflow/console/_audio_in_bar.html, _audio_in_adapter.js, // midi.html, update.html), web/src/app/editions and the firmware // (patternflow.ino's NETWORK screen, features/midi). UI words are quoted as @@ -318,7 +319,7 @@ const en: AudioCopy = { { label: "The phone app", href: `${GH}tools/patternflow-audio-android/README.md` }, { label: "A Director show as .mid", href: `${GH}docs/director-midi.md` }, { label: "The MIDI map", href: `${GH}docs/midi-spec.md` }, - { label: "AUDIO_GUIDE.md", href: `${GH}AUDIO_GUIDE.md` }, + { label: "AUDIO_GUIDE.md", href: `${GH}docs/AUDIO_GUIDE.md` }, { label: "Editions", href: "/editions" }, { label: "All guides", href: "/guide" }, ], @@ -567,7 +568,7 @@ const ko: AudioCopy = { { label: "폰 앱", href: `${GH}tools/patternflow-audio-android/README.md` }, { label: "디렉터 쇼를 .mid로", href: `${GH}docs/director-midi.md` }, { label: "MIDI 맵", href: `${GH}docs/midi-spec.md` }, - { label: "AUDIO_GUIDE.md", href: `${GH}AUDIO_GUIDE.md` }, + { label: "AUDIO_GUIDE.md", href: `${GH}docs/AUDIO_GUIDE.md` }, { label: "Editions", href: "/editions" }, { label: "가이드 전체", href: "/guide/ko" }, ], diff --git a/web/src/components/guide/stage/build/layout.ts b/web/src/components/guide/stage/build/layout.ts index 6779dc52..a07c0ea8 100644 --- a/web/src/components/guide/stage/build/layout.ts +++ b/web/src/components/guide/stage/build/layout.ts @@ -67,9 +67,9 @@ export function boardToModel(local: THREE.Vector3, pose: Pose, out = new THREE.V } // The parts as they arrive on the mat: a row behind the board in the order -// they are soldered (BUILD_GUIDE_v2 §4's small-to-tall order mapped onto v3's -// refs), each upright as it will sit on the board; the encoders shaft-up with -// their washer and nut in front of them. +// they are soldered (docs/legacy/BUILD_GUIDE_v2.md §4's small-to-tall order +// mapped onto v3's refs), each upright as it will sit on the board; the +// encoders shaft-up with their washer and nut in front of them. export const ROW_Z = 21.6; export const ROW: Record = { "U1_socket_pins1-22": -6.8, diff --git a/web/src/components/journal/JournalLightbox.tsx b/web/src/components/journal/JournalLightbox.tsx index b99ca2d5..56e79fe6 100644 --- a/web/src/components/journal/JournalLightbox.tsx +++ b/web/src/components/journal/JournalLightbox.tsx @@ -83,6 +83,7 @@ export default function JournalLightbox() { close
event.stopPropagation()}> + {/* eslint-disable-next-line @next/next/no-img-element -- whichever journal image was clicked, at the src it already has */} {image.alt} {image.caption &&
{image.caption}
}
diff --git a/web/src/components/sections/BuildPanel.test.tsx b/web/src/components/sections/BuildPanel.test.tsx index c5005fc0..1997647d 100644 --- a/web/src/components/sections/BuildPanel.test.tsx +++ b/web/src/components/sections/BuildPanel.test.tsx @@ -384,7 +384,7 @@ describe('the rest of the panel', () => { `${GH}/blob/main/docs/panel-compatibility.md`, ); expect(screen.getByRole('link', { name: 'Breadboard guide' })).toHaveAttribute('href', '/build/breadboard'); - expect(screen.getByRole('link', { name: 'v2 board guide' })).toHaveAttribute('href', `${GH}/blob/main/BUILD_GUIDE_v2.md`); + expect(screen.getByRole('link', { name: 'v2 board guide' })).toHaveAttribute('href', `${GH}/blob/main/docs/legacy/BUILD_GUIDE_v2.md`); expect(screen.queryByText(/laser-cut, or an older/)).not.toBeInTheDocument(); }); }); diff --git a/web/src/components/sections/BuildPanel.tsx b/web/src/components/sections/BuildPanel.tsx index 5a86b2a4..129e93cb 100644 --- a/web/src/components/sections/BuildPanel.tsx +++ b/web/src/components/sections/BuildPanel.tsx @@ -414,7 +414,7 @@ export default function BuildPanel({ content, isActive }: BuildPanelProps) { Breadboard guide - + v2 board guide
diff --git a/web/src/components/sections/Hero.tsx b/web/src/components/sections/Hero.tsx index b108a34a..3dae1cf9 100644 --- a/web/src/components/sections/Hero.tsx +++ b/web/src/components/sections/Hero.tsx @@ -52,6 +52,7 @@ export default function Hero() { height: 0 }}> {/* Static fallback and loading placeholder */} + {/* eslint-disable-next-line @next/next/no-img-element -- a fixed poster under the video, shown as is */} Patternflow physical device { expect(frames(visited)).toEqual(frames(play)); }); }); + +// What a socket of the virtual panel reads off the board, and what it writes +// that no hand did. +describe("the sockets", () => { + let sim: DeviceSim; + const none = [false, false, false, false]; + + beforeEach(() => { + sim = new DeviceSim(); + sim.tick(FRAME); + }); + + it("read the knobs as /api/status does: a hand's clicks, the bus, the lanes", () => { + expect(sim.knobState()).toEqual({ + knobs: [0, 0, 0, 0], + params: [500, 500, 500, 500], + paramActive: none, + lanes: [0, 0, 0, 0], + laneActive: none, + }); + // Two edges a click on K2 (the /knobs page): its detent is two clicks. + sim.setKnobSettings(none, [4, 2, 4, 4]); + sim.turn(0, 3); + sim.turn(1, -1); + sim.applyRemoteParam(2, 750); + sim.setEdition("audio"); + sim.setLaneSource("ws"); + sim.setLane("ws", 3, 0.25); + sim.tick(FRAME); + expect(sim.knobState()).toEqual({ + knobs: [3, -2, 0, 0], + params: [500, 500, 750, 500], + paramActive: [false, false, true, false], + lanes: [0, 0, 0, 0.25], + laneActive: [false, false, false, true], + }); + }); + + it("drive the lanes from the WebSocket, and the editor writes nothing then", () => { + sim.setEdition("audio"); + sim.setLaneSource("ws"); + sim.setLane("editor", 0, 0.9); + sim.setLane("ws", 1, 0.9); + sim.tick(FRAME); + expect(sim.knobState().laneActive).toEqual([false, true, false, false]); + }); + + it("let one lane go on off=N, and the next write takes it again", () => { + sim.setEdition("audio"); + sim.setLaneSource("ws"); + const home = values(sim); + run(sim, 100, () => [0, 1, 2, 3].forEach((i) => sim.setLane("ws", i, 0.9))); + sim.releaseLane("editor", 2); + sim.releaseLane("ws", 1); + sim.tick(FRAME); + expect(sim.knobState().laneActive).toEqual([true, false, true, true]); + expect(sim.knobReadout(1).value).toBe(home[1]); + sim.setLane("ws", 1, 0.9); + sim.tick(FRAME); + expect(sim.knobState().laneActive[1]).toBe(true); + }); + + it("let one bus channel go on an empty param/N, inside the 250 ms a hand waits out", () => { + sim.applyRemoteParam(0, 800); + sim.applyRemoteParam(1, 800); + sim.tick(FRAME); + sim.turn(0, 1); + sim.releaseRemoteParam(1); + sim.tick(FRAME); + expect(sim.knobState().paramActive).toEqual([true, false, false, false]); + }); + + it("move a knob by clicks no hand made, one for one, and knobs counts none of them", () => { + const hand = new DeviceSim(); + hand.tick(FRAME); + hand.turn(1, 3); + hand.tick(FRAME); + sim.nudge(1, 3); + sim.tick(FRAME); + expect(values(sim)).toEqual(values(hand)); + expect(sim.given()?.input.knobDeltas).toEqual([0, 3, 0, 0]); + expect(sim.knobState().knobs).toEqual([0, 0, 0, 0]); + expect(sim.snapshot().turns).toEqual([0, 0, 0, 0]); + }); + + it("leave a nudged knob on its lane, and its bus channel held and in charge", () => { + sim.setEdition("audio"); + sim.setLaneSource("ws"); + sim.applyRemoteParam(3, 800); + run(sim, 300, () => sim.setLane("ws", 0, 0.5)); + const held = values(sim)[3]; + sim.setLane("ws", 0, 0.5); + sim.nudge(0, 2); + sim.nudge(3, 2); + sim.tick(FRAME); + expect(sim.knobState().laneActive[0]).toBe(true); + expect(sim.snapshot().laneHeld[0]).toBe(false); + expect(sim.knobState().paramActive[3]).toBe(true); + expect(values(sim)[3]).toBe(held); + expect(sim.given()?.input.knobDeltas).toEqual([2, 0, 0, 0]); + }); + + it("give the pattern a remote button's press once, and held until it is up, with no screen", () => { + sim.turn(2, 4); + sim.tick(FRAME); + expect(sim.knobReadout(2).value).toBeGreaterThan(sim.knobReadout(2).min); + sim.remoteButton(2, true); + sim.tick(FRAME); + expect(sim.given()?.input.btnPressed).toEqual([false, false, true, false]); + // Origin's press, as a hand's: K3 is back at its start. + expect(sim.knobReadout(2).value).toBe(sim.knobReadout(2).min); + sim.remoteButton(2, true); + run(sim, 2000); + expect(sim.given()?.input.btnPressed).toEqual(none); + expect(sim.given()?.input.btnHeld).toEqual([false, false, true, false]); + // A hand held this long opens KNOB MAP. + expect(sim.snapshot().mode).toBe("run"); + sim.remoteButton(2, false); + sim.tick(FRAME); + expect(sim.given()?.input.btnHeld).toEqual(none); + }); + + it("wake nothing: asleep, a nudge and a remote button are lost", () => { + sim.setSleeping(true); + run(sim, 1000); + sim.nudge(0, 5); + sim.remoteButton(0, true); + sim.tick(FRAME); + expect(sim.snapshot().mode).toBe("sleep"); + expect(sim.given()).toBeNull(); + sim.remoteButton(0, false); + sim.setSleeping(false); + sim.tick(FRAME); + expect(sim.given()?.input.knobDeltas).toEqual([0, 0, 0, 0]); + expect(sim.given()?.input.btnPressed).toEqual(none); + }); + + it("read what the pattern threw, frame by frame", () => { + const spy = vi.spyOn(PatternRuntime.prototype, "renderFrame").mockReturnValueOnce({ ok: false, error: "boom" }); + sim.tick(FRAME); + expect(sim.given()?.error).toBe("boom"); + sim.tick(FRAME); + expect(sim.given()?.error).toBeNull(); + spy.mockRestore(); + }); +}); diff --git a/web/src/lib/guide/deviceSim.ts b/web/src/lib/guide/deviceSim.ts index dae2d1da..794dd570 100644 --- a/web/src/lib/guide/deviceSim.ts +++ b/web/src/lib/guide/deviceSim.ts @@ -13,13 +13,14 @@ // device prints on its KNOB MAP screen. Where each one sits on the front is // the 3D stage's business. -import { PATTERN_DETENTS_PER_TURN, PatternRuntime, type ColorRamp, type PatternInput } from "@/lib/pattern/harness"; +import { PatternRuntime, type ColorRamp, type PatternInput } from "@/lib/pattern/harness"; import { parseRampAnnotation } from "@/lib/pattern/ramp"; import { hexToRgb } from "@/lib/pattern/color"; import { knobSetupFromCode } from "@/lib/community/knobs"; import { LOGICAL_KNOB_DEFAULTS, knobUnitsPerTurn } from "@/lib/pattern/controls"; import { livePresets } from "@/lib/presets"; import { BASICS_PACK } from "@/lib/pattern/packs"; +import { BUS_MAX, BUS_UNITS_PER_CLICK, DETENTS_PER_TURN, EDGES_PER_DETENT, KNOB_COUNT } from "@/lib/shared/constants"; import { BASICS_NAMES } from "./basicsNames"; import { brightnessPercent, @@ -37,8 +38,11 @@ export type SimMode = "off" | "run" | "brightness" | "network" | "knobmap" | "se export type SimPack = "origin" | "basics"; /** The firmware the board runs: the default, or the Audio edition (the OSC and AUD rows on hold K2). */ export type SimEdition = "core" | "audio"; -/** What may drive the four lanes: the extension's editor on the Audio guide (components/guide/EditorWindow). */ -export type SimLaneSource = "editor"; +/** + * What may drive the four lanes: the extension's editor on the Audio guide + * (components/guide/EditorWindow), or the audio WebSocket (docs/audio-ws-spec.md). + */ +export type SimLaneSource = "editor" | "ws"; export type SimPattern = { slug: string; @@ -141,11 +145,25 @@ export type SimConsoleState = { knobs: [number, number, number, number]; }; +/** The knobs as /api/status reports them (docs/rest-api.md). */ +export type SimKnobState = { + /** Clicks each encoder has made since load, after its /knobs settings. A hand's, and nothing else's. */ + knobs: [number, number, number, number]; + /** The bus, 0..1000 per channel, and which channels a remote writer holds. */ + params: [number, number, number, number]; + paramActive: [boolean, boolean, boolean, boolean]; + /** Each lane's last value, 0..1, and whether it has its knob once the bus and the hands have had theirs. */ + lanes: [number, number, number, number]; + laneActive: [boolean, boolean, boolean, boolean]; +}; + +/** What the pattern was given on a frame, and the error it threw there. */ +export type SimGiven = { input: PatternInput; error: string | null }; + // The absolute parameter bus (src/core_bus.h): what the console's sliders // write. A channel is held once written; a change of the held value reaches // a pattern as clicks, ten bus units each, the remainder carried; a hand on // that encoder takes it back after a grace. -const BUS_UNITS_PER_CLICK = 10; const BUS_RELEASE_GRACE_MS = 250; // The lanes (docs/audio-ws-spec.md): what sound writes. A lane is an absolute @@ -197,7 +215,7 @@ export class DeviceSim { private now = 0; private time = 0; - private knobs: Knob[] = Array.from({ length: 4 }, () => ({ + private knobs: Knob[] = Array.from({ length: KNOB_COUNT }, () => ({ down: false, downAt: 0, longFired: false, @@ -228,6 +246,13 @@ export class DeviceSim { private busResidual = [0, 0, 0, 0]; private busPending = [0, 0, 0, 0]; + // The sockets' side of the board (nudge, remoteButton, knobState, given). + private clicks = [0, 0, 0, 0]; + private nudgePending = [0, 0, 0, 0]; + private remoteDown = [false, false, false, false]; + private remoteEdge = [false, false, false, false]; + private lastGiven: SimGiven | null = null; + /** Called with the new mode whenever it changes. */ onModeChange: ((mode: SimMode) => void) | null = null; @@ -330,11 +355,16 @@ export class DeviceSim { * the AUD row switched off stops all four. */ setLane(source: SimLaneSource, knob: number, value: number) { - if (source !== this.laneSource || !Number.isInteger(knob) || knob < 0 || knob > 3 || !Number.isFinite(value)) return; + if (source !== this.laneSource || !Number.isInteger(knob) || knob < 0 || knob >= KNOB_COUNT || !Number.isFinite(value)) return; this.laneValue[knob] = clamp(value, 0, 1); this.laneAt[knob] = this.now; } + /** off=N: the lane goes back to its encoder now, as it would 500 ms after its last write. */ + releaseLane(source: SimLaneSource, knob: number) { + if (source === this.laneSource && isKnob(knob)) this.laneAt[knob] = -1e9; + } + /** * The guide may draw over the frame — not the firmware's doing, the page's: * the hub redraws the panel row by row when it shows a pattern being made @@ -359,7 +389,9 @@ export class DeviceSim { turn(knob: number, detents: number) { if (!Number.isFinite(detents) || detents === 0) return; // clickScale is 1 unless a console changed the knob's settings. - this.pendingDetents[knob] += detents * this.clickScale[knob]; + const clicks = detents * this.clickScale[knob]; + this.pendingDetents[knob] += clicks; + this.clicks[knob] += clicks; this.turns[knob] += detents; // "Hands always win": a turn takes the knob from its lane for five seconds. this.handAt[knob] = this.now; @@ -438,7 +470,8 @@ export class DeviceSim { this.pendingDetents = [0, 0, 0, 0]; const toPattern = this.handle(input); - if (this.busPending.some((d) => d !== 0)) this.applyBus(toPattern.detents); + const remote = [...this.busPending, ...this.nudgePending].some((d) => d !== 0) || this.remoteEdge.some(Boolean); + if (remote) this.applyRemote(toPattern); this.render(step, toPattern); } @@ -499,8 +532,8 @@ export class DeviceSim { * (core_bus.h applyRemoteParam). The 3D knob does not turn — nothing did. */ applyRemoteParam(knob: number, value: number) { - if (knob < 0 || knob > 3 || !Number.isFinite(value)) return; - const v = clamp(Math.round(value), 0, 1000); + if (knob < 0 || knob >= KNOB_COUNT || !Number.isFinite(value)) return; + const v = clamp(Math.round(value), 0, BUS_MAX); if (this.busHeld[knob]) { const acc = this.busResidual[knob] + (v - this.busValue[knob]); const d = Math.trunc(acc / BUS_UNITS_PER_CLICK); @@ -512,6 +545,51 @@ export class DeviceSim { this.busHeldAt[knob] = this.now; } + /** An empty MQTT param/N: the channel is let go now, grace or not (docs/mqtt-spec.md). */ + releaseRemoteParam(knob: number) { + if (isKnob(knob)) this.releaseBus(knob); + } + + /** + * Clicks that no hand made: OSC's knob/N/delta, MIDI CC 24 to 27, MQTT + * knob/N, the WebSocket's d=, POST /api/params dN. They reach the pattern + * where a turn's would, one for one, except on a channel the bus holds, + * which outranks them (docs/audio-ws-spec.md). The specs give a hand two + * things these do not get: the knob back from its lane, and its bus + * channel freed (mqtt-spec.md, midi-spec.md). They wake nothing, and + * `knobs` does not count them. + */ + nudge(knob: number, clicks: number) { + if (isKnob(knob) && Number.isInteger(clicks)) this.nudgePending[knob] += clicks; + } + + /** + * A button no hand pressed: MIDI notes 60 to 63 (docs/midi-spec.md). Down is + * a press edge, and the button reads as held until it is up. Only the + * pattern is given it, so it opens no screen and wakes nothing. + */ + remoteButton(knob: number, down: boolean) { + if (!isKnob(knob)) return; + if (down && !this.remoteDown[knob]) this.remoteEdge[knob] = true; + this.remoteDown[knob] = down; + } + + /** The fields of /api/status a socket reads: `knobs`, `params`, `paramActive`, `lanes`, `laneActive`. */ + knobState(): SimKnobState { + return { + knobs: [...this.clicks] as SimKnobState["knobs"], + params: [...this.busValue] as SimKnobState["params"], + paramActive: [...this.busHeld] as SimKnobState["paramActive"], + lanes: [...this.laneValue] as SimKnobState["lanes"], + laneActive: [0, 1, 2, 3].map((i) => this.laneDrives(i)) as SimKnobState["laneActive"], + }; + } + + /** What the pattern was given on the last lit frame, and what it threw; null while the panel is dark. */ + given(): SimGiven | null { + return this.lastGiven; + } + /** What a knob controls on the running pattern, for the readout beside it. */ knobReadout(knob: number): { label: string; value: number; min: number; max: number } { const [min, max] = this.ranges[knob] ?? [0, 1]; @@ -520,9 +598,9 @@ export class DeviceSim { /** The /knobs page: which way each encoder counts and how many of its 4 edges make a click. */ setKnobSettings(invert: boolean[], edgesPerClick: number[]) { - for (let i = 0; i < 4; i++) { - const sub = [1, 2, 4].includes(edgesPerClick[i]) ? edgesPerClick[i] : 4; - this.clickScale[i] = (invert[i] ? -1 : 1) * (4 / sub); + for (let i = 0; i < KNOB_COUNT; i++) { + const sub = [1, 2, 4].includes(edgesPerClick[i]) ? edgesPerClick[i] : EDGES_PER_DETENT; + this.clickScale[i] = (invert[i] ? -1 : 1) * (EDGES_PER_DETENT / sub); } } @@ -557,15 +635,22 @@ export class DeviceSim { this.busResidual[knob] = 0; } - /** The bus's clicks reach the pattern where a turn of that knob would. */ - private applyBus(detents: number[]) { + /** The bus's clicks, a nudge's and a remote button's press reach the pattern where a hand's would. */ + private applyRemote(toPattern: { detents: number[]; presses: boolean[] }) { const knobs = this.mode === "run" ? [0, 1, 2, 3] : this.mode === "brightness" ? [1, 2, 3] : []; const moved = [0, 0, 0, 0]; - for (const i of knobs) moved[i] = this.busPending[i]; + for (const i of knobs) moved[i] = this.busPending[i] + (this.busHeld[i] ? 0 : this.nudgePending[i]); + const edges = this.remoteEdge; this.busPending = [0, 0, 0, 0]; + this.nudgePending = [0, 0, 0, 0]; + this.remoteEdge = [false, false, false, false]; if (!knobs.length) return; this.moveValues(moved, knobs); - for (const i of knobs) detents[i] += moved[i]; + for (const i of knobs) { + toPattern.detents[i] += moved[i]; + if (edges[i]) toPattern.presses[i] = true; + } + if (this.mode === "run") this.originPress(edges); } // ── firmware rules (patternflow.ino; see panelScreens.ts FIRMWARE) ────────── @@ -675,20 +760,23 @@ export class DeviceSim { // The press edge reaches the pattern the moment the button goes down, // so a long-press delivers one too (patternflow.ino:1201-1205). this.moveValues(f.detents, [0, 1, 2, 3]); - // Origin's own click sends that knob back to its start (its update() - // zeroes the parameter); the knob's readout follows it there. - if (this.patterns[this.active]?.slug === "origin") { - const values = this.currentValues(); - f.edges.forEach((e, i) => { - if (e) values[i] = this.ranges[i]?.[0] ?? 0; - }); - } + this.originPress(f.edges); return { detents: f.detents.slice(), presses: f.edges.slice() }; } } return none(); } + // Origin's own click sends that knob back to its start (its update() + // zeroes the parameter); the knob's readout follows it there. + private originPress(edges: boolean[]) { + if (this.patterns[this.active]?.slug !== "origin") return; + const values = this.currentValues(); + edges.forEach((e, i) => { + if (e) values[i] = this.ranges[i]?.[0] ?? 0; + }); + } + private enterFrom(mode: SimMode) { this.setMode(mode); return { detents: [0, 0, 0, 0], presses: [false, false, false, false] }; @@ -700,7 +788,7 @@ export class DeviceSim { const d = detents[i]; if (!d) continue; const [min, max] = this.ranges[i] ?? [0, 1]; - const next = values[i] + d * (knobUnitsPerTurn([min, max]) / PATTERN_DETENTS_PER_TURN); + const next = values[i] + d * (knobUnitsPerTurn([min, max]) / DETENTS_PER_TURN); values[i] = this.wraps[i] ? wrap(next, min, max) : clamp(next, min, max); } } @@ -710,7 +798,7 @@ export class DeviceSim { // SELECT clears the name card (patternflow.ino:1731). if (mode === "select") this.noticeUntil = -1; // SELECT drops every held bus channel, so browsing never fights one (patternflow.ino). - if (mode === "select") for (let i = 0; i < 4; i++) if (this.busHeld[i]) this.releaseBus(i); + if (mode === "select") for (let i = 0; i < KNOB_COUNT; i++) if (this.busHeld[i]) this.releaseBus(i); this.mode = mode; this.idleAt = this.now; this.onModeChange?.(mode); @@ -813,6 +901,7 @@ export class DeviceSim { if (this.mode === "off" || this.mode === "sleep") { out.fill(0); for (let i = 3; i < out.length; i += 4) out[i] = 255; + this.lastGiven = null; return; } @@ -827,9 +916,10 @@ export class DeviceSim { }), knobRanges: ranges, btnPressed: toPattern.presses, - btnHeld: this.knobs.map((k) => this.mode === "run" && k.down), + btnHeld: this.knobs.map((k, i) => this.mode === "run" && (k.down || this.remoteDown[i])), }; - this.runtime.renderFrame(dt, this.time, input); + const result = this.runtime.renderFrame(dt, this.time, input); + this.lastGiven = { input, error: result.ok ? null : (result.error ?? "") }; const src = this.runtime.data; // A short power-on: the frame fades up over the first 0.6 s after boot. @@ -878,6 +968,10 @@ export class DeviceSim { } } +function isKnob(knob: number) { + return Number.isInteger(knob) && knob >= 0 && knob < KNOB_COUNT; +} + function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); } diff --git a/web/src/lib/guide/panelScreens.ts b/web/src/lib/guide/panelScreens.ts index 5fdced60..59048832 100644 --- a/web/src/lib/guide/panelScreens.ts +++ b/web/src/lib/guide/panelScreens.ts @@ -10,8 +10,9 @@ // firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h // drawPixel/fillRect overrides, the // rotation transform, RGB565 -> 888 -// Line numbers cited here are from those files at v3.10.4. When a screen -// changes there, change it here - the test pins a few pixels so drift shows. +// Firmware constants are cited by name. Other line numbers cited here are +// from those files at v3.10.4. When a screen changes there, change it here - +// the test pins a few pixels so drift shows. // // Things the port reproduces on purpose, because the panel does them: // - Adafruit GFX's text wrap is on (the default; nothing in the firmware turns @@ -36,11 +37,18 @@ // License: MIT. The two font tables this draws with are BSD - see glcdFont.ts // and tomThumbFont.ts for their notices. +import { + BUTTON_DEBOUNCE_MS, + EDGES_PER_DETENT, + LONG_PRESS_MS, + PANEL_GEOMETRIES, + PANEL_GEOM_DEFAULT, +} from "@/lib/shared/constants"; import { GLCD_FONT } from "./glcdFont"; import { TOM_THUMB, type GfxFont } from "./tomThumbFont"; -export const PANEL_W = 128; -export const PANEL_H = 64; +export const PANEL_W: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resW; +export const PANEL_H: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resH; const FRAME_BYTES = PANEL_W * PANEL_H * 4; export type Rgb = readonly [number, number, number]; @@ -71,15 +79,15 @@ export type PanelOverlay = wifi: WifiStatusText | (string & {}); /** ipString(): the dotted IP, or "-" when not connected (src/core_wifi.h:629-632). */ ip: string; - /** Feature toggle rows; a core build has none. At most two are drawn (patternflow.ino:759). */ + /** Feature toggle rows; a core build has none. At most two are drawn (MAX_FEATURE_ROWS, patternflow.ino). */ rows?: { name: string; on: boolean }[]; /** The hotspot's name, e.g. "patternflow-a1b2", when the panel's own AP is up. Shown only while not connected. */ hotspotName?: string; /** PatternflowWifi::isConnected(). Defaults to wifi === "CONNECTED". */ connected?: boolean; - /** "K1 = SLEEP" line; true unless a build compiles sleep out (config.h:247-248). */ + /** "K1 = SLEEP" line; true unless a build compiles sleep out (PF_SLEEP_ENABLED, config.h). */ sleepHint?: boolean; - /** "K4=UPDATE" line; true unless a build compiles the web update out (net_config.h:172-173). */ + /** "K4=UPDATE" line; true unless a build compiles the web update out (PF_WEBUPDATE_ENABLED, net_config.h). */ updateHint?: boolean; } | { @@ -123,7 +131,7 @@ export type PanelOverlay = // ── Firmware constants ────────────────────────────────────────────────────── -const DEFAULT_BRIGHTNESS = 204; // firmware/patternflow/config.h:241 ("80%") +const DEFAULT_BRIGHTNESS = 204; // DEFAULT_BRIGHTNESS, firmware/patternflow/config.h ("80%") /** The percentage the panel prints for a brightness byte (patternflow.ino:693). */ export function brightnessPercent(level: number): number { @@ -155,50 +163,53 @@ export interface FirmwareConstants { } export const FIRMWARE: Readonly = Object.freeze({ - // MODE_HOLD_MS, patternflow.ino:269. Fires WHILE still held, on the first - // frame the hold exceeds it (strict >, src/core_encoders.h:172); the release - // that follows is not a click (core_encoders.h:151-154). - longPressMs: 1000, - // Button edges closer than this are ignored (src/core_encoders.h:143). - buttonDebounceMs: 50, - // Quadrature edges per detent (knobSubSteps default, src/core_encoders.h:76); - // one detent = one knob delta (patternflow.ino:1168-1182, no acceleration). - edgesPerDetent: 4, - // BRIGHTNESS_IDLE_MS, patternflow.ino:270; checked with > at :1566. The timer - // restarts on entry (:1539) and only when a turn actually changes the level - // (:1555-1559) - turning against the 5/255 clamp does not keep it open. + // MODE_HOLD_MS in patternflow.ino, LONG_PRESS_MS in shared/constants.json. + // Fires WHILE still held, on the first frame the hold exceeds it (strict >, + // Button::longPressed in src/core_encoders.h); the release that follows is + // not a click (Button::pressed). + longPressMs: LONG_PRESS_MS, + // Button edges closer than this are ignored (Button::pressed in + // src/core_encoders.h, BUTTON_DEBOUNCE_MS in shared/constants.json). + buttonDebounceMs: BUTTON_DEBOUNCE_MS, + // Quadrature edges per detent (the knobSubSteps default in + // src/core_encoders.h, EDGES_PER_DETENT in shared/constants.json); one + // detent = one knob delta (readInputFrame in patternflow.ino, no acceleration). + edgesPerDetent: EDGES_PER_DETENT, + // BRIGHTNESS_IDLE_MS in patternflow.ino, checked with > in loop(). The timer + // restarts on entry and only when a turn actually changes the level - + // turning against the 5/255 clamp does not keep it open. brightnessIdleExitMs: 5000, - // BRIGHTNESS_SAVE_DELAY_MS, patternflow.ino:273 (NVS write after the mode closes). + // BRIGHTNESS_SAVE_DELAY_MS in patternflow.ino (NVS write after the mode closes). brightnessSaveDelayMs: 3000, - // OSC_INFO_IDLE_MS (the NETWORK screen's timer despite the name), patternflow.ino:274, :1658. + // OSC_INFO_IDLE_MS in patternflow.ino (the NETWORK screen's timer despite the name). networkIdleExitMs: 8000, - // KNOB_MAP_IDLE_MS, patternflow.ino:276, :1687. + // KNOB_MAP_IDLE_MS in patternflow.ino. knobmapIdleExitMs: 8000, - // KNOB_MAP_HILITE_MS, patternflow.ino:277; a knob stays lit this long after its last detent (:1005-1006). + // KNOB_MAP_HILITE_MS in patternflow.ino: a knob stays lit this long after its last detent (drawKnobMap). knobmapHighlightMs: 600, - // UPDATE_IDLE_MS, patternflow.ino:278, :1711 (not while flashing). + // UPDATE_IDLE_MS in patternflow.ino (not while flashing). updateIdleExitMs: 600000, - // NET_INFO_REDRAW_MS, patternflow.ino:275: info screens repaint at most this often (or on a change). + // NET_INFO_REDRAW_MS in patternflow.ino: info screens repaint at most this often (or on a change). infoRedrawMs: 250, - // SELECT_DETENTS_PER_STEP, patternflow.ino:240; see stepSelect(). + // SELECT_DETENTS_PER_STEP in patternflow.ino; see stepSelect(). selectDetentsPerStep: 3, - // SELECT_SETTLE_MS, patternflow.ino:234: the highlighted pattern loads once K4 has rested this long (:1943). + // SELECT_SETTLE_MS in patternflow.ino: the highlighted pattern loads once K4 has rested this long. selectSettleMs: 350, - // PATTERN_SAVE_DELAY_MS, patternflow.ino:207 (only once SELECT is left, :313). + // PATTERN_SAVE_DELAY_MS in patternflow.ino (only once SELECT is left, savePatternIfSettled). patternSaveDelayMs: 3000, - // CONTENT_NOTICE_SECONDS, patternflow.ino:279 - the name card after a remote pick; SELECT never shows it. + // CONTENT_NOTICE_SECONDS in patternflow.ino - the name card after a remote pick; SELECT never shows it. contentNoticeMs: 1000, - // constrain(currentBrightness + d * 5, 5, 255), patternflow.ino:1554. + // constrain(currentBrightness + d * 5, 5, 255) in loop(), patternflow.ino. brightnessMin: 5, brightnessMax: 255, brightnessStepPerDetent: 5, - // DEFAULT_BRIGHTNESS, config.h:241 - a byte, not a percent. + // DEFAULT_BRIGHTNESS in config.h - a byte, not a percent. defaultBrightness: DEFAULT_BRIGHTNESS, // brightnessPercent(204) = 80. defaultBrightnessPercent: brightnessPercent(DEFAULT_BRIGHTNESS), - // MAX_FEATURE_ROWS, patternflow.ino:759. + // MAX_FEATURE_ROWS in drawNetworkInfo, patternflow.ino. networkMaxFeatureRows: 2, - // WAKE_GUARD_MS, src/core_sleep.h:64: input ignored this long after falling asleep. + // WAKE_GUARD_MS in src/core_sleep.h: input ignored this long after falling asleep. wakeGuardMs: 800, }); @@ -224,7 +235,7 @@ export function wrapIndex(index: number, count: number): number { return ((index % count) + count) % count; } -// Fixed-size name buffers: MODULE_NAME_BYTES, firmware/patternflow/pattern_registry.h:139. +// Fixed-size name buffers: MODULE_NAME_BYTES, firmware/patternflow/pattern_registry.h. const MODULE_NAME_BYTES = 40; const LATIN1_FOLD = "AAAAAAACEEEEIIII" + // C0-CF @@ -235,8 +246,8 @@ const LATIN1_FOLD = /** * A pattern name as the panel prints it: asciiFold (patternflow.ino:577-601) * over the UTF-8 bytes. Latin-1 letters lose their accent, anything else - * becomes one '?'. Stored names live in 40-byte buffers (pattern_registry.h:139, - * snprintf at :217), so at most 39 bytes of the name survive. + * becomes one '?'. Stored names live in 40-byte buffers (MODULE_NAME_BYTES in + * pattern_registry.h, filled with snprintf), so at most 39 bytes of the name survive. */ export function asciiFold(name: string): string { const bytes = new TextEncoder().encode(name).subarray(0, MODULE_NAME_BYTES - 1); @@ -282,17 +293,17 @@ const rgb565 = (r: number, g: number, b: number): Rgb => color565to888(color565( /** The firmware's panel palette, as it lands in the frame buffer. */ export const PANEL_COLORS = Object.freeze({ black: rgb565(0, 0, 0), - led: rgb565(232, 85, 46), // pfLedC, patternflow.ino:635 - white: rgb565(255, 255, 255), // pfWhiteC, :636 - gray: rgb565(140, 140, 140), // pfGrayC, :637 - dim: rgb565(90, 90, 90), // pfDimC, :638 - rule: rgb565(60, 60, 60), // pfRuleC, :639 - green: rgb565(80, 220, 130), // pfGreenC, :640 - red: rgb565(255, 80, 80), // pfRedC, :641 - blue: rgb565(120, 180, 255), // pfBlueC, :642 - selectPage: rgb565(190, 190, 190), // "i / N", :1054 - selectHint: rgb565(200, 200, 200), // "HOLD TO SELECT", :1084 - knobActiveFill: rgb565(74, 27, 15), // KNOB MAP active disc, :1009 + led: rgb565(232, 85, 46), // pfLedC in patternflow.ino + white: rgb565(255, 255, 255), // pfWhiteC + gray: rgb565(140, 140, 140), // pfGrayC + dim: rgb565(90, 90, 90), // pfDimC + rule: rgb565(60, 60, 60), // pfRuleC + green: rgb565(80, 220, 130), // pfGreenC + red: rgb565(255, 80, 80), // pfRedC + blue: rgb565(120, 180, 255), // pfBlueC + selectPage: rgb565(190, 190, 190), // "i / N", drawSelectingMode + selectHint: rgb565(200, 200, 200), // "HOLD TO SELECT", drawSelectingMode + knobActiveFill: rgb565(74, 27, 15), // KNOB MAP active disc, drawKnobMap }); // ── A small Adafruit GFX over an RGBA frame ───────────────────────────────── @@ -729,8 +740,8 @@ function drawUiLine(g: PanelGfx, font: GfxFont | null, text: string, yTop: numbe } // PatternflowUiText::drawWrappedName, src/core_ui_text.h:447-449 -> drawWrapped -// (:388-445) with PF_UI_FONT_SELECT = the built-in 5x7 (core_ui_fonts.h:50-52) -// and pitch 9 (:64-66). +// (:388-445) with PF_UI_FONT_SELECT = the built-in 5x7 and PF_UI_PITCH_SELECT +// = 9 (both in core_ui_fonts.h). // // Only drawWrapped's first branch is ported, because it is the only one that // runs: it measures the whole name with getTextBounds, and with GFX wrap on, diff --git a/web/src/lib/lab/director/bake.ts b/web/src/lib/lab/director/bake.ts index 0d6cf59a..20c65831 100644 --- a/web/src/lib/lab/director/bake.ts +++ b/web/src/lib/lab/director/bake.ts @@ -7,8 +7,10 @@ // The shipping path is PFST v2 (bakeShowV2): cues on the 0.1 s grid, curve // segments adaptively flattened into EASE pieces the player lerps between, // within a sub-detent error of the authored bezier. bakeShow (v1, the dense -// whole-second staircase) stays for compatibility checks and old players — -// nothing in the lab's own path uses it anymore. +// whole-second staircase) was the shipping path until 25 August 2026 +// (7749e69). Its one reader now is scripts/lab-director-smoke.ts, which runs +// its round trips on it and measures v2's cue count against it. Firmware +// before v3.6.3 plays only v1 tables, and nothing on the site bakes for it. import { PFST_MAX_CUES, diff --git a/web/src/lib/lab/legacyDraft.ts b/web/src/lib/lab/legacyDraft.ts index fbd71b39..446c29cc 100644 --- a/web/src/lib/lab/legacyDraft.ts +++ b/web/src/lib/lab/legacyDraft.ts @@ -1,18 +1,19 @@ -// ── Pattern Lab draft autosave ─────────────────────────────────────────────── -// The lab is a long-session tool: people tune a pattern for an hour, edit the -// ramp, generate a gallery. Closing the tab (or a crash) used to throw all of -// that away, because only the ramp and the Experiment patch were persisted. -// -// This module stores the rest of the working state in localStorage so reopening -// the page lands back where the user left off. Two keys on purpose: +// ── Pattern Lab: the July draft, and the gallery ───────────────────────────── +// The lab's autosave (e19fe19, 25 July 2026) stored the working session under +// two keys on purpose: // // DRAFT_STORAGE small, always worth keeping (code, knobs, ranges, ...) // GALLERY_STORAGE potentially hundreds of KB of generated patterns // // A quota failure while writing the (large) gallery must never take the (small) -// draft down with it, hence the split. Every read is defensive: a draft written -// by an older build, or hand-edited, must degrade to "no draft" rather than -// crash the lab on boot. +// draft down with it, hence the split. +// +// Pattern Lab v2 (9be6e98, 27 July) moved the working session into the layered +// project and stopped writing the draft. loadDraft stays for serialize.ts's +// migrateLegacyDraft. The gallery never moved, and loadGallery and saveGallery +// are its live reader and writer. Every read is defensive: a draft written by +// an older build, or hand-edited, must degrade to "no draft" rather than crash +// the lab on boot. import { COLOR_MODES, THINKING_LEVELS } from "@/lib/ai/settings"; import type { ColorMode, PatternVariant, ThinkingLevelKey } from "@/lib/ai/settings"; @@ -21,7 +22,7 @@ import { isValidMatrix, type MatrixSize } from "@/lib/pattern/matrix"; import { LAB_STORAGE, readJson, removeStorage as remove, writeJson } from "./persist"; export const DRAFT_STORAGE = LAB_STORAGE.legacyDraft; -export const GALLERY_STORAGE = LAB_STORAGE.legacyGallery; +export const GALLERY_STORAGE = LAB_STORAGE.gallery; // Guard rails so one runaway value can't blow the whole quota. const MAX_CODE_CHARS = 200_000; @@ -141,11 +142,6 @@ export function loadDraft(knobCount: number): Partial | null { }; } -export function saveDraft(draft: Omit): boolean { - if (draft.code.length > MAX_CODE_CHARS) return false; - return writeJson(DRAFT_STORAGE, { ...draft, savedAt: Date.now() }); -} - export function clearDraft() { remove(DRAFT_STORAGE); remove(GALLERY_STORAGE); diff --git a/web/src/lib/lab/persist.ts b/web/src/lib/lab/persist.ts index 6be03e56..3157fe48 100644 --- a/web/src/lib/lab/persist.ts +++ b/web/src/lib/lab/persist.ts @@ -6,9 +6,10 @@ // lab stored without grepping for "patternflow_lab_". // // Bumping a version means: add the new key here, keep the old one, and write a -// migration that reads the old and writes the new (serialize.ts's -// migrateLegacyDraft is the precedent — it lifts _draft_v1 + _gallery_v1 into -// _project_v2 the first time the new lab opens). +// migration that reads the old and writes the new. serialize.ts's +// migrateLegacyDraft is the precedent: it lifts _draft_v1 and _ramp_v1 into a +// _project_v2 project. The gallery was never migrated. It still lives under +// its _v1 key. export const LAB_STORAGE = { /** The whole layered project — layers, knobs, ramps, director, gen settings. serialize.ts */ @@ -21,11 +22,24 @@ export const LAB_STORAGE = { capture: "patternflow_lab_capture_v1", /** Shader twins by layer id / code hash. capture/shaderStore.ts */ shader: "patternflow_lab_shader_v1", - /** v1 lab: the single-pattern draft. Read once by the migration. legacyDraft.ts */ + /** + * The single-pattern draft, written by the lab's autosave from 25 July 2026 + * (e19fe19) until Pattern Lab v2 replaced it on 27 July (9be6e98). Nothing + * writes it now. The migration reads it on every open where no project + * restores, an empty canvas after a stash included. legacyDraft.ts + */ legacyDraft: "patternflow_lab_draft_v1", - /** v1 lab: the generated gallery. Still live — the gallery has not moved into the project. legacyDraft.ts */ - legacyGallery: "patternflow_lab_gallery_v1", - /** v1 lab: the colour ramp, kept apart from the draft back then. Read once by the migration. serialize.ts */ + /** + * The generated gallery. The lab's autosave has written it since 25 July + * 2026 (e19fe19) and still does, because the gallery never moved into the + * project. legacyDraft.ts + */ + gallery: "patternflow_lab_gallery_v1", + /** + * The colour ramp, written by the lab on every ramp edit from 2 July 2026 + * (67b9798) until Pattern Lab v2 on 27 July (9be6e98). Nothing writes it + * now. The migration reads it with the draft. serialize.ts + */ legacyRamp: "patternflow_ramp_v1", } as const; diff --git a/web/src/lib/lab/serialize.ts b/web/src/lib/lab/serialize.ts index 555b21c2..b09dbd24 100644 --- a/web/src/lib/lab/serialize.ts +++ b/web/src/lib/lab/serialize.ts @@ -4,9 +4,10 @@ // Reads are defensive: a project written by any other build must degrade to // "no project" (or partial restore) rather than crash the lab on boot. // -// Migration: when no v2 project exists, the old single-pattern draft -// (patternflow_lab_draft_v1 + patternflow_ramp_v1) is lifted into a one-layer -// project, so nobody loses work by opening the new lab once. +// Migration: when no project restores, the single-pattern draft the lab wrote +// from 25 to 27 July 2026 (patternflow_lab_draft_v1 + patternflow_ramp_v1) is +// lifted into a one-layer project, so nobody lost work by opening the new lab. +// persist.ts says who wrote each key. import { RAMP_MODES, type RampMode } from "@/lib/pattern/harness"; import { COLOR_MODES, THINKING_LEVELS } from "@/lib/ai/settings"; @@ -76,7 +77,7 @@ export function layoutViewCount(layout: unknown): number { const grid = (layout as { grid?: { root?: unknown } } | null | undefined)?.grid; return walk(grid?.root); } -/** Old single-pattern lab keys, read once for migration. */ +/** The colour ramp the lab wrote from 2 to 27 July 2026. Only the migration reads it. */ const LEGACY_RAMP_STORAGE = LAB_STORAGE.legacyRamp; const MAX_CODE_CHARS = 200_000; @@ -533,10 +534,13 @@ export function clearProject() { removeStorage(PROJECT_STORAGE); } -// ── v1 migration ── +// ── migration from the July draft ── /** - * Lift the old single-pattern draft into a one-layer project. Returns null - * when there is nothing to migrate. + * Lift the single-pattern draft the lab's autosave wrote from 25 to 27 July + * 2026 into a one-layer project. Returns null when there is no draft. The + * store calls this whenever loadProject returns null, and an empty canvas + * saves a project loadProject refuses, so a browser that still holds the + * draft gets it back on every such open, not once. */ export function migrateLegacyDraft(): (LabProject & { savedAt: number }) | null { const draft = loadDraft(PATTERN_KNOB_COUNT); @@ -582,7 +586,7 @@ export function migrateLegacyDraft(): (LabProject & { savedAt: number }) | null knobLabels: draft.knobLabels ?? knobState.labels, director: emptyShow(), forkOf: draft.forkOf ?? null, - // The legacy single-draft format predates in-place editing entirely. + // The July draft predates in-place editing entirely. editOf: null, gen: readGen(draft.gen), }; diff --git a/web/src/lib/lab/store/project.ts b/web/src/lib/lab/store/project.ts index 2a466889..d32f06a7 100644 --- a/web/src/lib/lab/store/project.ts +++ b/web/src/lib/lab/store/project.ts @@ -17,6 +17,8 @@ export function createProjectSlice(set: SliceSet, get: SliceGet): ProjectSlice { return { hydrate: () => { if (get().hydrated) return; + // No project, or one with no layers: lift the July draft if the browser + // still holds one. persist.ts names the keys. const restored = loadProject() ?? migrateLegacyDraft(); if (restored) { const { savedAt, ...project } = restored; @@ -33,7 +35,7 @@ export function createProjectSlice(set: SliceSet, get: SliceGet): ProjectSlice { discardProject: () => { clearProject(); - clearDraft(); // also clears the legacy draft + gallery keys + clearDraft(); // the July draft's key, and the gallery's set({ ...defaultProject(), restoredAt: null, diff --git a/web/src/lib/pattern/controls.ts b/web/src/lib/pattern/controls.ts index 7c945677..0f84f834 100644 --- a/web/src/lib/pattern/controls.ts +++ b/web/src/lib/pattern/controls.ts @@ -1,16 +1,17 @@ -import { PATTERN_DETENTS_PER_TURN } from "./harness"; +import { BUS_MAX, DETENTS_PER_TURN, TURNS_PER_FULL_RANGE } from "@/lib/shared/constants"; // Detents in one full turn of the physical encoder. One fact, one constant: -// harness.ts owns it (see the note there — the reference Bourns -// PEC11R-...-S0024 is 24, not the 20 assumed for a long time) and this is the -// name the knob math has always used for it. -export const ENCODER_CLICKS_PER_TURN = PATTERN_DETENTS_PER_TURN; +// shared/constants.json owns it (see the note in harness.ts — the reference +// Bourns PEC11R-...-S0024 is 24, not the 20 assumed for a long time) and this +// is the name the knob math has always used for it. +export const ENCODER_CLICKS_PER_TURN = DETENTS_PER_TURN; // The device's absolute bus speaks integers 0..1000 (PF_BUS_MAX in -// firmware/patternflow/src/core_bus.h). Everything here that writes a wire -// value — the Director's bake, the .pfs codec, the MIDI export — uses this -// rather than the literal, so the two sides can only disagree in one place. -export const BUS_WIRE_MAX = 1000; +// firmware/patternflow/src/core_bus.h, BUS_MAX in shared/constants.json). +// Everything here that writes a wire value — the Director's bake, the .pfs +// codec, the MIDI export — uses this rather than the literal, so the two sides +// can only disagree in one place. +export const BUS_WIRE_MAX = BUS_MAX; // How many full turns cross a parameter's ENTIRE range. // @@ -21,7 +22,7 @@ export const BUS_WIRE_MAX = 1000; // Deriving travel from the range makes every knob feel the same regardless of // what it controls, and keeps the web preview and the encoder in step because // both read the value from here. -export const TURNS_PER_FULL_RANGE = 2; +export { TURNS_PER_FULL_RANGE }; /** Value units covered by one full turn, for a parameter spanning `range`. */ export function knobUnitsPerTurn(range: readonly [number, number]) { diff --git a/web/src/lib/pattern/harness.ts b/web/src/lib/pattern/harness.ts index 8fbf7729..e1aa9564 100644 --- a/web/src/lib/pattern/harness.ts +++ b/web/src/lib/pattern/harness.ts @@ -1,14 +1,16 @@ -export const PATTERN_MATRIX_WIDTH = 128; -export const PATTERN_MATRIX_HEIGHT = 64; -export const PATTERN_KNOB_COUNT = 4; +import { DETENTS_PER_TURN, KNOB_COUNT, PANEL_GEOMETRIES, PANEL_GEOM_DEFAULT } from "@/lib/shared/constants"; + +export const PATTERN_MATRIX_WIDTH: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resW; +export const PATTERN_MATRIX_HEIGHT: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resH; +export const PATTERN_KNOB_COUNT = KNOB_COUNT; // Detents in one full turn of the physical encoder. The BOM reference part is // Bourns PEC11R-4220F-S0024 — the S0024 suffix means 24 detents / 24 PPR, which // distributors confirm. This was 20 for a long time, which silently put the web // preview 20 % out of step with the hardware it claims to mirror. // Sourcing a different encoder? Generic EC11s ship as 20, 24 or 30 detents — -// this constant has to match whatever is actually in the build. -// Keep in sync with ENCODER_CLICKS_PER_TURN in patternflowControls.ts. -export const PATTERN_DETENTS_PER_TURN = 24; +// DETENTS_PER_TURN in shared/constants.json has to match whatever is actually +// in the build. controls.ts reads the same value as ENCODER_CLICKS_PER_TURN. +export const PATTERN_DETENTS_PER_TURN = DETENTS_PER_TURN; export type PatternParams = Record; diff --git a/web/src/lib/pattern/matrix.ts b/web/src/lib/pattern/matrix.ts index 10b328c4..4ecc7ce4 100644 --- a/web/src/lib/pattern/matrix.ts +++ b/web/src/lib/pattern/matrix.ts @@ -24,10 +24,15 @@ // public/pattern-sandbox.html is built from this module // (scripts/build-sandbox.ts), so there is no second parser to keep in step. +import { PANEL_GEOMETRIES, PANEL_GEOM_DEFAULT } from "@/lib/shared/constants"; + export type MatrixSize = { width: number; height: number }; /** The stock Patternflow panel. Used whenever a pattern carries no @matrix. */ -export const DEFAULT_MATRIX: MatrixSize = { width: 128, height: 64 }; +export const DEFAULT_MATRIX: MatrixSize = { + width: PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resW, + height: PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resH, +}; /** * The stock panel in either orientation — the board is mounted whichever way @@ -69,7 +74,7 @@ export function clampMatrixDimension(value: number): number | null { * Pixels per frame beyond which the JS preview starts to struggle — four times * the stock panel. Not a limit, just the point worth warning about. */ -export const MATRIX_HEAVY_PIXELS = 128 * 64 * 4; +export const MATRIX_HEAVY_PIXELS = DEFAULT_MATRIX.width * DEFAULT_MATRIX.height * 4; export function isValidMatrix(size: Partial | null | undefined): boolean { if (!size) return false; diff --git a/web/src/lib/shared/constants.ts b/web/src/lib/shared/constants.ts new file mode 100644 index 00000000..e5211834 --- /dev/null +++ b/web/src/lib/shared/constants.ts @@ -0,0 +1,29 @@ +// GENERATED by web/scripts/gen-shared.ts from shared/constants.json. Do not edit. +// Change the JSON and run `npm run gen:shared` in web/. check:shared fails +// while this file says anything else. + +// The geometries PANEL_GEOMETRY picks from in config.h. Two 64x64 modules in a +// chain make the same 128x64 canvas as one 128x64 module. +export const PANEL_GEOMETRIES = { + "128x64_SINGLE": { id: 1, physW: 128, physH: 64, chain: 1, resW: 128, resH: 64 }, + "64x64_SINGLE": { id: 2, physW: 64, physH: 64, chain: 1, resW: 64, resH: 64 }, + "64x64_CHAIN2": { id: 3, physW: 64, physH: 64, chain: 2, resW: 128, resH: 64 }, +} as const; +export const PANEL_GEOM_DEFAULT = "128x64_SINGLE"; + +// The PEC11R-4220F-S0024 has 24 detents a turn. Two turns cross a parameter's +// whole range, so 48 detents do. +export const KNOB_COUNT = 4; +export const DETENTS_PER_TURN = 24; +export const TURNS_PER_FULL_RANGE = 2; + +// Quadrature edges in one detent. A press held past LONG_PRESS_MS is a long +// press, and button edges closer than BUTTON_DEBOUNCE_MS are ignored. +export const EDGES_PER_DETENT = 4; +export const LONG_PRESS_MS = 1000; +export const BUTTON_DEBOUNCE_MS = 50; + +// The absolute bus speaks integers 0 to BUS_MAX. A pattern that reads only knob +// deltas gets one click for every BUS_UNITS_PER_CLICK a held channel moves. +export const BUS_MAX = 1000; +export const BUS_UNITS_PER_CLICK = 10; diff --git a/web/src/proxy.ts b/web/src/proxy.ts index 7387f8e1..df9de46f 100644 --- a/web/src/proxy.ts +++ b/web/src/proxy.ts @@ -32,6 +32,11 @@ function normalizeJournalPath(pathname: string) { return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; } +// No journal page ends in /en/en, but the first ?lang=en redirect in +// next.config.ts makes one: it reads /journal/en?lang=en as a post called "en" +// and answers /journal/en/en. The language switch on the Korean index has +// linked /journal/en?lang=en since 17 May 2026 (668eaa2c), and this collapse +// came the next day (83050af7). function collapseDuplicateEnglishSuffix(pathname: string) { const normalizedPathname = normalizeJournalPath(pathname);