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 b95315876..d07895ba7 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 e67ad6201..cdcde0f89 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/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 776794203..24ab53dcb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,5 +9,10 @@ ## Tested +## Which row + + --- - [ ] Touches `web/` → it builds (`npm run build`) +- [ ] Touches `firmware/` → every composition builds (`./firmware/bundles/build.sh all`), and the core still names no feature (`python firmware/toolchain/check_boundaries.py`) +- [ ] Touches a markdown file → its relative links resolve (`python3 .github/scripts/check_links.py`) diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 57% rename from SECURITY.md rename to .github/SECURITY.md index 98915fe88..772748efa 100644 --- a/SECURITY.md +++ b/.github/SECURITY.md @@ -7,13 +7,13 @@ Please report privately through [GitHub's report form](https://github.com/engmun ## What is in scope - **The community site** ([community.patternflow.work](https://community.patternflow.work)): accounts and sessions, uploads, the sandboxed iframe that runs published patterns, moderation. -- **The browser flasher and update handoff** ([patternflow.work/flash](https://patternflow.work/flash), `/update`): the firmware images it serves and where they come from. +- **The browser flasher and update handoff** (the **Flash Patternflow** button on [patternflow.work/pattern](https://patternflow.work/pattern) and in the Play guide, which installs the images under `/flash/`; and [`/update`](https://patternflow.work/update)): the firmware images they serve and where they come from. - **The build worker** that turns a pattern into a `.pfm` module on the community host. - **Published firmware images.** They must not carry credentials. Three early releases did carry the maintainer's Wi-Fi credentials, which is why the release workflow now refuses an image built with `patternflow_secrets.h`. If you find one that slipped through, that is a report. ## 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`. 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 it. 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 51% rename from SUPPORT.md rename to .github/SUPPORT.md index b5f6e1121..e9dc5ab2b 100644 --- a/SUPPORT.md +++ b/.github/SUPPORT.md @@ -6,12 +6,22 @@ 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 | | 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.** + +1. The DevKit out of the board, on its **left** USB-C port (labelled `USB`), with a cable that carries data — a charge-only cable is the most common reason nothing shows up — in desktop Chrome or Edge. That port is for flashing and data only; the panel is always powered through `J4`, the screw terminal. +2. **Download mode, every first flash:** hold **BOOT**, tap **RST**, let go of BOOT. Then press **Flash Patternflow** (on the [Pattern tab](https://patternflow.work/pattern) under *Got the hardware?*, or in the Play guide) and pick the port (`USB JTAG/serial debug unit`). +3. **The Wi-Fi step usually does not appear** after *Installation complete!* That is expected: press **RST** once, press **Flash Patternflow** again, pick the port, choose **Connect to Wi-Fi**, and after about ten seconds of scanning type your network's name exactly (capitals count; 2.4 GHz only) and its password. +4. **If that will not take,** put the DevKit back in the board and power it: about 15 seconds after it finds no network it knows, the panel raises its own, `patternflow-xxxx` (password `patternflow`; hold **K2** to see the exact name). Not in your phone's list yet? Wait, and refresh the list. Join it (the phone will say the network has no internet, which is true; stay on it), open `http://192.168.4.1/`, and pick your network on the Wi-Fi page. + +A board that already runs Patternflow is updated over Wi-Fi from its console's Update page instead — download mode erases the patterns and saved networks. **If Discord is blocked where you live**, the community site still works for patterns and decks without it, and a bug or a documentation issue here reaches the maintainer the same day. A discussion venue that works everywhere is planned; until it exists, an issue is the fallback for a question too. diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml new file mode 100644 index 000000000..c41a676e9 --- /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: 22 + 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 87717e892..3253a76fc 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -33,16 +33,17 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: web/package-lock.json - 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 a66463721..6e8973332 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.0 board**), `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). -- `hardware/` — Hardware designs. `case/` (Blender source in `source/`, STLs by printer bed size, `legacy_v2/` for the v2.x board), `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 (`/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). +- `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 prompt Pattern Lab's Copy prompt puts on the clipboard (the site calls no AI service). Architecture doc: `web/ARCHITECTURE.md`. The JS presets in `web/src/lib/presets/` are the source of truth for firmware preset headers. +- `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 dfb0b5fef..f6fe29b44 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. @@ -23,7 +23,7 @@ This guide walks you through building a Patternflow on the v3 board from scratch 4. [3D Printing](#4-3d-printing) 5. [PCB Assembly](#5-pcb-assembly) 6. [Case Assembly](#6-case-assembly) -7. [Wiring & First Power-Up](#7-wiring--first-power-up) +7. [Wiring](#7-wiring) 8. [Firmware](#8-firmware) 9. [Final Checks](#9-final-checks) 10. [Known Issues & Design Notes](#10-known-issues--design-notes) @@ -217,7 +217,7 @@ By now the enclosure halves you bonded in Section 4 have cured and the board is - The power bank slides into the internal compartment behind the board bay. - The four black knobs press-fit onto the encoder shafts — save them for **last**, after the Section 9 checks pass. -## 7. Wiring & First Power-Up +## 7. Wiring 1. Connect the HUB75 ribbon from `J1` to the panel's `IN` connector. 2. Wire `J3` to the panel's power cable (+5V / GND — double-check polarity). The wiring run is shown at **06:57** in the [assembly video](https://youtu.be/J9C9bZgkNKs). @@ -233,44 +233,38 @@ By now the enclosure halves you bonded in Section 4 have cured and the board is *Cable already through the enclosure hole, wires clamped into `J4`, board about to go in.* -4. With power off, seat the ESP32 DevKit in its sockets (orientation per silkscreen), then power on. +4. **Leave the ESP32 DevKit out of its sockets for now.** It gets its firmware on its own, out of the board (§8.1), and goes in only after that, with the power bank unplugged. The first power-up is §8.1, step 5. ## 8. Firmware -The ESP32-S3 module is flashed **separately, outside the PCB**. Already seated it? Just pop it gently out of the sockets, flash it, plug it back in, and connect power — done. **One firmware image serves every board generation** — the pin map is identical on v2.x and v3.0, so there is no board selection to get wrong. +The ESP32-S3 module is flashed **on its own, out of the board**, once; after that, patterns and firmware updates arrive over Wi-Fi. Already seated it? Lift it gently straight up out of the sockets first. **One firmware image serves every board generation** — the pin map is identical on v2.x and v3.0, so there is no board selection to get wrong. ### 8.1 Browser flash (recommended) -No installation required — desktop **Chrome or Edge** only (Web Serial; Firefox/Safari won't work). +No installation required — desktop **Chrome or Edge** only (Web Serial; Firefox/Safari won't work). Steps 1–6 follow the Play guide's [Flash chapter](https://patternflow.work/guide/play#flash) in the same order, and step 7 is its [Patterns chapter](https://patternflow.work/guide/play#patterns). > 🎬 **See it before you do it.** [patternflow.work/guide/play](https://patternflow.work/guide/play) walks through this section — the port, BOOT and RST, the Wi-Fi step, the Basics pack — on a Patternflow you can turn, and every step has a link for telling us where you got stuck. > 🔌 **Use the LEFT USB-C port** — the one on your left when the two ports face you. On the ESP32-S3 DevKit that's the board's **native USB** port (labeled `USB`); the browser flasher (Web Serial + Improv) talks to it directly. The right-hand port goes through a separate USB-to-UART bridge chip and is the one Arduino IDE uses (§8.2) — the flasher may still write the firmware through it, but the Wi-Fi step never appears there, because the firmware only listens for it on the left port. -1. Visit **[patternflow.work](https://patternflow.work)** on a desktop browser. -2. Connect the ESP32-S3 to your computer with a USB-C **data cable**, using the **left port** (see above). -3. Open the **Pattern** tab, find **Got the hardware?**, click **"Flash Patternflow"**, pick the serial port, and follow the on-screen steps. A new install erases the module first; a module already running Patternflow is updated in place and keeps its patterns and Wi-Fi. -4. **Wi-Fi.** When the install finishes, press **Next** and the flasher asks for your network (Improv-Serial). Type the name exactly — there is no list to pick from, and it is case-sensitive — and use a **2.4 GHz** network; the ESP32-S3 cannot see 5 GHz. If the install ends without asking, press **RST** on the module once, click **Flash Patternflow** again, pick the port, and choose **Connect to Wi-Fi**. Some modules do not restart into Patternflow on their own after flashing; the button does it. -5. Disconnect, seat the module back into the board sockets (orientation per silkscreen), and connect power. -6. **Load the patterns.** The image ships with **Origin only** — the rest live on the device's filesystem instead of inside the firmware, which is what freed the memory for everything else. Open **[the decks shelf](https://community.patternflow.work/community/decks)** and press **Install to my board** on the **Basics** pack: 33 patterns, one click, no account. Your browser fetches the pack and hands it to the board over your Wi-Fi, so the board is never talking to the internet itself. On a freshly flashed board the device's page first says **Storage needs formatting**: press **Format storage**, confirm, and the pack installs by itself as soon as the format is done. On Android, `patternflow.local` does not resolve — type the board's IP address (hold **K2**) into the **Device address** field on the Basics card instead. +1. **Connect the module on its own.** Hold it antenna up, ports toward you, and connect its **left port** (see above) to your computer with a USB-C **data cable**. A charge-only cable is the usual reason nothing shows up. +2. **Download mode first — every first flash, before you click anything in the browser.** Press and **hold BOOT**, **tap RST** (labelled EN on some modules), then **let go of BOOT**. The module now waits for its firmware. + + In download mode the flasher can't tell the module already runs Patternflow, so it **erases it** — installed patterns and saved Wi-Fi included. Fine on a new build. A board already running Patternflow updates over Wi-Fi instead, from its console's **Update** page, and keeps everything. +3. **Flash.** On **[patternflow.work](https://patternflow.work)**, open the **Pattern** tab, find **Got the hardware?** and click **Flash Patternflow** (the Play guide's Flash chapter has the same button). Pick the line like `USB JTAG/serial debug unit (COM4)` — the number depends on which USB port you used — and install. It takes about two minutes. Not in the list? Hold BOOT, tap RST again, then click the button again. +4. **Wi-Fi — expect it to take a second go.** Usually the install ends without asking for your network. Nothing failed: after download mode the module just hasn't restarted into Patternflow yet. Close the dialog, press **RST** once, click **Flash Patternflow** again and pick the port: this time it opens on Patternflow's own menu instead of installing. Choose **Connect to Wi-Fi**, let the short scan finish (it lists nothing, by design), and type your network's name exactly — it is case-sensitive — and its password. Use a **2.4 GHz** network; the ESP32-S3 cannot see 5 GHz. (Sometimes the same form comes straight after the install instead; fill it in there the same way.) Still won't join? Carry on: step 6 does it from a phone. +5. **Back in, power on.** Unplug the USB cable. With the power bank unplugged, seat the module in the board's sockets the way the silkscreen shows: its USB-C ports toward the board's `USB` mark, all 44 pins in, neither row shifted by one. Then plug the `J4` lead into the power bank — never the module's USB ports (Section 2). Within a second or two the panel lights with **Origin**, a red, squarish pattern. +6. **Still no Wi-Fi? The panel is a network itself.** Give it about 15 seconds after power-on, then hold **K2**, the top-left knob, for a second. `CONNECTED` and an address: it's on your Wi-Fi, carry on. `HOTSPOT` and a name like `patternflow-a1b2`: it found no network it knows, so it raised its own. Join that network from a phone or laptop — the password is `patternflow` — and open `http://192.168.4.1/`. The phone will say the network has no internet; that is true, and it stays connected. On the **Wi-Fi** page, pick your network, add its password and save: the panel shows `JOINED` and its new address, or why it couldn't. Neither on K2 yet? Wait a few seconds and hold it again. Not in your phone's list yet? Refresh it (or switch Wi-Fi off and on), and look for the exact name K2 shows. +7. **Load the patterns.** The image ships with **Origin only** — the rest live on the device's filesystem instead of inside the firmware, which is what freed the memory for everything else. Back on your own Wi-Fi, open **[the decks shelf](https://community.patternflow.work/community/decks)** and press **Install to my board** on the **Basics** pack: 33 patterns, one click, no account. Your browser fetches the pack and hands it to the board over your Wi-Fi, so the board is never talking to the internet itself. On a freshly flashed board the device's page first says **Storage needs formatting**: press **Format storage**, confirm, and the pack installs by itself as soon as the format is done. On Android, `patternflow.local` does not resolve — type the board's IP address (hold **K2**) into the **Device address** field on the Basics card instead. > 🎛️ **One pattern after flashing is correct, not a failed install.** It used to be 34 baked into the image. They moved out so that patterns can be added and removed without reflashing, which is also how anything you make yourself reaches the panel. -> 🔍 **Nothing in the port list?** A module that is running its own firmware does not always announce itself, so put it into download mode by hand before you look again: -> -> 1. Press and **hold BOOT** -> 2. **Tap and release EN / RST** (the reset button) -> 3. **Release BOOT** -> -> The picker should now offer a line like `USB JTAG/serial debug unit (COM4) – Paired`. The number depends on which USB port you used. -> -> In download mode the firmware cannot answer the flasher, so it treats the module as new and **erases it** — installed patterns and saved Wi-Fi included. Press **RST** alone to bring a module back to normal first if you only meant to update it. +> 🔍 **Still nothing in the port list?** Put the module into download mode again (step 2) and look again. Still nothing? It is almost always the cable — a charge-only USB-C one enumerates nothing at all. **There is no driver to install on this port**: the ESP32-S3 handles USB itself, so the CP2102 / CH34x links on the flasher's troubleshooting screen do not apply here. > -> Still nothing? It is almost always the cable — a charge-only USB-C one enumerates nothing at all. **There is no driver to install on this port**: the ESP32-S3 handles USB itself, so the CP2102 / CH34x links on the flasher's troubleshooting screen do not apply here. +> Only meant to update a module that already runs Patternflow, over USB? Skip download mode: plugged in as it is (or after a press of **RST** alone), the flasher recognises Patternflow and updates it in place, keeping its patterns and Wi-Fi. The console's **Update** page does the same over Wi-Fi. -> 📶 **Changing Wi-Fi later.** The network you set during flashing is **saved on the device and reused on every boot**. The panel remembers up to five networks — home, studio, a venue — and tries the most recent first, so moving it does not mean reflashing. Add or forget networks on the device's **Wi-Fi** page (the console at `patternflow.local`, or the board's IP address). A plain Arduino IDE re-upload does **not** clear the stored networks; **Tools → *Erase All Flash Before Sketch Upload*** does. +> 📶 **Changing Wi-Fi later.** The network you set during flashing is **saved on the device and reused on every boot**. The panel remembers up to five networks — home, studio, a venue — and tries the most recent first, so moving it does not mean reflashing. Add or forget networks on the device's **Wi-Fi** page (the console at `patternflow.local`, or the board's IP address). Somewhere with none of them, it raises its own `patternflow-xxxx` network again, as in step 6 (every image the flasher and the editions page install does; the older clock and MIDI try-out images from the features page don't). The hotspot's mode (`auto`, `always`, `off`) and password are on the same page. A plain Arduino IDE re-upload does **not** clear the stored networks; **Tools → *Erase All Flash Before Sketch Upload*** does. -> 📡 **No Wi-Fi where you are? The panel is one.** *(Firmware after v3.10.4; the Performance edition v0.4.0 has it already.)* About fifteen seconds after it finds no known network, the panel raises its own hotspot: `patternflow-xxxx` (the name on its NETWORK screen - hold K2), password `patternflow`. Join it from a phone or a laptop and open `http://192.168.4.1/` - the whole console, including the Wi-Fi page, so you can add the network for wherever you are next and the panel joins it at once. The phone will say the network has no internet; that is true, and it stays connected. Mode (`auto`, `always`, `off`) and the password are on the console's Wi-Fi page. *Photos from the v2 guide — the flashing flow is identical on v3.* diff --git a/CHANGELOG.md b/CHANGELOG.md index d19b7130d..4ef9e5b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,210 @@ 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 +- **Pattern Lab no longer has the AI gallery or a Gemini key.** The Gallery panel, its batch generation and the key field are gone, and so is the `@google/genai` dependency: the site calls no AI itself. Copy prompt stays, with the v-field/rgb choice beside it in the Code tab now. Opening the Lab removes the gallery's saved patterns and the key from the browser, and a saved dock layout opens without the Gallery tab instead of falling back to the default. +- **Pattern Lab has text layers.** + Text adds a layer you type into, set in the site's pixel font, a generic system family, or a .ttf, .otf, .woff or .woff2 file from your own device. The file is read in the browser and never uploaded, and the browser keeps it only when you tick remember. Size, X and Y, alignment, one color or the layer's Color Ramp across or down, sharp or soft edges; a drag on Preview moves the text, by touch too. The text is drawn into a bitmap, so it blends, masks, renders in Graphic Export and travels in both exports as pixel art. +- **Pattern Lab's text layer copies out as code, and its firmware header is checked against the preview.** Copy as code in the Text panel hands the drawn letters to an AI chat as a pattern that needs no font, with a marked place to warp them and another to change their light. To hardware says a stack of text and pixel layers is a finished header with nothing to translate. A new test compiles that header with the host's C++ compiler and finds it lights the same pixels as the Lab preview and the flattened JS, apart from exact half steps at 50% and 25% opacity, where the browser's doubles and the board's floats round one level apart. +- **Pattern Lab's text layers scroll, twinkle or run their colour ramp, at the speed of a knob.** The Text panel's Effect picks one, and the knob it names sets the speed; turned all the way down, the letters stop. A scroll carries the whole line, so words longer than the panel pass through it. The flattened JS and the To hardware header move the same LEDs the preview does, frame for frame, which a test checks by compiling the header on the host. +- **Tapping a tab in Pattern Lab on a phone no longer closes another panel.** Touch screens showed a × on every tab, and a tab half out of the strip scrolled a × under the finger before the click landed. The tab overflow menu also opened under the panel below it. +- **A new code layer in Pattern Lab follows the knobs and lets the pattern beneath show.** The starter "+ Code" adds read no knob and drew every pixel opaque, so it hid the layer under it and turning a knob changed nothing. It now scrolls by knob 2 and spaces its bands by knob 3, and over another layer its ramp starts with the low end transparent. +- **Pattern Lab's Panels menu brings a hidden panel to the front instead of closing it.** On a phone every panel but Preview is a tab in one group, so picking Knobs while Code was in front closed Knobs. A panel behind another tab now comes forward and the menu closes. Picking a panel that is already in front still closes it. +- **Pattern Lab's To hardware waits for a translated layer before Next.** With two code layers, Next opened on "0 done" and called a header of stubs ready, and the board showed black. Next now opens once one layer is translated, and the next step names each layer that is still a stub. +- **Pattern Lab's To hardware keeps only the header from a pasted chat answer.** With one code layer, an answer pasted whole went into the .h with the model's words and the ``` fence, and Copy .h on an empty box copied nothing and said Copied. The .h now starts at `#pragma once` (with the comment lines above it) and stops at the closing fence, and Copy .h waits for a header. +- **Pattern Lab's To hardware translates the layer that shows.** With one code layer showing and a hidden or 0% code layer picked in Layers, the conversion prompt carried the hidden layer's code, so the board got a pattern the preview and the community upload did not show. To hardware and the upload now pick the layer the same way. +- **Pattern Lab's stacked C++ header compiles whatever the knobs and names.** A knob that only became a whole number once rounded (a 0..360 knob at Director bus value 175) came out as `63f`, and a backslash at the end of the pattern or knob name escaped its closing quote. g++ stopped on both. +- **Pattern Lab sends one code layer out at its preview brightness.** A single code layer under 100% opacity or on multiply showed dimmed or black in the preview, but Share published its bare code and To hardware ported it the same way, so the community and the board lit it at full brightness. It now goes out as a stack of one that keeps its opacity and blend. +- **A Pattern Lab code layer written as `const draw = …` still runs once stacked.** The flattened export pasted each layer's code under its own `var setup, update, draw;`, so a layer that declared them with `const`, `let` or `class` failed to load with "Identifier 'draw' has already been declared" and the published stack never ran. Each layer's code now runs in a scope of its own. +- **A stacked Pattern Lab code layer goes out with the pixels the preview shows.** The flattened export cut coordinates with `| 0` and let `Uint8ClampedArray` round colours, so a point at y −0.5 lit row 0, a red of 0.5 went out as 0 and `1 / 0` as 255. It now floors and rounds half up as the preview does. +- **Typing over a code layer's `@matrix` line no longer crops the pixel and text layers.** The Lab took the line up at every keystroke, so typing 96 over the 128 of `// @matrix 128x64` passed through 12x64 and 9x64 and left a full pixel layer with 576 of its 8,192 cells. The frame now changes when focus leaves the editor, a line out of range or gone leaves it as it is, and Undo in the Pixel panel brings back what a smaller frame cut off. +- **Pattern Lab's To hardware keeps the pasted header.** Esc, a press outside the window or a reload emptied the box, and the way back was to ask the model again. The paste is now kept for the tab against the prompt it answers, so it comes back on the next open, and an edit to the code, the ramp or the matrix drops it. +- **Pattern Lab's dialogs stay open when a drag that began inside them ends outside.** Selecting text in To hardware, Code guide or Import image and letting go on the backdrop closed the window; only a press that starts on the backdrop closes it now. +- **Pattern Lab's Text panel turns a text effect's speed knob in place.** A slider beside Speed knob moves the same knob the Knobs panel does, so on a phone an effect's speed no longer takes a trip to the Knobs tab and back. +- **Pattern Lab's paste box asks in English, and the Code and Pixel panels name a selected text layer.** When the browser refused the clipboard, Paste opened a box that asked in Korean on an English page. With a text layer selected, the Code panel called it pixel art and the Pixel panel called it a code pattern; each now names the layer's kind and the panel that edits it. +- **Pattern Lab's knob sliders turn one encoder detent per arrow key.** An arrow key moved a knob by the slider's 0.001 step, so Freq's 10..120 took 110,000 presses. It now moves one detent, 48 presses end to end as on the device, in the Knobs panel and beside a text effect's speed picker. +- **Pattern Lab's Layers and Knobs controls are fingertip-sized on touch screens.** On a 390px phone Del was 37×28px, the layer eye 20×17px and a knob's Push 40×22px. Where there is no hover, the layer bar buttons, the eye, the mask, opacity and blend rows and Push are now 44px tall, as the Text panel's fields already were. +- **Pattern Lab's Pixel panel opens a 128×64 picture at 2× on a 390px phone.** The status bar's alt-click, right-drag and middle-drag hint wrapped to a second line on touch screens, which have none of those gestures, and left the picture a 151px viewport that fitted at 1×. Touch screens no longer show it. +- **Pattern Lab's Pixel zoom buttons keep the middle of the view still.** − and + zoomed from the viewport's top-left corner, so one + at 10× moved the middle of the 649px desktop panel from column 64 to 58. They now hold the cell in the middle the way the wheel holds the one under the cursor. +- **Pattern Lab's Share window closes on Esc.** It closed only on a press outside, and its title field took the focus before anything noted the Share button, so the focus had nowhere to go back to. One Esc now closes it and puts the focus back on Share, and inside To hardware it still closes Share alone and keeps the header. +- **Add firmware header names the Lab's To hardware button.** Its note, and the one in Port this pattern, sent people to Copy C++ prompt, a button Pattern Lab no longer has. +- **A community card reads a fine knob the way the Lab does.** A knob declared `0.0001..0.0004` started below its range at 0 and reached the pattern as -0.1 where the Lab gives 0.5. One declared `0.001..0.002` started at its top, and the card's wheel rounded both to three decimals. Every place that reads a knob as 0..1, and the card's starting middle, now go through `lib/pattern/controls.ts`. +- **A downloaded community header carries one licence line.** A Pattern Lab header published under CC BY 4.0 downloaded with the scaffold's own `CC-BY-SA-4.0` SPDX line on line 1 above the author's `CC-BY-4.0`; the download now drops SPDX lines from the comments above the code before it adds the licence block. +- **Pattern Lab's stylesheets drop 13 classes no component used.** The 220 lines of rules that styled them are gone, and the Lab CSS test now fails when a Lab module defines a class nothing reads. +- **The Pattern and Build tabs' small notes keep their own size.** The global `.panel-body p` rule outranked each note's single module class, so the advanced note and both To hardware notes ran at 20px instead of 14px, and the Build tab's other-paths line at 20px instead of 15px, each with a 28px bottom margin. At 1440 the To hardware block is 363px instead of 572px. +- **Pattern Lab's modules export only what another file reads.** knip found 64 exports in `lib/lab` and `app/pattern-lab` with no importer; two names with no reader at all are gone and the other 62 are file-local. +- **Pattern Lab's twice-written helpers live in one place each.** The FNV-1a hash, the file download, `commentSafe`, `roundRangeValue` and `sortedLane` had two or three bodies. The `.h`, `.pfs` and `.mid` downloads now take the Graphic Export path, and a test pins the keys a To hardware paste and a shader are filed under. +- **The Layers panel has its own stylesheet.** The 24 rules only `LayersPanel.tsx` read moved from the dock's `LabPanels.module.css` to `panels/LayersPanel.module.css`, and computed styles in 29 Lab states at 390px and 1440px match `dev`. +- **Pattern Lab's header rules live with the dock.** The 22 rules only `PatternLabClient.tsx` read moved from the shared `PatternLab.module.css` to `LabPanels.module.css`, To hardware's dark button went to `HardwareModal.module.css`, and a test fails when a class in the shared file has one reader. Computed styles in 28 Lab states at 390px and 1440px match `dev`. +- **Pattern Lab's preview shows add layers the way the device does.** The preview clamped each layer to 255 before blending the next, so two (200, 0, 0) add layers under a (128, 128, 128) multiply showed R 128 in the Lab and 201 in the exported pattern and on the panel. +- **The Live Editor says why pasted code does not run.** The error, and the line it failed on when the stack gives one, shows under the editor. It used to reach the browser console only, once a frame for a `draw()` that throws. +- **Community pages open when the browser blocks site data.** Reading the deck from `localStorage` threw a SecurityError that took every community page down to "This page couldn't load", and the atlas did the same reading its language. Both reads are guarded now, and a deck or language the browser refuses to store holds for the rest of the visit. +- **The home page waits for the Pattern tab before fetching the code editor and the flasher.** The Pattern panel is mounted on `/`, `/build` and `/inside` too, folded away, so every visit asked jsdelivr for Monaco's loader and unpkg for esp-web-tools. Both now load the first time the tab opens, and stay. +- **The guide's tests give the same answer on a busy machine and a fast one.** The hub hand test read Make's redraw off the wall clock and, on a fast machine, passed without the hand turning a knob; it now runs on its own clock. That turned up a simulator bug: nine clicks of K1 out and nine back left Origin's hue at 0.9999999999999993, read as "hue 1.00" with a full bar, and `wrap` now calls that the start. The NETWORK timeout test covers its 15 seconds in 150 frames instead of 900. +- **Pattern Lab's "Copied" labels clear their timer when the panel closes.** The seven labels that show after a copy, paste or clear no longer leave a `setTimeout` behind, which could fail the Lab tests after every test had passed, and a second click keeps its label for the full 1.2 s. The image import modal's wheel listener now attaches in the same commit that enables Apply. +- **posthog-js loads after the page does.** The analytics library was the largest script on every page (318 KB, 103 KB gzipped); it is now fetched with `import()` after the load event, and events captured before then are sent with their original timestamps. +- **Closing Send or Publish from its backdrop keeps To hardware open.** In Pattern Lab's To hardware dialog, a press outside the nested Send or Publish modal closed To hardware as well, and the C++ header pasted into it was lost. +- **Korean pages fetch 318 KB of Pretendard Hangul instead of 1.2 MB.** The Hangul part of each weight is now the 2,350 syllables of KS X 1001 and the jamo; the other 8,822 syllables are a second file that a page fetches only when its text has one of them. +- **Editing a deck no longer runs out after four changes.** Sharing a deck and editing it counted against the same five requests a minute, so the fifth edit within a minute of sharing (each visibility change is one) came back 429. Edits now have their own twenty a minute, the same as a pattern's or a post's. +- **The hero video has a stop button.** The YouTube embed on the home page played for 89 seconds by itself and took no clicks, so nothing could stop it (WCAG 2.2.2). The button in its corner takes the video away and leaves the photo under it. Pressed again, or after the video ends, it plays from the start. +- **Pattern Lab's dialogs close on Esc, and its header menus close on a tap outside.** To hardware, Import image and Code guide take the keyboard focus when they open and give it back to their button when they close; an Esc over Send or Publish closes only that window, so the pasted header stays. Recent and Panels close on a press outside them, on Esc and on Tab past the last item, and the Panels list tells a screen reader which panels are open. +- **The breadboard build guide fits the window from 1280px up, and loads its photos as you reach them.** At 1440px the page scrolled 146px sideways and the photos on the left were cut off. The step photos now sit in the space beside the text, up to 360px wide. On a phone the eight photos (1.9 MB) no longer download when the page opens, and the page ships 59 KB less JavaScript. +- **The deck panel and the Send window stop waiting when the build server goes quiet.** With the build worker down, both asked the server every second until the window was closed, with a spinner beside "usually a second or two". They now give up after the wait the deck bar already used (6 s, plus 1.5 s a pattern, plus 5 s), say the server has not answered, and leave a button to start the build again. The bar, the panel and the window share that rule in one hook, `lib/community/useModuleBuild.ts`. +- **The Live Editor fits a phone screen.** On a 390px phone the preset bar squeezed the pattern name to nothing and showed only "01"; the name now takes its own row above the buttons. The editor shrinks to the height left under the pinned 3D preview (330px on a 390×844 phone, 480px on desktop as before), so some of the page always stays in view to scroll by. +- **`npm run build:enqueue` queues a module build.** Without `--pfm` it wrote a whole-image row, which the worker built as modules anyway, so `build:status` reported an image it never made. +- **Pattern Lab's Pixel zoom follows the wheel's travel and holds the cell under the cursor.** Each wheel event was one zoom step, so a trackpad swipe of thirty small events ran from 6× to 32×, and the picture grew from the viewport's top-left corner. Steps now add up from the distance turned, 100 px a step, and the viewport scrolls the cursor's cell back under it. The image-import preview scales 8% per 100 px around the cursor the same way. +- **The Live Editor's creation prompt gives the step one detent really moves each knob.** It said 0.05 a detent (0.10 for Knob 2). With 24 detents a turn and two turns to a range it is 0.021, 0.206, 0.102 and 0.021, worked out from the same ranges the preview's knobs turn through. The note under the Pattern Lab card no longer says the build server compiles firmware in the browser, and links PlatformIO instead of the Arduino IDE. +- **Community dialogs close with a thumb, and the atlas works from the keyboard.** The × in the header of the community's 13 dialogs was a 10.7×23.2px target and is now 44×44px, with the header the same height. A point on the atlas shows a ring when Tab reaches it, and Space no longer scrolls the page; pin tiles can be reached with Tab and opened with Enter; the live ring stops pulsing under reduced motion. +- **Pattern Lab's Pixel panel opens with the whole picture in view.** It always opened at 6×, so a 390 px phone showed 56 of 128 columns and 19 of 64 rows. It now opens once at the zoom the Fit button would pick, when 6× does not fit. +- **The community rate limiter keeps an hour of builds through its cleanup.** Once it held more than 10,000 keys, the cleanup judged every key by the one-minute window of the call that set it off, so somebody who had used their 30 builds for the hour got a 31st. The limiter and the origin check every community route runs first now have tests. +- **Pattern Lab writes an unnamed knob and a fine knob range into `@knobs` so they read back.** An empty name went out as `-=0..1` and came back as a knob called "-", and 0.0001..0.0004 was rounded to `0..0` and dropped. The `@knobs` and `@matrix` lines and the knob wrap rule now have tests. +- **The home tabs' link rows draw a focus ring, and each tab's title is its page's h1.** A focused row under Start here or in the case card took only a cream fill, 1.23:1 against white, and now draws the LED ring the case tabs use; the join band on Inside does the same. With a tab open the hero's h1 is inert, so /build had no h1 at all. +- **A desk run of `check:drift` reports a skipped `links-absolute` as skipped.** In a clone without the release tags the check could not read 24 links and said so, but exited 0, so the runner's summary listed it as passed. It now exits with the drift checks' skip code; under `PF_DRIFT_STRICT=1` it still fails. +- **Links drawn as buttons and cards keep their look on hover.** The global `a:hover` underline outranked each CSS module's `text-decoration: none`, so 453 links on 16 pages grew an underline when pointed at, and the guide's "Stuck here? Tell us" drew a second line under its own. +- **Two unimported files and six uncalled exports are gone.** `Deck.tsx`, `waveTest.ts`, `renderPatternStill`, `cloneShow`, `deleteSession`, `useActiveCodeLayer`, `timelinePatternNames` and `licensedHeaderText` had no callers in `web/` or `tools/virtual-panel/`, and `web/ARCHITECTURE.md` no longer lists `Deck.tsx` on the landing page. +- **The 3D device and the Inside globe hold still for reduced motion.** With the system's reduced-motion setting on, the preview device stands facing the reader instead of swaying 26° each way every 42 seconds, and the globe no longer turns by itself or runs dots along its links. The LED pattern on the panel still plays. +- **`web/ARCHITECTURE.md` says what Vitest runs and when a licence header is added.** `check:panels` runs every `src/**/*.test.ts(x)`, not only the Pattern Lab panels, and the community bakes the licence header into the stored code on publish and edit. `web/README.md` lists `check:voice`. +- **The atlas says why it did not save a pin.** Every pin write ignored the server's answer, so after a 401 a dragged tile stayed at its new spot as if saved, and a dropped connection ended in an unhandled rejection. The panel now shows the server's reason, and a refused drop goes back to where the pin is saved. +- **The guide's panel simulator fails a test when a firmware number it copies changes.** `firmwareConstants.test.ts` reads the 21 timings and limits from `firmware/patternflow/` (brightness idle, SELECT settle, the bus grace, the lane release and others) and the brightness percent rounding, and checks every `FIRMWARE` key against them. +- **A pattern page stops counting the lines Pattern Lab writes on its own as the author's decisions.** The Lab stamps `@matrix 128x64` on every publish and the stock black-to-white `@ramp` on every value-field layer, so a Gemini variant published untouched showed "Framed" and "Colour shaped". Each line is now read with its parser and set against the Lab's defaults, and "Framed" names the frame. +- **The atlas fits a phone.** At 390px the page scrolled 99px sideways, "Add my pattern" opened its picker 201px past the left edge where no tile could be tapped, and the placing banner ran off both sides of the map. The toolbar buttons now wrap as one block, the picker grid no longer widens with long titles (it scrolled sideways at 1440px too), and the banner sits under the filter chips instead of on them. +- **The site's typecheck catches unused locals and parameters, switch fallthrough and a missing `override`.** It found twelve; one was a ramp table the pattern runtime rebuilt on every `setRamp` and never read. The eight `eslint-disable-next-line` comments without a reason now give one. +- **Community fields a screen reader could not name now have names.** The device address under a deck or Send build was read out as an edit box with no name, and so were the atlas's made-from select and pin knob sliders; the atlas search and the report's detail box read out their placeholder instead. The atlas names them in Korean too. +- **The home page's tabs open in a browser without WebGL.** Where WebGL 2 was turned off, `/`, Build, Pattern and Inside threw from the 3D view's renderer and showed the site's error screen. The view now asks for a context before it makes a canvas, and a scene that throws anyway is taken off, with one line standing in its place. +- **The home page poster comes at the width it is drawn.** It was the 4032-wide camera original, 2.1 MB, on every tab of the home view. On a phone at 9 Mbps the first screen's largest paint took 9.6 s, and it now takes 0.5 s. +- **The Build and Pattern tabs, and the home page on a phone, no longer download the build globe's photos.** The 3D view opened on the globe for a moment on /build and /pattern, and a phone's hidden hero globe warmed every pin's photos as well: 65 images, 8.3 MB on a 3× phone. The view now starts on the page's own tab, and the globe warms its photos only where it is on screen. +- **Community write routes answer a `null` body with a 400.** `null` parses as JSON, so it got past every route's `catch` and threw on the first field read, and 22 handlers answered an empty 500 with no CORS headers. They now read the body through one helper that answers `{"error":"Expected a JSON object."}`. +- **Pattern Lab and the Live Editor say when pasted code was cut off.** Code that stopped inside a call reported "Unexpected token 'return'", and code missing its last `}` reported "Unexpected token ')'", tokens the author never typed. The message now counts the brackets that never close and quotes the last line. +- **Pattern Lab's Recent and Panels menus open inside a phone screen.** Below 800px each menu hung from its button's right edge and opened off the left of the screen. It now takes its left edge from the header's button row, at most 360px wide, with 44px rows. The × in the Lab's dialogs grows from 30px to 44px on the same screens. +- **Send over Wi-Fi reaches the board when the address field is empty or holds a pasted `http://` address.** An emptied field made `http:///patterns`, which a browser reads as a host called `patterns`, and `http://192.168.0.44/` made a host called `http`. Every handoff and install link now cleans the address the same way, in `lib/community/deviceHost.ts`. +- **Copy buttons in Pattern Lab and the Live Editor say "Copy failed" when the browser refuses the clipboard.** The Lab's Copy prompt and the To hardware copy buttons kept their label, and the Live Editor said the creation prompt was copied before the write had finished. +- **Sharing from Pattern Lab measures the code the way the community does.** "Open in Pattern Lab" hands back the stored code with its licence header and footer on, and the publish modal counted them, so an unchanged fork or a reopened post just under the 100 KB cap was refused before it was sent, though the server takes it. +- **The community API no longer lets an edit move a pattern to MIT or CC0.** Both are retired and stay on the patterns already published under them, but a direct PATCH still moved a CC BY-SA original to CC0 and rewrote the SPDX line in its source. +- **Five community windows close on Esc and hand the focus back.** Sign-in, report, Edit details, Add firmware header and Publish a performance take the focus when they open and return it to the button that opened them; before, Esc left them open and four kept the focus on the page behind. +- **The community atlas stands its filter chips above the map on a phone.** At 390px the two rows of chips hid 21 of the map's 83 labels, the map box left 152px blank under the drawing, and touch screens showed the "wheel to zoom · drag to pan" hint, which names gestures they do not have. +- **Six pages stop prefetching the home page's 3D code.** The Patternflow link on /terms, /contact, /features, /roadmap, the 404 and /community prefetched / as soon as it was in view, and the home page's three.js and post-processing chunks came with it: /terms fetched 767,286 bytes of script at 390px, and 148,456 without them. The home page now loads when the link is clicked. +- **Pages no longer preload fonts their text doesn't use.** The root layout drops DM Sans, which set no text, and loads Newsreader and Silkscreen without a preload, so only the journal, /terms and the guide fetch them; `/` and Pattern Lab take 166 KB of fonts less. +- **A wrapping knob on a range other than 0..1 hands the pattern every detent.** Knobs 1 and 4 wrapped at ±0.5 whatever their range, so in the Lab, on community cards and on the guide's desk a pattern declaring `@knobs Hue=0..360` read a full turn as 20.8 clicks instead of 24; the wrap now sits at half the knob's own range. +- **A mask over a 0% layer in Pattern Lab gates nothing in the exports either.** The exported JS and the C++ header dropped the 0% layer before grouping and handed its mask to the layer below, so a layer the Lab showed whole came out half black; the preview did the same for a code layer that had sat at 0% since it was added. +- **The build map has a fourth kind of pin, a showing.** A unit set out somewhere for people to play is a triangle under In use, with a line back to the unit it is and no part in the web of builds. *Anbu*, a DJ night in Hongdae, the Hongik course exhibition and the Seoul Design Week party go in as showings, beside new pins for two gifts, SimonePDA's laser-cut case, Besoiobiy's printed case, the MQTT test between Ajman and Seoul, and jonwtr's norns screen mirror in Ghent. A pin's link into the site, like Build this case, changes the tab in place instead of opening a new one. +- **The Build guide keeps up with a finger on a phone.** A scroll waits for the 3D stage's script only when the finger lands on a knob. The stage's models start downloading with three.js and weigh 266 KB less, and a stage that has stopped moving no longer redraws its shadow map. +- **Pattern Lab's pixel undo survives a matrix resize.** Undo after shrinking the matrix threw a RangeError, and after growing it wrote the old rows into the new buffer. Each undo frame now keeps its size and goes back through the same centred crop or pad the resize gave the layer. +- **A link to any page previews with that page's own title and picture.** The guide pages and the home page's tabs went out with no image, and pages without Open Graph tags of their own, such as `/features` and `/editions`, previewed as the home page. `twitter:title` was the home page's everywhere. `/build/breadboard` has its own title now, and it and `/update` are in the sitemap. +- **Pattern Lab asks before Del removes a layer with work in it.** Deleting a layer has no undo and autosave wrote the smaller stack over the saved project within 3 s, so one misclick lost a pixel drawing. A blank pixel layer or a code layer still holding the starter pattern goes without asking. +- **Pattern Lab stacks its panels on a phone.** At 800px wide or less the default dock puts Preview across the top and every other panel as a full-width tab under it, where three columns left each panel about 110px. The narrow layout is saved under its own key, so a desktop arrangement survives a phone visit. +- **Pattern Lab's wheel zoom no longer scrolls the panel under it.** React attaches wheel listeners as passive, so the Pixel viewport and the image-import preview called preventDefault for nothing: each notch zoomed and scrolled at once. Both now listen natively with `passive: false`, and a sideways wheel scrolls instead of zooming out. +- **The home page video gives way to reduced motion, and the section tabs say which one is open.** With reduced motion on, the hero shows its poster and never loads the YouTube embed, which autoplays with no control to stop it. The desktop tab column is now a `Sections` nav whose open tab is `aria-pressed`, as on the phone, with its ✕ hidden from screen readers, and the panel beside it is the page's `
`. +- **The links at the end of each guide say where they go.** All guides moves to the hub over the same 3D stage instead of reloading the page. A link to another page of the site carries → and opens in the same tab without a reload, and a link away from it carries ↗ and opens in a new tab. +- **`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. - **The preview's case is white, not beige, and the rest of it looks more like the photos.** The printed parts are a neutral white PLA (`#f4f4f2`, matte), where the guide's warm white had come along; the case front measures b\* +0.4 and L\* 89.8 where it was +1.7 and 82.5. The frame goes through the shoulder of the Neutral tone curve, which leaves what is under its knee as lit and rolls off what is brighter with its channels together, so a bright light no longer clips red and green first and leaves the white yellow. The light is made on the page from light panels, as on the guide's stage, instead of an environment map fetched from a CDN at run time. The LED panel is a grid of round LEDs on a black mask where it was solid blocks on grey (each LED becomes its whole cell once it is under about three pixels across, as on a phone), its sides and back are the panel's black frame, and what glowed before (the whites, and the bright yellows and cyans of a pattern from the editor) glows as before. The official knob is the printed knob's own profile drawn round, where it was a 32-sided prism. SimonePDA's clear acrylic is clear: the canvas is see-through, and every partly covered pixel used to reach the page far too bright, which drew the sheet frosted white; its three feet, which no photo shows on it, now come out only with the parts and on Assemble. Every case is drawn at one scale, so the LED panel is the same size in all three (the remixes drew it 3.5 to 4% smaller). The three models were rebuilt with `tools/case-models/build.sh`; the official one grows by 0.8 KB. +- **The guide's first flash starts in download mode, and its Wi-Fi assumes the step won't come.** The Play guide's 01 Flash (`/guide/play`, and `/ko`), where the Build guide sends a new DevKit, now has hold BOOT, tap RST, let go as its fourth card, before Flash Patternflow, every first flash, instead of a fallback for a missing port. The Wi-Fi card says plainly that after an install from download mode the dialog usually ends without asking, and gives the moves in order: close it, press RST once, Flash Patternflow again, the port again, Connect to Wi-Fi; its flasher screens lead with that path ("Usually: after RST"), the install-right-away case second, and it carries its own Flash Patternflow button. A new last card, after Power, is the panel's own network for when that didn't take: hold K2 for HOTSPOT and `patternflow-xxxx`, join it with the password `patternflow`, open `http://192.168.4.1/` and pick your network on the Wi-Fi page, with what to do when it isn't in the phone's list yet (it comes up about 15 seconds after power-on). The stage follows the cards, and the device simulator draws the NETWORK screen's hotspot (`setHotspot`). The Build guide's flash card, the Pattern tab's notes under Flash Patternflow and the breadboard page say the same order. Anchors `#flash-1` to `#flash-3` are where they were; `#flash-4` is now download mode. +- **`/flash` goes to the guide's Flash chapter.** It had no page, though the device's Update page ("flash it over USB at patternflow.work/flash") and every release's FLASHING.md send people there; it now redirects to `/guide/play#flash`. `/flash/manifest.json` and `/flash/bin/*` are untouched. ### Docs +- **The build service docs describe the worker that runs today.** `docs/SERVICES.md` and `web/.env.example` still said the worker compiles firmware with arduino-cli and offered `ARDUINO_CLI_PATH` and `BUILD_FQBN`, which nothing has read since whole-image builds ended on 12 August. `.env.example` now lists `PF_XTENSA_BIN`, `PF_TARGET_ABI` and `GITHUB_TOKEN`, and `src/lib/envExample.test.ts` fails when it, the code and the table in `web/ARCHITECTURE.md` disagree. +- **The docs index, the repository map and the contributor guide describe the tree as it is.** `docs/README.md` lists the cleanup RFC and its log, the rename list in `docs/REPOSITORY.md` names all 30 files the site's absolute links reach (it had ten and the BOM), `CONTRIBUTING.md` names the four build kinds and `originId`, and `docs/EDITIONS.md` says `build.sh all` builds five compositions. +- **The cleanup's progress log records steps 1 to 5 and the gate.** `docs/rfc-cleanup-progress.md` has an entry for each step and checks the six items of §2.12 at `9dff573`, and all six hold. `docs/rfc-cleanup.md` is marked done. +- **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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7d695cde..8e675ccba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,24 +1,27 @@ # 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 | You have… | It goes in | A change touches | What CI runs on it | | :--- | :--- | :--- | :--- | | A fix to a guide, a README or a spec | the markdown file itself: the root guides, `docs/`, any folder's README | one file | every relative link must resolve (`docs-links`) | +| A fix to the **interactive guide** at [patternflow.work/guide](https://patternflow.work/guide) (Build, Play, Make, Audio) | its words are TypeScript, English and Korean side by side: `web/src/components/guide/copy.ts` for Play, `web/src/components/guide/copy/` for Build, Make, Audio and the hub | the copy, both languages | `web-ci` | | A fix to the **breadboard** build guide | `web/src/app/build/breadboard/page.tsx` — that guide is React, not markdown | JSX text | `web-ci` (`next build`) | | Your finished build, for the [build map](https://patternflow.work/inside) | `web/src/components/sections/InsideGlobe/builds.ts` plus a photo folder `web/public/builds//` — see [Your build](#your-build). Not a git person? The [Share your build](../../issues/new?template=share_build.yml) form does the same | one `Build` entry and its photos | `web-ci` (the typecheck rejects a malformed entry) | | A firmware **feature** | `firmware/patternflow/features//`, plus one line in the bundle it joins (`firmware/bundles//features_local.h`) | the folder, the bundle line, an inventory row in `features/README.md`; a console page also adds a row to `firmware/toolchain/console_pages.py`; a library also goes in `platformio.ini` | `firmware-checks` (boundary, ABI freeze, presets, console pages…) and `firmware-build` (every edition, from scratch) | | 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/…`). @@ -75,7 +78,7 @@ A pin on the map is one entry in [`web/src/components/sections/InsideGlobe/build Inside includes builds, projects derived from Patternflow, and places where it is used. Add one entry to `web/src/components/sections/InsideGlobe/builds.ts`: a short `title`, `maker`, public `location`, `date`, `description`, and any external `links`. Photos are optional. Keep existing `id` and `slug` values stable so shared links keep working. -Choose one primary `category`: `builds` for assembled devices and enclosure changes, `projects` for ports and new tools, or `in-use` for installations, performances and other uses. This is separate from `kind`, which preserves the existing build/collaboration relationship and marker style. A maker can have several entries: a new tool and a later exhibition are different records. Link to the maker's own documentation for ongoing updates; a short introduction here is enough. Do not add unannounced work or assume an event happened from a sale or a planned appearance. +Choose one primary `category`: `builds` for assembled devices and enclosure changes, `projects` for ports and new tools, or `in-use` for installations, performances and other uses. This is separate from `kind`, which says what the pin is and picks its marker on the globe: `build`, `collaboration`, `sold` or `showing`, each explained in the comment above `BuildKind`. A collaboration, a sale or a showing names the pin it grew out of in `originId`, and the globe draws a line back to it. Left out, the line goes to the first build in the file. A maker can have several entries: a new tool and a later exhibition are different records. Link to the maker's own documentation for ongoing updates; a short introduction here is enough. Do not add unannounced work or assume an event happened from a sale or a planned appearance. Classify the subject of the entry, not its surroundings. A completed device photographed in a studio or DJ booth is still a `builds` entry. Use `in-use` when the entry documents actual use in an exhibition, performance or installation; a sale or delivery alone is not enough. Leave a category empty until a qualifying case is available. diff --git a/FEATURE_GUIDE.md b/FEATURE_GUIDE.md index 96db5e767..8b16150c4 100644 --- a/FEATURE_GUIDE.md +++ b/FEATURE_GUIDE.md @@ -160,7 +160,9 @@ python firmware/toolchain/console_pages.py check # if you added a console page Compiling is not testing — most historical regressions were invisible on the build in front of whoever caused them, which is why `build.sh all` exists. CI runs the same set ([`firmware-checks.yml`](.github/workflows/firmware-checks.yml), -[`console-sync.yml`](.github/workflows/console-sync.yml)). +[`console-sync.yml`](.github/workflows/console-sync.yml), and +[`firmware-build.yml`](.github/workflows/firmware-build.yml) for every +composition, one job each). If the user asks for a PR against this repository: it should touch only `features//` plus a bundle, pass everything above, and follow diff --git a/PATTERN_GUIDE.md b/PATTERN_GUIDE.md index ca4cf7173..3ba06a3da 100644 --- a/PATTERN_GUIDE.md +++ b/PATTERN_GUIDE.md @@ -24,22 +24,30 @@ in a window, and a practice AI to take the two prompts. ## 0. Before you start -**A USB cable is for exactly one moment.** The very first browser flash at -[patternflow.work/pattern](https://patternflow.work/pattern) — you set up -Wi-Fi as part of it, and from then on **everything is wireless.** Whether -you're in the community or in Pattern Lab, every path a pattern takes to the -device goes over Wi-Fi. +**A USB cable is for exactly one moment: the very first flash**, with the +ESP32-S3 out of the board, from **Flash Patternflow** at +[patternflow.work/pattern](https://patternflow.work/pattern). Put the module +in download mode first — hold **BOOT**, tap **RST**, let go of BOOT — then +press the button. The Wi-Fi step usually does not appear after that first +install, and that is expected: press **RST**, run **Flash Patternflow** again +and choose **Connect to Wi-Fi**, or seat the module, power the panel and join +the network it raises for itself, `patternflow-xxxx` (password +`patternflow`), to set Wi-Fi at `http://192.168.4.1/`. From then on +**everything is wireless.** Whether you're in the community or in Pattern +Lab, every path a pattern takes to the device goes over Wi-Fi. So this whole guide rests on one assumption: **the device is powered on and on the same Wi-Fi network as your computer.** If you haven't done the first flash and Wi-Fi setup yet, finish the -[firmware section of BUILD_GUIDE](BUILD_GUIDE.md#8-firmware) first. +[firmware section of BUILD_GUIDE](BUILD_GUIDE.md#8-firmware) first, or the +Play guide's [Flash chapter](https://patternflow.work/guide/play#flash), which +walks it on a board you can turn. | You need | Why | | --- | --- | | An assembled, flashed Patternflow | It's what this guide is about | | The device connected to Wi-Fi | The premise of every transfer here | -| Firmware **v3.2.0 or newer** | Pattern modules (wireless install) need it | +| Firmware **v3.10.5 or newer** (the flasher installs the current release) | Pattern modules (wireless install) need v3.2.0, the Basics pack v3.5.1, and the larger patterns, as they are built now, v3.10.5 | | A community account (optional) | Browsing, knobs and deck-building work without one. Sending to your device (building) and sharing / liking / forking need a sign-in | The device's page lives at **`patternflow.local`**. Android can't resolve @@ -55,7 +63,7 @@ travels to the device as a module — a `.pfm` file of a few KB — and arrives over Wi-Fi in seconds.** The firmware is not rebuilt and nothing is reflashed. The buttons wear slightly different names on different screens — -`Send to my Patternflow`, `SEND TO MY BOARD`, `APPLY TO MY PATTERNFLOW` — but +`Send to my Patternflow`, `SEND TO MY BOARD`, `Apply to my Patternflow` — but they are all the same thing: compile this pattern into a module, hand it to the board. @@ -208,8 +216,7 @@ Now for the making side. Click **Pattern Lab ↗** at the community's top right. Pattern Lab has a lot of panels, but **this guide uses exactly two things: pattern generation and the color ramp.** They're enough to take a pattern from -nothing to your device. Layers, pixel art, the gallery — all of that is for a -later guide. +nothing to your device. Layers and pixel art are for a later guide. ### 4-1. Pick a resolution first @@ -313,7 +320,7 @@ to **try it on the board first.** ![On to hardware](docs/images/pattern-guide/16-on-to-hardware.png) -1. Press **`APPLY TO MY PATTERNFLOW`** — it builds the module and takes +1. Press **`Apply to my Patternflow`** — it builds the module and takes seconds. 2. In the dialog, press **`Send over Wi-Fi`** — with the device on and connected, it uploads immediately. @@ -363,11 +370,12 @@ onto their device. The loop has come all the way around. | Symptom | Check | | --- | --- | +| The panel isn't on Wi-Fi, or the flasher never asked for a network | Expected after a first flash. Press **RST**, run **Flash Patternflow** again and choose **Connect to Wi-Fi** (2.4 GHz only; type the name exactly). Or power the panel, wait about 15 s, join its own network `patternflow-xxxx` (password `patternflow`; hold **K2** for the exact name; not in the phone's list yet? refresh it) and pick your network at `http://192.168.4.1/` — [BUILD_GUIDE §8](BUILD_GUIDE.md#8-firmware) | | `Send over Wi-Fi` does nothing, or fails | Device power · **same Wi-Fi network** as the computer · on Android, use the IP from the NETWORK screen (hold K2) instead of `patternflow.local` | | The device's Patterns page won't open | Same as above — try typing `patternflow.local` into the address bar directly | | A card has no `+` (add to deck) | Not a bug — the author shared it without an `.h` header (not hardware-verified) | | The `.h` conversion looks wrong on the device | Ask the AI to convert again (a plain retry often fixes it) · simplify the pattern code · try a different model | -| Modules uploaded but don't appear in the list | Firmware older than v3.2.0 — update it at [patternflow.work/update](https://patternflow.work/update) | +| Modules uploaded but don't appear in the list | Firmware older than v3.2.0 — update it at [patternflow.work/update](https://patternflow.work/update). A larger pattern that shows PATTERN FAILED on firmware older than v3.10.5 needs the same update | | A `.zip` pack was dropped but nothing installed | The pack has to contain `.pfm` modules. A zip of `.js` patterns is not a pack — build them first | | The deck's order came out wrong on the device | Re-download the pack: the running order ships inside it, and a pack built before the deck was rearranged carries the old one | | The device is out of storage | The Patterns page shows free space (KB free) at the top right — delete modules you don't use | @@ -375,5 +383,5 @@ onto their device. The loop has come all the way around. --- -*The rest of Pattern Lab — layer compositing, pixel art, the gallery — gets -its own guide later.* +*The rest of Pattern Lab, layer compositing and pixel art, gets its own guide +later.* diff --git a/README.md b/README.md index 2208549a1..5df970569 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ +
+ # Patternflow -[![Open Source Hardware](https://img.shields.io/badge/Open_Source-Hardware-blue?style=flat-square&logo=opensourceinitiative)](https://github.com/engmung/Patternflow) +**An open-source LED synthesizer.**
+Play light patterns with your fingertips. + +**[Try one in your browser](https://patternflow.work/pattern)**  ·  **[Build one](https://patternflow.work/guide/build)**  ·  **[Get one assembled](https://www.crowdsupply.com/engmung/patternflow)** + [![License: MIT](https://img.shields.io/badge/Code-MIT-green?style=flat-square)](./LICENSE-MIT) [![License: CC BY-SA 4.0](https://img.shields.io/badge/Hardware-CC_BY--SA_4.0-orange?style=flat-square)](./LICENSE-CC-BY-SA) [![Release](https://img.shields.io/github/v/release/engmung/Patternflow?style=flat-square&color=purple&label=Release)](../../releases) -[![Crowd Supply](https://img.shields.io/badge/Crowd_Supply-Campaign_Live-d4502b?style=flat-square)](https://www.crowdsupply.com/engmung/patternflow) +[![Crowd Supply](https://img.shields.io/badge/Crowd_Supply-funded,_ends_Oct_15-d4502b?style=flat-square)](https://www.crowdsupply.com/engmung/patternflow) [![Discord](https://img.shields.io/discord/1497757947827327067?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Vr9QtsxeTk) +
+

Patternflow: a hand turns a knob and the light reshapes Patternflow, the film: what it is, how it's built, and why @@ -15,23 +23,30 @@ ▶ the reel: 400k+ views on Instagram  ·  ▶ the film: the whole story, on YouTube

-> **Photosensitivity warning.** Patternflow displays rapidly changing light patterns that may trigger seizures in people with photosensitive epilepsy. If you experience any discomfort, stop use immediately. +> **Photosensitivity warning.** Patternflow displays rapidly changing light patterns that may trigger seizures in people with photosensitive epilepsy. Viewer discretion is advised. If you experience any discomfort, stop use immediately. + +**Every Patternflow plays every pattern we make.** Four knobs, and the pattern answers as you turn them: 8,192 points of light, every frame computed live on the device, nothing played back. A pattern is a small file, so one made anywhere plays on all of them. And every part of the instrument is open, from the circuit board to the firmware. -**Patternflow is an open-source LED synthesizer.** You play it with four knobs. 8,192 pixels across a 128 × 64 matrix respond the instant you turn one; nothing is pre-rendered, every frame is computed live on the device. A pattern is just a small file, so every Patternflow plays every pattern anyone makes. +Started by media artist SeungHun Lee ([@engmung](https://github.com/engmung)) in Seoul, and made by everyone who builds one, makes a pattern or sends a fix. -**Start with [the guide](https://patternflow.work/guide).** It builds one with you, shows you how to play it, walks you through making patterns, and connects sound and MIDI, each step on a 3D Patternflow you can turn in the browser: **[Build](https://patternflow.work/guide/build)** · **[Play](https://patternflow.work/guide/play)** · **[Make](https://patternflow.work/guide/make)** · **[Audio](https://patternflow.work/guide/audio)**. +**Start where you are.** [The guide](https://patternflow.work/guide) shows every step on a 3D Patternflow you can turn, in English and Korean, with a **Stuck here?** link on each: + +- **[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](docs/AUDIO_GUIDE.md). ## Where it began Almost every screen around us is a playback device. It takes in content and shows it to us, and we sit in front of it and watch. Patternflow is different: touch it and it answers in that instant, and nobody plays the same thing twice. -In January 2026 I stopped by the Nam June Paik Art Center, which happened to be running an event marking twenty years since Paik's death. That is how I ended up standing in front of *Participation TV* (1963): you speak into a microphone and the picture on the screen moves with your voice. It turned the viewer from someone watching into someone taking part. I spoke into it, and my voice became the picture. +In January 2026 I stopped by the Nam June Paik Art Center, which happened to be running an event marking twenty years since Paik's death. That is how I ended up standing in front of *Participation TV*: you speak into a microphone and the picture on the screen moves with your voice. It turned the viewer from someone watching into someone taking part. I spoke into it, and my voice became the picture. Two months later I connected an LED matrix and a potentiometer for the first time. -Where Paik brought the audience into the work, Patternflow hands over the making of it. +I put my first pattern, *Patternflow: Origin*, on an LED matrix with four knobs and posted it online. Some people asked how to buy it; far more asked how to build it. So everything went public: schematics, firmware, 3D models, the build guide, and the whole workflow for making new patterns. -I put that first pattern, *Patternflow: Origin*, on an LED matrix with four knobs and posted it online. Some people asked how to buy it; far more asked how to build it. So everything went public: schematics, firmware, 3D models, the build guide, and the whole workflow for making new patterns. +**A contemporary reinterpretation of Nam June Paik's *Participation TV* (1963). Paik let the audience change the image. Patternflow lets you make it, and give it away.** ## The community responded @@ -42,181 +57,239 @@ I put that first pattern, *Patternflow: Origin*, on an LED matrix with four knob Builds by Nath (UK), shooter (Poland), day (France), Enerjoy (Norway), SimonePDA (UAE), Nick (USA), and Jon (USA).

-Builders from around the world keep adding pins to the **[build map](https://patternflow.work/inside)**. Someone built theirs as a Raspberry Pi port with a 64 × 32 matrix, another layered paper and acrylic into a diffuser, others cut their own enclosures. Two of them live in DJ booths. +Two of the builds above live in DJ booths. More keep arriving on the **[build map](https://patternflow.work/inside)**: a wooden case in Mexico City, a horizontal desk case in Kyiv that is now one of the remixes in this repo, a plaster shell hanging on a wall in Germany with a Raspberry Pi inside. + +

+ Build map: a globe of Patternflows built around the world, with the story of every build +

+ +I started this alone. It hasn't worked that way for a while now. Web upload began as someone else's suggestion and proof of concept, and shipped as a main feature two days later. The pattern modules that install over Wi-Fi came from **[Simone Majocchi](https://github.com/SimonePDA)**, who designed the loader, built it and proved it on hardware before proposing it. They're core to the instrument now, and Simone went on to bring MQTT, a laser-cut case and the Performance edition. Martin Lopez has put more patterns on the Community wall than anyone, and a Patternflow went to California as thanks. -I started this alone. It hasn't worked that way for a while now. The web-upload feature wasn't mine: someone suggested it, built a proof of concept, and shared the code, and two days later it shipped as a main feature. The `.pfm` pattern modules came the same way. A contributor designed the loader, built it, and proved it on hardware before proposing it. They're core to the instrument now. +In Iran, Azmano took Patternflow's patterns, added MIDI and a microphone, and built an instrument of their own that runs in a browser, **[MOTIFLOW](https://azmano.art/portfolio-item/motiflow/)**, shown in a gallery and played at events since. Patternflow had become something people build their own instruments out of. -The clearest case started on the other side of the world. A media art collective took Patternflow's pattern modules, added MIDI control and a microphone, and built a browser-based instrument of their own: **[MOTIFLOW](https://azmano.art/portfolio-item/motiflow/)**, recently shown in a gallery. Patternflow had become something people build their own instruments out of. +Underneath is an instrument still being designed, in the open, by whoever shows up. It's already moving in directions I didn't choose, and none of it was assigned. The next one is open too: a bridge for TouchDesigner or Node-RED has a place waiting in [`integrations/`](integrations/README.md), and [Contributing](#contributing) says how to start. **Patternflow is a thing we're all making now.** ## Make it yours -### 1. The device +Try it in the Live Editor, make it in Pattern Lab, share it on Community, and it plays on every Patternflow in the world. Then build one to play it on. -The **[Full Build Guide](BUILD_GUIDE.md)** covers the official route: the custom PCB and a 3D-printed enclosure. The **[build guide in 3D](https://patternflow.work/guide/build)** is the same route, every step shown on a board you can turn. Don't want to order a board? The **[Breadboard Build Guide](https://patternflow.work/build/breadboard)** wires the same electronics with jumper wires instead, and that's a real Patternflow, not a temporary prototype. Every other combination is on the **[Assembly Map](docs/assembly/README.md)**. Parts run about US$100–200 ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)), it's all big through-hole joints, and every first-timer who has built one finished it. Most came back saying the soldering was the fun part. +### Patterns -**When yours lights up, tell us.** Post it in [Discord](https://discord.gg/Vr9QtsxeTk) or fill in the [Share your build](../../issues/new?template=share_build.yml) form and it goes on the build map, a globe of Patternflows where each pin carries its build's story. The map is for the ones people made themselves: every pin is someone who built one from these files, in their own material, wherever they are. The goal is simple: cover it with pins. +You don't need hardware to start. The **[Live Editor](https://patternflow.work/pattern)** is a Patternflow in your browser: the same knobs, the same detents, no account and no install. Patterns are JavaScript, and you don't have to write it yourself: click **Copy creation prompt**, paste it into your AI assistant with the look you want, paste back the code it returns, and play. The template handles the encoder mapping, brightness curve and pixel buffer; you describe the look.

- Build map: a globe of Patternflows built around the world, with the story of every build + Live Editor: a Patternflow in the browser, code beside the device

-If you'd rather have one arrive assembled, tested, and flashed, the **[Crowd Supply campaign](https://www.crowdsupply.com/engmung/patternflow)** is currently live. Back the campaign to have an assembled unit, DIY kit, or custom enclosure shipped worldwide. - -### 2. Patterns +**[Pattern Lab](https://patternflow.work/pattern-lab)** is the studio, and where a pattern reaches the hardware. Make variations through any AI chat, then shape the color ramps and retune the knob ranges. Send it to your Patternflow and it builds into a small `.pfm` module that installs over Wi-Fi in about ten seconds, with no cable, no reflash and no IDE. Graphic Export renders the same pattern as a print-size PNG for a business card or an MP4 loop for a post, in your browser, with nothing uploaded. -You don't need hardware to start. The **[Live Editor](https://patternflow.work/pattern)** is a full Patternflow simulator: turn the virtual knobs and it behaves like the real thing. Patterns start as JavaScript in the browser, and you don't have to write it yourself. Click **Copy creation prompt**, paste it into your AI assistant with a description of the look you want, paste back the code it returns, and play. You don't write GLSL or touch a rendering pipeline: the template handles the encoder mapping, brightness curve and pixel buffer, and you describe the visuals. +Then publish it to the **[Community](https://community.patternflow.work/community)**: more than a hundred patterns, from quiet waves to chaos-theory studies, all playable under the same four knobs. Collect them into a deck and send it to your board in one click. Browsing needs no account, and publishing asks only a username and password, no email. New studies go up on **[Instagram](https://www.instagram.com/patternflow.work)** almost daily; publish something that stands out and it can get featured there, or ask for a collab and I'll post yours with you. The [Make guide](https://patternflow.work/guide/make) walks the whole loop as a tutorial you do as you read, no hardware needed.

- Live Editor: a full Patternflow simulator in the browser, code beside the device + The community wall: dozens of patterns by different people, each one playing, with a deck being assembled along the bottom

-**[Pattern Lab](https://patternflow.work/pattern-lab)** is the full studio, and where a pattern reaches the hardware. Generate variations in batches (in the Lab with your own Gemini key, or through any AI chat), shape color ramps, retune knob ranges, then send it to your device: it builds into a small `.pfm` module and installs over Wi-Fi, about ten seconds start to finish. You never plug in a cable, reflash the board, or open an IDE. The Graphic Export panel takes the same pattern off the panel: a PNG at print size for a business card, or an MP4 loop for a post, rendered in your browser with nothing uploaded. +### The device -When it looks right, publish it to the **[Community](https://community.patternflow.work/community)**. More than a hundred patterns are up already and the range keeps widening, from quiet waves to chaos-theory studies, every one written as code and every one playable under the same four knobs. Collect patterns into a deck and send it to your board in one click. Browsing needs no account, and publishing asks a username and password, no email. The **[Pattern Guide](PATTERN_GUIDE.md)** walks the whole loop, and the **[Make guide](https://patternflow.work/guide/make)** is that loop as a tutorial you do as you read. +The **[Build Guide](BUILD_GUIDE.md)** is the official route: the v3.9 board in a 3D-printed case, about US$100–200 in parts ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)). Order first and build later: the parts take about two weeks to arrive, and the case prints in about ten hours on a 256 mm bed. The rest is about an hour of hands-on work, half of it soldering, and every joint is a big through-hole one. Every first-timer who has built one finished it, and most came back saying the soldering was the fun part. The **[build guide in 3D](https://patternflow.work/guide/build)** is the same route on a board you can turn. -

- The community wall: dozens of patterns by different authors, each one playing, with a deck being assembled along the bottom -

+**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. -Patterns don't stay on the wall, either. New pattern studies go up on **[Instagram](https://www.instagram.com/patternflow.work)** almost daily. Publish something that stands out and it can get featured there, or ask for a collab on Instagram and we'll post yours together. +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). -### 3. The ecosystem +**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. -Patterns are the surface. Underneath is an instrument still being designed, in the open, by whoever shows up. This is the whole system on one napkin: +Rather have one arrive assembled, tested and flashed? It's on **[Crowd Supply](https://www.crowdsupply.com/engmung/patternflow)**, shipped worldwide. The campaign is past its goal and runs until 15 October 2026. -

- Hand-drawn map of the Patternflow ecosystem: the device, the GitHub files and Crowd Supply routes to it; patternflow.work with the Live Editor, Pattern Lab and Community wall; Discord, Instagram and GitHub -

+## First light -It's already moving in directions I didn't choose. One contributor is building out MQTT further than I've had time to follow: units reaching each other across a network, one person's playing coming out of somebody else's device. Another is working on sound on the board itself. Another is taking the TouchDesigner link further than I did. None of it was assigned. +For a DevKit that has never run Patternflow, or one that needs rescuing. The same steps, with the flasher's own screens and a **Stuck here?** link on each, are the Play guide's **[Flash](https://patternflow.work/guide/play#flash)** and **[Patterns](https://patternflow.work/guide/play#patterns)** chapters; [BUILD_GUIDE §8](BUILD_GUIDE.md#8-firmware) is the same order in writing, with more on each step. Already running Patternflow, or got one assembled? Skip download mode, which erases installed patterns and saved Wi-Fi: power it from `J4` and start at step 5. Later firmware comes from the console's **Update** page over Wi-Fi, which keeps everything. -**[CONTRIBUTING.md](CONTRIBUTING.md)** covers how contributions flow. Questions and ideas go to the **[Discord](https://discord.gg/Vr9QtsxeTk)**; where to ask what is **[SUPPORT.md](SUPPORT.md)**. +> **Power goes in through `J4`, the screw terminal, and nowhere else:** a USB cable from a 5 V power bank, cut, red to +5V and black to GND. Never power it through the DevKit's USB-C ports: they are for flashing, with the DevKit out of the board. -## Quick facts +1. **Flash the DevKit on its own, out of the board,** from desktop Chrome or Edge. Use its **left** port, labelled `USB` (antenna up, ports toward you): the right one flashes too, but the Wi-Fi step never appears there. Use a USB-C cable that carries data; a charge-only cable is the usual reason nothing shows up. +2. **Download mode first:** hold **BOOT**, tap **RST** (EN on some DevKits), let go of BOOT. Then press **[Flash Patternflow](https://patternflow.work/guide/play#flash-5)**, pick `USB JTAG/serial debug unit` and install. It takes about two minutes and erases the flash, which a new DevKit doesn't mind. +3. **Then the Wi-Fi, which takes a second go.** The dialog usually ends without asking for it, and nothing failed: close it. Press **RST**, press **Flash Patternflow** again and pick the port, and this time it opens on Patternflow's own menu. Choose **Connect to Wi-Fi**, let the short scan finish (it lists nothing, by design), and type your network's exact name (capitals count, 2.4 GHz only) and its password. +4. **Unplug the DevKit from the computer and, with the power bank unplugged, seat it.** Its USB-C ports go toward the `USB` mark on the board, all 44 pins in. Then plug the `J4` cable into the power bank: within a second or two a red, squarish pattern comes on. That's **Origin**, and one pattern is right for now. +5. **Give it 15 seconds, then hold K2, the top-left knob,** for a second. `CONNECTED` and an address: you're on your Wi-Fi. `HOTSPOT` and a name like `patternflow-a1b2`: it found no network it knows, so it became one. Join it (password `patternflow`; the phone will say there's no internet, which is true, and it stays connected), open `http://192.168.4.1/` and add your network on the Wi-Fi page. +6. **Back on that Wi-Fi, install the Basics pack.** On the [decks page](https://community.patternflow.work/community/decks), press **Install to my board** on the Basics card: 33 patterns, no account. The first time, the board asks to format its storage: press **Format storage**, confirm, and the pack installs by itself. The board's console is at `patternflow.local`; on Android, which can't open that name, type the address K2 shows into the card's **Device address** first. -| | | -| :--- | :--- | -| **Display** | 128 × 64 HUB75 RGB LED matrix, P2.5 (320 × 160 mm) | -| **Brain** | ESP32-S3-WROOM-1 **N16R8** (16 MB flash, 8 MB PSRAM) · standalone, no sending card | -| **Input** | 4× EC11 rotary encoders with push-switch; long-press encoder 4 to switch patterns ([controls](#playing-it)) | -| **Power** | 5 V over USB from any power bank; about **4 h per 10,000 mAh** at max brightness with a typical pattern (see [runtime](#power--runtime)) | -| **Size / weight** | 245 × 325 × 36 mm (9.6 × 12.8 × 1.4 in) · 933 g (2.06 lb) | -| **Firmware** | Arduino-compatible C++, modular pattern architecture, runtime switching (no reflash) | -| **Flashing** | Everything from the browser: one USB flash the first time, then it's all Wi-Fi. Patterns install as modules in seconds, full firmware builds land wirelessly too. A local build (PlatformIO) only for firmware development or other matrix resolutions | -| **Connectivity** | Wi-Fi and USB. Network MIDI (a MIDI port in any DAW — Ableton, Logic, Bitwig), bidirectional OSC (Max/TouchDesigner/Resolume), MQTT and audio-react each ship in an [edition](docs/EDITIONS.md) you install in one click — the way patterns do — and switching keeps your patterns, networks and settings | -| **Editions** | One image ships on the board; others are a click away on [the shelf](https://patternflow.work/editions); what each feature does, with a reel of it, is on [the features page](https://patternflow.work/features). Writing a feature or cutting your own firmware starts at **[docs/EDITIONS.md](docs/EDITIONS.md)** | -| **Build** | ~1 h hands-on (≈30 min soldering + ≈30 min assembly, first-build friendly) + ~10 h 3D printing · US$100–200 in parts ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)) | -| **License** | MIT (firmware & web code) · CC-BY-SA 4.0 (hardware, docs, bundled patterns) · community patterns are licensed by their authors ([summary](docs/LICENSE-SUMMARY.md)) | +Panel stays dark? Check that the ribbon is in the panel's `IN`, not `OUT`, that red goes to +5V at both `J3` and `J4`, and that the power bank is on; for a panel other than the BOM's, see [Panel Compatibility](docs/panel-compatibility.md). Only starts after a press of RST? See [BUILD_GUIDE §10](BUILD_GUIDE.md#10-known-issues--design-notes). Then finish with [BUILD_GUIDE §9](BUILD_GUIDE.md#9-final-checks): every knob, one power cycle, the back panel, and the knobs last. -### Power & runtime +## Playing it -Patternflow runs off any standard USB power bank that can supply a couple of amps at 5 V. You don't need a wall adapter, so it stays portable. Rule of thumb: **about 4 hours per 10,000 mAh** at maximum brightness with a typical pattern. +Every knob does two jobs. Turn it, or give it a short press, and you move the pattern that's running; what each knob does there is the pattern's own decision, so the same knob feels different on every one. Hold it down and a device function opens instead, and a second long-press closes it. -
-Measured runtimes +

+ Patternflow controls: K1 brightness, K2 network, K3 knob numbers, K4 patterns; turn or short-press to move the running pattern, long-press for device functions +

-In testing, a 40,000 mAh bank dropped about 13% over 2 hours at **full brightness**, which is roughly **15 hours on a full charge**: +| Hold | Where | Opens | +| :--- | :--- | :--- | +| **K1** | top right | **Brightness** | +| **K2** | top left | **Network**: the board's address. Type it, or `patternflow.local`, into a browser for the web console, every feature the device has, from a phone too. With no Wi-Fi it shows the name of its own network instead | +| **K3** | bottom right | **Knob numbers**, each knob's number on the panel; the order isn't the one you'd guess | +| **K4** | bottom left | **Patterns**: turn to browse what's installed, stop on one to play it, long-press again to close | -| Power bank | Approx. runtime (max brightness) | -| :--- | :--- | -| 10,000 mAh | ~4 hours | -| 20,000 mAh | ~8 hours | -| 40,000 mAh | ~15 hours | +### What's on the device -These were measured with a bright generative pattern at maximum brightness, which is how the device is normally used. Lowering the brightness (long-press encoder 1) extends runtime well beyond these figures. +A new board boots into **Origin**, concentric sine waves sampled by an emergent grid. It is the one pattern compiled into the firmware, the failsafe a board can always boot into. Everything else lives on the board's storage as **`.pfm` modules**, up to 128 of them, installed over Wi-Fi with no reflash. Adding a pattern never costs a firmware update, and a firmware update never touches your patterns, your Wi-Fi or your storage. -**Sleep mode** takes it further: the panel goes dark, the HUB75 transfer stops and the board idles, while staying on Wi-Fi so it can be woken again from MQTT or a browser. Hold encoder 2 for the NETWORK screen, then turn encoder 1; any knob or button wakes it. The pattern that was running comes back, as it does after a full power cut. +The **Basics pack** is 33 patterns at the top of the [decks shelf](https://community.patternflow.work/community/decks), one click and no account; or drop its `.zip` on your board's Patterns page yourself. The [Live Editor](https://patternflow.work/pattern) opens with a library of its own, over forty patterns, each one loadable and remixable in the browser. -**A pattern that fills the screen with white is a different story.** The same 40,000 mAh bank dropped 9% in 30 minutes: about 26.6 W, or roughly 4.8 A at 5 V, which is above what a typical USB-A port is rated for and enough to make the connector warm. That works out to ~5.5 hours on a 40,000 mAh bank. A total-pixel-power clamp is going into the firmware so this can't happen; until then, avoid near-full-white patterns on a power bank, or use a supply and cable rated for the current. +## MIDI, OSC, MQTT & audio-react -
+

+ Patternflow beside an Ableton Live session, with the Patternflow Bridge Max for Live device open and the four knobs mapped to Live parameters +

-## Playing it +The default firmware is the instrument and nothing else, which leaves the most memory for your patterns. MIDI, OSC, MQTT and audio-react come in **editions**, other builds of the same firmware, installed in one click from **[the editions page](https://patternflow.work/editions)** the way patterns arrive. Switching, and switching back, keeps your patterns, Wi-Fi networks and settings. [The features page](https://patternflow.work/features) shows each feature with a reel of it, and [`docs/EDITIONS.md`](docs/EDITIONS.md) explains why it's arranged this way and how to build an edition of your own. -Every encoder does two jobs. Turn it, or give it a short press, and you are moving the pattern that's running. Hold it down and a device function opens instead, and a second long-press closes it again. +| | What it does | Edition | Read | +| :--- | :--- | :--- | :--- | +| **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](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) | -

- Patternflow controls: encoder 1 brightness, 2 network, 3 knob numbers, 4 patterns; turn or short-press to move the running pattern, long-press for device functions -

+The panel plays its own patterns, not a video feed: nothing streams pixels to it. Your rig plays it the way a hand does, through the knobs. -What a knob does to a running pattern is the pattern's own decision, so the same knob feels different on every one of them. That is what encoder 3 is for: hold it and each knob puts its own number on the panel, which saves counting around the front. +**Performance**, maintained by **[Simone Majocchi](https://github.com/SimonePDA)**, is for a panel that runs a room: sequences on a timeline (cue lists and the night/wake scheduler), MQTT in every role, the weather mapped onto the knobs, and the clock. -The device functions themselves are fixed. Encoder 1 is brightness. Encoder 4 opens the pattern list, where you turn to browse the names and long-press again to load one, choosing from whatever is installed at the time. Encoder 2 puts the board's IP address on the panel, and typing that into a browser opens the web console, which carries every feature the device has and works from a phone. +
+More on MIDI, OSC, MQTT and audio-react -[patternflow.work/guide/play](https://patternflow.work/guide/play) has all of this on a Patternflow you can turn, press and hold, from flashing the ESP32 to the console. The same guide builds one with you ([Build](https://patternflow.work/guide/build)) and takes you through the community and Pattern Lab ([Make](https://patternflow.work/guide/make)); [patternflow.work/guide](https://patternflow.work/guide) is where to start. +**MIDI, over the network.** RTP-MIDI over Wi-Fi: native on macOS and iOS, one free driver on Windows. The Pattern Lab's Director exports a show as exactly the four CC lanes the knobs answer to ([Director → MIDI](docs/director-midi.md)), so a show can play on the panel from Live's transport. Sensitivity per knob, an absolute or relative mode, and the session that reconnects itself all live on the panel's console. -## On the device +**OSC.** Play Patternflow as a controller for your set, let your set play the light, or both at once. OSC is the language of Max, TouchDesigner, Resolume and Processing, and it carries more than MIDI can: pattern names, floats, a heartbeat. For Ableton Live Suite there's a ready-made Max for Live bridge in [`integrations/ableton`](integrations/ableton): click Connect, map the four knobs to any Live parameters, done. -A new board boots into **Origin**, concentric sine waves sampled by an emergent grid, and long-pressing encoder 4 opens the list of whatever else is installed. Patterns live on the filesystem rather than inside the program. The firmware compiles in Origin alone, as the failsafe a board can always boot into, and everything else installs as **`.pfm` modules over Wi-Fi**, up to 128 of them, no reflash. Adding a pattern never costs a firmware update, and a firmware update never touches your patterns, your Wi-Fi, or your storage. +**MQTT.** Knob turns and pattern changes publish as they happen, and messages coming the other way move the knobs and switch patterns exactly as a hand on the knob would. It puts the panel on the same bus as the rest of a home or venue, so Home Assistant, Node-RED or a lighting desk can turn its knobs and switch its patterns with nothing Patternflow-specific in the middle. Two boards pointed at the same broker follow each other, which is the short version of why it exists. Point it at a broker on the console's **MQTT** page; a pattern can be addressed by name or by slug. Publishing `1` to `/sleep` puts the panel to sleep and `0` wakes it, with the state mirrored on `/sleep/state`: enough for a Home Assistant switch, and the one command a panel obeys in either role. Simone's work, along with the browser-side zip unpacking that makes pattern packs install in one click. -A new board is therefore nearly empty, so a set ships with it: the **Basics pack**, 33 patterns, at the top of the [decks shelf](https://community.patternflow.work/community/decks). One click installs the lot, no account and no build queue, or drop the `.zip` on your board's Patterns page yourself. The [Live Editor](https://patternflow.work/pattern) opens with its own preset library of 42 patterns, each loadable and remixable in the browser. +**Audio-react.** The Chrome/Edge extension in [`tools/patternflow-audio-extension`](tools/patternflow-audio-extension) captures the current tab's audio, splits it into four bands you shape on a response graph, and sends the levels to the panel over a WebSocket. A pattern reads them as ordinary parameters. The panel can also listen for itself: a $5 PDM microphone on five thin leads runs the same analysis on the device, with no computer involved. -A local firmware build (PlatformIO, driven by `firmware/bundles/build.sh`) is only needed for feature development or targeting an LED matrix with a different resolution; the setup is [`firmware/README.md`](firmware/README.md) and a new feature starts at [FEATURE_GUIDE.md](FEATURE_GUIDE.md). Patterns never need it: [Custom Patterns](firmware/CUSTOM_PATTERNS.md). To rebuild the shipped pack from the repo's own preset sources, see [`firmware/toolchain/make_pack.py`](firmware/toolchain/make_pack.py). +
-## MIDI, OSC, MQTT & audio-react +## Quick facts -

- Patternflow beside an Ableton Live session, with the Patternflow Bridge Max for Live device open and the four knobs mapped to Live parameters -

+| | | +| :--- | :--- | +| **Display** | 128 × 64 HUB75 RGB LED matrix, P2.5 (320 × 160 mm); the driver chips decide which panels work ([compatibility](docs/panel-compatibility.md)) | +| **Brain** | ESP32-S3-DevKitC-1 **N16R8** (16 MB flash, 8 MB PSRAM), plugged into sockets, never soldered · drives the panel itself, no sending card | +| **Board** | v3.9, every soldered part through-hole · KiCad source, Gerbers and schematic in [`hardware/`](hardware/README.md) | +| **Input** | 4× EC11 rotary encoders with push-switch ([controls](#playing-it)) | +| **Power** | 5 V from a USB power bank into **`J4`, the screw terminal**, its only power input · about **4 h per 10,000 mAh** at max brightness ([runtime](#power--runtime)) | +| **Size / weight** | 245 × 325 × 36 mm (9.6 × 12.8 × 1.4 in) · 933 g (2.06 lb) | +| **Firmware** | Arduino-compatible C++, patterns as modules switched at runtime, no reflash | +| **Flashing** | From the browser, once over USB with the DevKit out of the board; after that, patterns and firmware updates arrive over Wi-Fi | +| **Connectivity** | Wi-Fi, an [HTTP API](docs/rest-api.md), and a network of its own when no known one is in reach · MIDI, OSC, MQTT and audio-react in one-click [editions](#midi-osc-mqtt--audio-react) | +| **Build** | ~2 weeks for parts · ~10 h of 3D printing · ~1 h hands-on (≈30 min soldering + ≈30 min assembly, first-build friendly) · US$100–200 in parts ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)) | +| **License** | MIT code · CC BY-SA 4.0 hardware, docs and bundled patterns · community patterns licensed by their authors ([License](#license)) | + +### Power & runtime -Each of these lives in an **edition** — a firmware you install from [the shelf](https://patternflow.work/editions) in one click, the same way patterns arrive, and switching keeps your patterns, Wi-Fi networks and settings. MIDI, OSC and audio-react are in **Audio**; MQTT is in **Performance**, alongside sequences and the Director. Why it is arranged that way, and how to build your own: [`docs/EDITIONS.md`](docs/EDITIONS.md). +Patternflow runs off any standard USB power bank that fits the case's compartment and can supply a couple of amps at 5 V, wired into `J4`. You don't need a wall adapter, so it stays portable. + +
+Measured runtimes, sleep mode, and full-white patterns + +In testing, a 40,000 mAh bank dropped about 13% over 2 hours at **full brightness**, which is roughly **15 hours on a full charge**: + +| Power bank | Approx. runtime (max brightness) | +| :--- | :--- | +| 10,000 mAh | ~4 hours | +| 20,000 mAh | ~8 hours | +| 40,000 mAh | ~15 hours | -**MIDI, over the network.** *New.* The panel is a MIDI port: RTP-MIDI over Wi-Fi, native on macOS and iOS, one free driver on Windows. Its four knobs and buttons map to anything in Ableton with Ctrl-M, and a DAW drives the panel back with plain CC and Program Change — the Pattern Lab's Director exports a show as exactly those four CC lanes, so a show can play on the panel from Live's transport. Sensitivity per knob, an absolute or relative mode, and the session that reconnects itself all live on the panel's console. Step by step with screenshots: [`docs/midi-ableton.md`](docs/midi-ableton.md); the contract: [`docs/midi-spec.md`](docs/midi-spec.md). +These were measured with a bright generative pattern at maximum brightness, which is how the device is normally used. Lowering the brightness (long-press K1) extends runtime well beyond these figures. -**Bidirectional OSC.** Over Wi-Fi, Patternflow speaks OSC in both directions: knob turns, button presses, and pattern switches stream out to a remote host (Ableton Live, Max/MSP, TouchDesigner, anything that speaks OSC), and incoming OSC messages drive the device exactly like physical encoder motion. Play Patternflow as a controller for your set, let your set drive the light, or both at once. OSC is the language of Max, TouchDesigner, Resolume and Processing, and it carries more than MIDI can — pattern names, floats, a heartbeat. For Ableton Live Suite there's a ready-made Max for Live bridge in [`integrations/ableton`](integrations/ableton). Click Connect, map the four knobs to any Live parameters, done. The wire protocol is documented in [`docs/osc-spec.md`](docs/osc-spec.md). +**Sleep mode** takes it further: the panel goes dark, the HUB75 transfer stops and the board idles, while staying on Wi-Fi so a browser (or MQTT, on the Performance edition) can wake it again. Hold K2 for the NETWORK screen, then turn K1; any knob or button wakes it. The pattern that was running comes back, as it does after a full power cut. -**MQTT.** Patternflow also speaks MQTT, both ways, on the broker you already run ([the contract](docs/mqtt-spec.md)). Knob turns and pattern changes publish as they happen; messages coming the other way move the knobs and switch patterns exactly as a hand on the encoder would. Two boards pointed at the same broker follow each other, which is the short version of why it exists. It also puts the device on the same bus as the rest of a home or venue setup, so Home Assistant, Node-RED or a lighting desk can drive it without anything Patternflow-specific in the middle. Point it at a broker on the device's own **MQTT** page; a pattern can be addressed by name or by slug. Publishing `1` to `/sleep` puts the panel to sleep and `0` wakes it, with the current state mirrored on `/sleep/state`. That is enough for a Home Assistant switch, and it is the one command a panel obeys whether it is set to Publisher or Subscriber. Contributed by **[@SimonePDA](https://github.com/SimonePDA)** (Simone Majocchi), along with the browser-side zip unpacking that makes pattern packs install in one click. +**A pattern that fills the screen with white is a different story.** The same 40,000 mAh bank dropped 9% in 30 minutes: about 26.6 W, or roughly 4.8 A at 5 V, which is above what a typical USB-A port is rated for and enough to make the connector warm (~5.5 hours on that bank). The firmware's total power clamp, on by default, keeps that from happening: 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. The budget is `power_budget` on the [`/api/display`](docs/rest-api.md#get-apidisplay) endpoint. -**Audio-react.** The Chrome/Edge extension in [`tools/patternflow-audio-extension`](tools/patternflow-audio-extension) captures the current tab's audio, splits it into four bands you shape on a response graph, and sends the levels to the panel over a WebSocket. A pattern reads them as ordinary parameters, so every encoder-driven pattern reacts with no audio code of its own. The panel can also listen for itself: an on-board microphone runs the same analysis on the device, with no browser involved. +
## How it's built -Patternflow is built around a standalone ESP32-S3 driving a HUB75 RGB LED matrix at low resolution, where each pixel reads as a discrete point of light with its own brightness and color. Four rotary encoders feed firmware written in Arduino-compatible C++ around a modular pattern architecture: each pattern is a self-contained module with its own setup, update, and draw routines, while the shared framework handles input, LED rendering, mode transitions, and color calibration. I designed the PCB myself; the enclosure is 3D-printed by default. Two community remixes sit beside it in [`hardware/case/remixes/`](hardware/case/remixes/README.md), another printed case and a laser-cut one in clear acrylic or MDF, and a stainless steel version is still in progress. +Patternflow is built around a standalone ESP32-S3 driving a HUB75 RGB LED matrix at low resolution, where each pixel reads as a discrete point of light with its own brightness and color. Four rotary encoders feed firmware written in Arduino-compatible C++ around a modular pattern architecture: each pattern is a self-contained module with its own setup, update and draw routines, while the shared framework handles input, LED rendering, mode transitions and color calibration. I designed the PCB myself; the enclosure is 3D-printed by default, with the community remixes in [`hardware/case/remixes/`](hardware/case/remixes/README.md) beside it. A stainless steel version is still in progress. + +A local firmware build (PlatformIO, driven by `firmware/bundles/build.sh`) is only for feature development, or for a panel the stock image can't drive (a different driver profile or module layout, [Panel Compatibility](docs/panel-compatibility.md)). The setup is [`firmware/README.md`](firmware/README.md), and a new feature starts at [FEATURE_GUIDE.md](FEATURE_GUIDE.md). Patterns never need it ([Custom Patterns](firmware/CUSTOM_PATTERNS.md)). The Basics pack is built from the repo's own preset sources and zipped by [`firmware/toolchain/make_pack.py`](firmware/toolchain/make_pack.py). ## Repository & documentation -> **Moving fast.** [v3.11.0 is out](https://github.com/engmung/Patternflow/releases/tag/v3.11.0): installed patterns run faster - the Basics pack is rebuilt and every one of its 33 patterns gains, 1.5 times the frames a second at the median, once the firmware is updated and the pack installed again - and a pattern you go back to resumes at once instead of loading again. The console is light and reads as one thing, and the Audio edition's page and browser extension are rebuilt: the page says who has the knobs and saves only what you changed, and the extension holds the knobs through silence. There is an [Audio guide](https://patternflow.work/guide/audio) beside Build, Play and Make. Audio is re-cut on this core as v0.7.0; Performance stays at v0.4.0, on core 3.10.4, until its maintainer re-cuts it. [v3.10.5](https://github.com/engmung/Patternflow/releases/tag/v3.10.5) made every pattern 2.45 ms a frame faster and moved a pattern's code to PSRAM, so patterns that were refused for size load and the console has more room while one is running; the panel raises its own Wi-Fi hotspot when it knows no network in reach, a panel that crashed says which pattern it was in and where, a pattern that hangs no longer takes the console down with it, and five requests that could reboot the board cannot any more. [v3.10.4](https://github.com/engmung/Patternflow/releases/tag/v3.10.4) made the panel film properly on a phone. It refreshes at exactly 300 Hz — the one rate the shutter speeds a phone actually uses (1/50, 1/60, 1/100, 1/30) all divide — so the rolling bands are gone at those; 1/120 and slow motion still band, and [the firmware README](firmware/README.md#refresh-rate-anti-flicker-for-video) says why and what to set. [v3.10.3](https://github.com/engmung/Patternflow/releases/tag/v3.10.3) made the panel's colours come out right: bright edges had been growing purple and sky-blue fringes and greys read pink; the cause was in the display driver — which bit plane each brightness window actually lights — and part of it was 3.10.1's own dark-end fix. The planes are exactly binary now, and because the fix is in the driver every installed pattern gets it without a rebuild. It also brings a [Knobs page](docs/rest-api.md#get-apiknobs-and-post-apiknobs) on the device (which way each encoder counts, set from the browser) and the [features catalogue](https://patternflow.work/features). Both editions were re-cut on that core. [v3.10.2](https://github.com/engmung/Patternflow/releases/tag/v3.10.2) made a Format that did not take stop reporting success, and a storage that will not mount say why. [v3.10.1](https://github.com/engmung/Patternflow/releases/tag/v3.10.1) fixed an upload that could reboot the board, a failed settings write that could make the next boot forget a good pattern, a mic-less Audio panel holding the knobs, and MIDI notes that would not let go. [v3.10.0](https://github.com/engmung/Patternflow/releases/tag/v3.10.0) made a heavy pattern come on every time. [v3.9.5](https://github.com/engmung/Patternflow/releases/tag/v3.9.5) taught the web console to survive a slow Wi-Fi link and a heavy pattern, and put thumbnails under the knob. [v3.8.0](https://github.com/engmung/Patternflow/releases/tag/v3.8.0) split the firmware into [editions](docs/EDITIONS.md) — core, Audio (on-board microphone, OSC, network MIDI) and Performance (sequences, MQTT, weather) — installable in one click and switchable without losing your patterns. [v3.2.0](https://github.com/engmung/Patternflow/releases/tag/v3.2.0) made patterns install over Wi-Fi as `.pfm` modules, no reflash. On v2.x hardware? Everything you need stays bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). Follow the [changelog](CHANGELOG.md) and the [journal](https://patternflow.work/journal) for what's current. +Folder by folder, and what you want to do → where to start: **[docs/REPOSITORY.md](docs/REPOSITORY.md)**. Every doc is indexed in [`docs/README.md`](docs/README.md). + +
+The top-level folders | Folder | Contents | | :--- | :--- | | `firmware/` | Arduino code for ESP32-S3, the custom pattern template, and the toolchain that builds patterns into `.pfm` modules and packs | | `hardware/` | Enclosure files and electronics source files (case, PCB, Gerbers, schematic PDF) | | `web/` | Next.js site (landing, the interactive guide, Live Editor, Pattern Lab, community, browser flasher & build server, journal); the breadboard build guide is a React page here | -| `docs/` | The contracts (HTTP, OSC, MIDI, MQTT, audio WebSocket, show files), how the firmware is put together (`EDITIONS.md`), the assembly map, walk-throughs, records and media — indexed in [`docs/README.md`](docs/README.md), with the folder-by-folder map in [`docs/REPOSITORY.md`](docs/REPOSITORY.md) | +| `docs/` | The contracts (HTTP, OSC, MIDI, MQTT, audio WebSocket, show files), how the firmware is put together (`EDITIONS.md`), the assembly map, walk-throughs, records and media | | `tools/` | Desktop-side helpers, including the audio-react browser extension | | `integrations/` | Host-software bridges: Ableton Live / Max for Live (OSC knob mapping) | | `.github/` | Issue and PR templates, and the CI that runs on every pull request (web, every firmware edition, doc links, console pages) | -**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): each step shown on a 3D Patternflow you can turn, in English and Korean -**Build:** [Full Build Guide](BUILD_GUIDE.md) · [Assembly Map](docs/assembly/README.md) · [Panel Compatibility](docs/panel-compatibility.md) · [Hardware files](hardware/README.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) -**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) · [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) +
+**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](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](.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 + +**Moving fast.** [v3.11.0 is out](https://github.com/engmung/Patternflow/releases/tag/v3.11.0): installed patterns run faster, a pattern you go back to resumes at once, and there is an [Audio guide](https://patternflow.work/guide/audio) beside Build, Play and Make. Follow the [changelog](CHANGELOG.md) and the [journal](https://patternflow.work/journal) for what's current. + +
+Recent releases in brief + +- **[v3.11.0](https://github.com/engmung/Patternflow/releases/tag/v3.11.0)**: installed patterns run faster. The Basics pack is rebuilt and every one of its 33 patterns runs faster, 1.5 times the frames a second at the median, once the firmware is updated and the pack installed again; a pattern you go back to resumes at once instead of loading again. The console is light and reads as one thing, and the Audio edition's page and browser extension are rebuilt: the page says who has the knobs and saves only what you changed, and the extension holds the knobs through silence. Audio is re-cut on this core as v0.7.0; Performance stays at v0.4.0, on core 3.10.4, until its maintainer re-cuts it. +- **[v3.10.5](https://github.com/engmung/Patternflow/releases/tag/v3.10.5)**: every pattern 2.45 ms a frame faster, and a pattern's code moved to PSRAM, so patterns that were refused for size load and the console has more room while one is running. The panel raises its own Wi-Fi hotspot when it knows no network in reach, a panel that crashed says which pattern it was in and where, a pattern that hangs no longer takes the console down with it, and four requests that could reboot the board cannot any more. +- **[v3.10.4](https://github.com/engmung/Patternflow/releases/tag/v3.10.4)**: the panel films properly on a phone. It refreshes at exactly 300 Hz, the one rate the shutter speeds a phone actually uses (1/50, 1/60, 1/100, 1/30) all divide, so the rolling bands are gone at those; 1/120 and slow motion still band, and [the firmware README](firmware/README.md#refresh-rate-anti-flicker-for-video) says why and what to set. +- **[v3.10.3](https://github.com/engmung/Patternflow/releases/tag/v3.10.3)**: the panel's colours come out right. Bright edges had been growing purple and sky-blue fringes and greys read pink; the cause was in the display driver (which bit plane each brightness window actually lights), and part of it was 3.10.1's own dark-end fix. The planes are exactly binary now, and because the fix is in the driver every installed pattern gets it without a rebuild. Also a [Knobs page](docs/rest-api.md#get-apiknobs-and-post-apiknobs) on the device (which way each encoder counts, set from the browser) and the [features catalogue](https://patternflow.work/features). Both editions were re-cut on that core. +- **[v3.10.2](https://github.com/engmung/Patternflow/releases/tag/v3.10.2)**: a Format that did not take stops reporting success, and a storage that will not mount says why. +- **[v3.10.1](https://github.com/engmung/Patternflow/releases/tag/v3.10.1)**: fixed an upload that could reboot the board, a failed settings write that could make the next boot forget a good pattern, a mic-less Audio panel holding the knobs, and MIDI notes that would not let go. +- **[v3.10.0](https://github.com/engmung/Patternflow/releases/tag/v3.10.0)**: a heavy pattern comes on every time. +- **[v3.9.5](https://github.com/engmung/Patternflow/releases/tag/v3.9.5)**: the web console survives a slow Wi-Fi link and a heavy pattern, and thumbnails sit under the knob. +- **[v3.9.0](https://github.com/engmung/Patternflow/releases/tag/v3.9.0)**: the panel is a MIDI port over Wi-Fi, in the Audio edition. +- **[v3.8.0](https://github.com/engmung/Patternflow/releases/tag/v3.8.0)**: the firmware split into [editions](docs/EDITIONS.md), core, Audio (on-board microphone, OSC, browser and phone audio) and Performance (sequences, MQTT, weather), installable in one click and switchable without losing your patterns. +- **[v3.2.0](https://github.com/engmung/Patternflow/releases/tag/v3.2.0)**: patterns install over Wi-Fi as `.pfm` modules, no reflash. + +
+ ## 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; **[SUPPORT.md](SUPPORT.md)** says where to ask. Patterns don't come here: publish them from the Pattern Lab to the Community. +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 +
+From a visit to the Nam June Paik Art Center in January 2026 to two exhibitions in October, month by month + | When | Milestone | | :--- | :--- | -| **Jan 2026** | *Patternflow: Origin*, the first work as a new media artist, built around 3D-printed forms and the seed of what became Patternflow · visited the Nam June Paik Art Center | +| **Jan 2026** | *Patternflow: Origin*, my first work as a new media artist, built around 3D-printed forms and the seed of what became Patternflow · visited the Nam June Paik Art Center | | **Mar 2026** | The Origin pattern ran on a physical LED matrix with four knobs for the first time | | **Apr 2026** | Instagram and the Arduino subreddit responded strongly (**150,000+ views and 3,700 upvotes**), and the community asked for the files, not a product, so Patternflow went open source · first PCB fabricated *(sponsored by PCBWay)* · website live | -| **May 2026** | Reached **100 GitHub stars** · the first collaborator joined · Crowd Supply agreement · Discord community growing | -| **Jun 2026** | [Crowd Supply pre-launch page](https://www.crowdsupply.com/engmung/patternflow) live, backed by countless refinements toward mass production · Instagram passed **1,000 followers** · first community-made pattern shared | -| **Jul 2026** | Refining the design for mass production · growing an active community · outreach and promotion | -| **Aug 2026** | [Crowd Supply campaign](https://www.crowdsupply.com/engmung/patternflow) officially launched worldwide · firmware split into [editions](docs/EDITIONS.md) (Audio & Performance) over an unchanged core · global press wave across Mixmag, Synthtopia, AMAZONA.de, and MATRIXSYNTH | -| **Sep 2026** | Driving the Crowd Supply campaign to funding success · preparing mass production and fulfillment logistics · FreeRTOS dual-core network isolation, fast CIE frame push, and continuous ecosystem polish | -| **Next** | Deliver rewards to backers at the highest quality · send Patternflow further out into the world · collaborate with more artists · earn academic recognition | +| **May 2026** | Reached **100 GitHub stars** · the Live Editor and Pattern Lab went live · the first collaborator joined · Crowd Supply agreement | +| **Jun 2026** | Nath in the UK finished the first Patternflow built outside the project, from the published files alone · [Crowd Supply pre-launch page](https://www.crowdsupply.com/engmung/patternflow) live · Instagram passed **1,000 followers** · first community-made pattern shared | +| **Jul 2026** | The v3.0 board, every soldered part through-hole · patterns install over Wi-Fi as modules, Simone Majocchi's design · the Pattern Community opened · featured on Creative Applications Network | +| **Aug 2026** | [Crowd Supply campaign](https://www.crowdsupply.com/engmung/patternflow) launched worldwide · firmware split into [editions](docs/EDITIONS.md) (Audio & Performance) over an unchanged core · press in Mixmag Asia, Synthtopia, AMAZONA.de and MATRIXSYNTH | +| **Sep 2026** | The Crowd Supply campaign met its funding goal · the funded units will be printed and assembled by hand in Korea, on boards from PCBWay · the first Patternflow sold as an artwork · FreeRTOS dual-core network isolation and a faster frame push | +| **Oct 2026** | The Crowd Supply campaign, open until 15 October, reached 200% of its goal · v3.11.0 and the Audio guide · *Anbu*, an exhibition by visually impaired artists at Modu Art Space near Seoul Station, with Patternflow versions of their works to play by hand · Patternflow in *USER EXPERIENCE*, a pop-up gallery in San Francisco | +| **Next** | Deliver every Crowd Supply unit at the highest quality · send Patternflow further out into the world · collaborate with more artists · earn academic recognition | | **2028** | Grow Patternflow into a self-sustaining community and ecosystem, then move on to the next project | -Longer write-ups and the full story behind each step live on the **[journal](https://patternflow.work/journal)**: the whole process, written up at least weekly since the beginning, the thinking and the emotions included, and the parts that went badly too. If you want to know why this project exists and what it costs to keep alive, start there. +
+ +Longer write-ups and the full story behind each step live on the **[journal](https://patternflow.work/journal)**: the whole process, written up week by week since April, the thinking and the emotions included, and the parts that went badly too. If you want to know why this project exists and what it costs to keep alive, start there. ### Sponsor @@ -230,14 +303,14 @@ Patternflow's PCB fabrication and 3D-printed enclosure are sponsored by **[PCBWa ## License -A file's SPDX header is the authority where one exists; folders are not license boundaries, and files that cannot carry a header (STL, Gerber, images) take the license the nearest README states. Full breakdown in the **[License Summary](docs/LICENSE-SUMMARY.md)**. +Two licenses, in two separate files. A file's SPDX header is the authority where one exists; folders are not license boundaries, and files that cannot carry a header (STL, Gerber, images) take the license the nearest README states. Full breakdown in the **[License Summary](docs/LICENSE-SUMMARY.md)**. -- Firmware & web code: **MIT** ([LICENSE-MIT](./LICENSE-MIT)) -- Hardware, designs & docs: **CC-BY-SA 4.0** ([LICENSE-CC-BY-SA](./LICENSE-CC-BY-SA)) -- Bundled patterns (the presets shipped in the firmware and the editor): **CC-BY-SA 4.0**, per-file SPDX headers. -- Patterns contributed *to the repository*: inbound = outbound. By sending a pattern as a PR, issue or Discord post you license it under CC-BY-SA 4.0, attribution kept in the code header (no CLA). See [CONTRIBUTING.md](CONTRIBUTING.md). -- Patterns published *to the Community*: **licensed by their author**, either **CC-BY-SA 4.0** (default) or **CC-BY-4.0**. Both permit commercial use with credit; CC-BY-SA additionally requires adaptations to stay under the same license. A fork can never be looser than what it came from, and carries a `Based on:` credit to the original author in its source. +- Code (firmware, web, tools, integrations): **MIT** ([LICENSE-MIT](./LICENSE-MIT)) +- Hardware, designs & docs: **CC BY-SA 4.0** ([LICENSE-CC-BY-SA](./LICENSE-CC-BY-SA)) +- Bundled patterns (the presets shipped in the firmware and the editor): **CC BY-SA 4.0**, per-file SPDX headers. +- Anything sent *to this repository*: inbound = outbound, MIT for code and CC BY-SA 4.0 for the rest, attribution kept in the file (no CLA). See [CONTRIBUTING.md](CONTRIBUTING.md). +- Patterns published *to the Community*: **licensed by their author**, either **CC BY-SA 4.0** (default) or **CC BY 4.0**; a few older ones keep MIT or CC0, so read the pattern's own header. Both permit commercial use with credit; CC BY-SA additionally requires adaptations to stay under the same license. A fork can never be looser than what it came from, and carries a `Based on:` credit to the original author in its source. "Patternflow" is a trademark of SeungHun Lee. -The Patternflow series: LED Synthesizer (2026) · Origin (2026) +*The Patternflow series, by SeungHun Lee: LED Synthesizer (2026) · Origin (2026)* diff --git a/docs/.human-written b/docs/.human-written new file mode 100644 index 000000000..1e088aa53 --- /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 88% rename from AUDIO_GUIDE.md rename to docs/AUDIO_GUIDE.md index 6460a30e4..197c04ff1 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. --- @@ -25,15 +25,19 @@ written for AI coding agents; everything above it is for people. Any of these puts the same firmware on your panel: - **One click:** [patternflow.work/editions](https://patternflow.work/editions) - → Audio → flash from the browser. + → 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 + 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 @@ -43,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 @@ -83,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 @@ -183,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 @@ -191,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. @@ -202,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 @@ -219,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 @@ -260,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 | | --- | --- | @@ -273,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. @@ -315,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 @@ -331,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 @@ -346,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; @@ -360,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/EDITIONS.md b/docs/EDITIONS.md index fab3a1838..7e7b69d08 100644 --- a/docs/EDITIONS.md +++ b/docs/EDITIONS.md @@ -14,9 +14,10 @@ four knobs.** That sentence decides everything below it, and it is the only test worth applying. What that sentence names is not a feature and never composes out: the pattern -loader, the four encoders, the panel driver, Wi-Fi, `/update`, sleep, the -console. Those are the device. **The default build carries no features at -all**, and a panel running it does the whole of what Patternflow is for. +loader, the four encoders, the panel driver, Wi-Fi (its own hotspot +included), `/update`, sleep, the console. Those are the device. **The default +build carries no features at all**, and a panel running it does the whole of +what Patternflow is for. Everything else — sequences, MQTT, OSC, weather, audio — is a way of driving that device from somewhere else. Real, wanted, first-class, and not the thing @@ -217,7 +218,7 @@ deliberately re-pinned in the same commit. feature code that recompiles against a checkout, in-tree or out. Widening is tail-append only (the rule and its reason are written on the struct); the compiler surfaces every break at build, and `build.sh all` makes sure -"builds" means all three compositions. The two composition filenames and +"builds" means all five compositions. The two composition filenames and their macros are the out-of-tree seam - renamed spellings are shimmed, and shims get deleted only after their consumers migrate. @@ -290,22 +291,30 @@ Two files under `firmware/bundles//`: #include "osc/feature_osc.h" #include "audio/feature_audio.h" #include "audio_in/feature_audio_in.h" +#include "midi/feature_midi.h" #define PF_FEATURE_LIST \ &PFFeatureOsc::descriptor, \ &PFFeatureAudio::descriptor, \ - &PFFeatureAudioIn::descriptor + &PFFeatureAudioIn::descriptor, \ + &PFFeatureMidi::descriptor ``` ```c // overrides.h — what it calls itself, and anything it needs set differently #define PF_VARIANT "audio" -#define PF_VARIANT_VERSION "v0.2.0" // yours, moves at your pace -#define PF_WIFI_TX_POWER WIFI_POWER_17dBm +#define PF_VARIANT_VERSION "v0.7.0" // yours, moves at your pace +#define PF_AUDIO_IN_DRIVES_KNOBS 1 // the microphone drives the knobs here ``` +That is the Audio edition's pair with the comments taken out (v0.7.0 when +this was written). It pinned `PF_WIFI_TX_POWER` too until the core went to +full power, and dropped it in v0.7.0 — which is why every setting in an +`overrides.h` carries its reason: one copied without it stays after the reason +has gone. + A third file, `env`, is optional: one line naming the PlatformIO env to build in, for the rare edition whose difference is a build flag rather than a -setting. The MIDI edition is the one so far — its USB port has to be the OTG +setting. The MIDI bundle is the one so far — its USB port has to be the OTG controller for a MIDI device to exist, and the Arduino core decides that at compile time (`ARDUINO_USB_MODE`). Everything else about it is the two files above; `build.sh` and `shelf.sh` read the env name and look for the outputs @@ -463,6 +472,9 @@ because no phone showed it in its pick list; and the Wi-Fi setup portal is a parked branch, `feat/wifi-setup-portal`, in no edition and not to be merged as it stands — it predates the seam and edits the core. What that attempt learned is in [`investigations/2026-08-the-wifi-portal-a-samsung-never-drew.md`](investigations/2026-08-the-wifi-portal-a-samsung-never-drew.md). +The need it was for — setting Wi-Fi up from a phone — is met in the core +since 3.10.5 by the panel's own hotspot (`src/core_hotspot.h`): the +device's Wi-Fi, so core rather than a feature. --- diff --git a/docs/README.md b/docs/README.md index 096215e3e..007b2f060 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,32 +20,45 @@ 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 -- [`assembly/`](assembly/README.md) — the assembly map: which enclosure with which electronics, and where each route is documented - [`../firmware/CUSTOM_PATTERNS.md`](../firmware/CUSTOM_PATTERNS.md) — the hands-on pattern route (the everyday one is [`PATTERN_GUIDE.md`](../PATTERN_GUIDE.md)) - [`../firmware/patternflow/console/README.md`](../firmware/patternflow/console/README.md) — editing or adding a device console page ## Building it -- [`assembly/`](assembly/README.md) — the map. The full guide is [`../BUILD_GUIDE.md`](../BUILD_GUIDE.md); the files to order are described in [`../hardware/README.md`](../hardware/README.md) +- [`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 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 +- [`rfc-cleanup.md`](rfc-cleanup.md) and its [progress log](rfc-cleanup-progress.md) — the October 2026 tidy-up before the any-resolution work, done on 16 October. It changed where things live and what CI checks, and nothing the firmware or the site does. §2.9 lists the writing it locked +- [`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 fb438b7ef..cd7bcde55 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,9 +103,9 @@ 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. -- `v3.0.0` -- the v3.0 board; `v3.9` (2026-09, unreleased as a tag) removed its USB-C footprint and changed nothing else. Every later `v3.x` is firmware/web on that hardware: `.pfm` modules over Wi-Fi (3.2), shows and the Director (3.6), the feature seam (3.7), editions (3.8). +- `v3.0.0` -- the v3.0 board; `v3.9` (2026-09, unreleased as a tag) removed its USB-C footprint and changed nothing else. Every later `v3.x` is firmware/web on that hardware: `.pfm` modules over Wi-Fi (3.2), shows and the Director (3.6), the feature seam (3.7), editions (3.8), the panel's own hotspot and pattern code in PSRAM (3.10.5), patterns that stay loaded when you leave them (3.11). - Editions on the shelf (`audio`, `performance`) carry their own version lines, independent of the project version — see `docs/EDITIONS.md`. `clock` and `midi` are bundles CI builds but the shelf does not carry. diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md index e22c6b03d..adbf7f49b 100644 --- a/docs/REPOSITORY.md +++ b/docs/REPOSITORY.md @@ -6,27 +6,28 @@ A map for people. (AI coding agents get the same map with more rules in [`../AGE | Folder | What it is | Start at | | :--- | :--- | :--- | -| `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, MQTT, MIDI, shows, weather, clock — 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 editions (which features compile in) and holds `build.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 | [`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, and `remixes/` for community variants), `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), browser flasher, edition shelf, 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 | [`tools/README.md`](../tools/README.md) | +| `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) | +| `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, the CI workflows (web, firmware editions, doc links, 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 | Goal | Path | | :--- | :--- | | Build one | [`README.md`](../README.md) → [`BUILD_GUIDE.md`](../BUILD_GUIDE.md) (or the [assembly map](assembly/README.md) for the other routes) → [`hardware/README.md`](../hardware/README.md) for which files to order. The same build in 3D: [patternflow.work/guide/build](https://patternflow.work/guide/build) | -| 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 | +| 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,15 @@ 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` (three section anchors are linked from the site), `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) -- `docs/EDITIONS.md`, `docs/rest-api.md`, `firmware/README.md`, `hardware/bom/bom_v3.9.csv` +- `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`, `CHANGELOG.md`, `CONTRIBUTING.md` and `.github/SUPPORT.md` (the issue forms link the last two) +- `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`, `docs/osc-spec.md`, `docs/mqtt-spec.md`, `docs/audio-ws-spec.md`, `docs/pfst-v2-spec.md`, `docs/director-midi.md` (the features page links each of these) +- `docs/panel-compatibility.md`, `docs/legacy/BUILD_GUIDE_v2.md`, `docs/LICENSE-SUMMARY.md`, `docs/releases/v2.0.0.md`, `firmware/README.md`, `firmware/CUSTOM_PATTERNS.md`, `hardware/README.md`, `hardware/bom/bom_v3.9.csv`, `tools/patternflow-audio-android/README.md` +- `hardware/case/README.md`, and each folder in `hardware/case/remixes/` with the files in it. The Build panel's case cards link them by name: a README, a `print_layout.3mf`, the mbchars case's `assembly.md` and `bom.md` - `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. +Folders are linked too. The features page links each feature's folder in `firmware/patternflow/features/`, and the site's Build guide links files in `hardware/case/bed_256mm/`, `hardware/case/bed_330mm/` and `hardware/pcb/`. Every row, with the file the link sits in, is in [`investigations/2026-10-doc-map.csv`](investigations/2026-10-doc-map.csv) under the kind `absolute-blob`. `npm run doc-map` in `web/` writes that list again from the tree as it is. + +`.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/SERVICES.md b/docs/SERVICES.md index ee49989ab..fca4ac937 100644 --- a/docs/SERVICES.md +++ b/docs/SERVICES.md @@ -1,6 +1,6 @@ # Patternflow Server Services & Worker Management -Patternflow 커뮤니티 웹 서버 및 C++ 펌웨어 웹플래시 컴파일 빌드 워커 서비스 구성과 운영 방법 가이드입니다. +Patternflow 커뮤니티 웹 서버와, 패턴 C++ 헤더를 `.pfm` 모듈로 굽는 빌드 워커 서비스 구성과 운영 방법 가이드입니다. --- @@ -32,10 +32,10 @@ RestartSec=3 OOM 킬은 `dmesg -T | grep -i "killed process"` 로 봅니다. ### ② `patternflow-worker.service` -- **역할**: C++ 펌웨어 빌드 워커 (`scripts/build-worker.ts`) -- **설명**: 웹플래시 요청 대기열(`queued`)을 수신하여 `arduino-cli`를 이용해 ESP32-S3 바이너리를 컴파일합니다. +- **역할**: 패턴 모듈 빌드 워커 (`scripts/build-worker.ts`) +- **설명**: 빌드 대기열(`queued`)에서 잡을 하나씩 가져가 헤더마다 `firmware/toolchain/build_module.py`와 Xtensa 컴파일러로 ESP32-S3용 `.pfm` 모듈을 굽습니다. 펌웨어 이미지 통째 빌드는 2026-08-12(`4c13474`)에 끝났고, 그 뒤로 워커는 `arduino-cli`를 부르지 않습니다. - **위치**: `/etc/systemd/system/patternflow-worker.service` -- **환경변수 중요 사항**: `Environment=PATH`에 `arduino-cli` 설치 경로(`~/.local/bin` 또는 `/usr/local/bin`) 포함 필수 +- **환경변수 중요 사항**: 워커는 `python`(다른 이름이면 `PYTHON_PATH`)과 `PF_XTENSA_BIN`이 가리키는 컴파일러(아래 2단계)를 띄웁니다. `Environment=PATH`에는 Python이 있어야 하고, 예전에 넣은 `arduino-cli` 경로는 이제 쓰이지 않습니다. #### 워커는 격리된 채로 돈다 — `hardening.conf` (2026-09-04) @@ -93,9 +93,10 @@ strace로 확인한 전부다: 데이터 디렉터리(DB·WAL·산출물), 워 것으로 재현했다. 두 단계 중 첫째는 끝났다: 1. **시크릿은 유닛 밖 (완료, 2026-09-04).** 워커가 필요로 하는 변수 - (`COMMUNITY_ENABLED`, `BUILD_ENABLED`, `BUILD_FQBN`, `COMMUNITY_DB_PATH`)는 - root 전용 `/etc/patternflow/worker.env`(0600)에서 `EnvironmentFile=`로 - 받는다. `.env.local`은 `InaccessiblePaths`로 막으면 워커가 못 뜬다 — + (`COMMUNITY_ENABLED`, `BUILD_ENABLED`, `COMMUNITY_DB_PATH`, 2단계의 + `PF_XTENSA_BIN`)는 root 전용 `/etc/patternflow/worker.env`(0600)에서 + `EnvironmentFile=`로 받는다. 그날 같이 넣은 `BUILD_FQBN`은 통째 이미지 + 빌드의 보드 옵션이라 8월 12일 뒤로 읽는 코드가 없다. `.env.local`은 `InaccessiblePaths`로 막으면 워커가 못 뜬다 — `scripts/loadEnv.ts`의 `existsSync`는 접근 불가 파일에도 `true`를 돌려주고, 그다음 `process.loadEnvFile`이 EACCES에 예외를 던진다. 그래서 `BindReadOnlyPaths=/dev/null:…/.env.local`로 **빈 파일로 보이게** 한다: @@ -314,8 +315,11 @@ npm run build:enqueue ## 3. 자주 발생하는 문제 & 조치법 (Troubleshooting) -- **웹플래시 요청이 계속 `queued`에서 안 넘어감**: +- **빌드 요청이 계속 `queued`에서 안 넘어감**: - `patternflow-worker.service`가 켜져 있는지 확인: `sudo systemctl status patternflow-worker.service` - 꺼져 있다면 재시작: `sudo systemctl restart patternflow-worker.service` -- **`spawn arduino-cli ENOENT` 에러 발생**: - - `patternflow-worker.service` 파일 내 `PATH` 환경변수에 `arduino-cli` 설치 경로가 올바르게 지정되었는지 확인. +- **빌드 오류가 `The build service's compiler is not available right now. No Xtensa compiler found`로 시작함**: + - 워커가 `xtensa-esp32s3-elf-g++`를 찾지 못했습니다. `/etc/patternflow/worker.env`의 `PF_XTENSA_BIN`이 래퍼 디렉터리(`/etc/patternflow/xtensa-sandbox`)를 가리키는지, 래퍼가 부르는 `~/.arduino15/packages/esp32/tools/esp-x32/2601`이 그대로 있는지 확인. + - 오류 전문은 `npm run build:status -- `의 `--- error ---` 아래에 나옵니다. +- **빌드 오류에 `[cannot run python: spawn python ENOENT]`가 나옴**: + - 유닛의 `PATH`에 `python`이 없습니다. `worker.env`에 `PYTHON_PATH=python3`처럼 실행 파일 이름이나 경로를 넣고 워커를 재시작. diff --git a/docs/assembly/README.md b/docs/assembly/README.md index 07010a0d1..beb91b416 100644 --- a/docs/assembly/README.md +++ b/docs/assembly/README.md @@ -1,13 +1,13 @@ # 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. Patternflow is not a single, rigid kit. It is a modular system divided into two core parts: -1. **Enclosure** — how you house the device (3D printed, or a community remix such as the laser-cut one below). +1. **Enclosure** — how you house the device (3D printed, or one of the community remixes below). 2. **Electronics** — how you wire the hardware (custom PCB, or breadboard). Build those two, flash the firmware, and your Patternflow is alive. @@ -31,6 +31,7 @@ This is the route [BUILD_GUIDE.md](../../BUILD_GUIDE.md) walks start to finish, | 3D printed enclosure | Breadboard / jumper-wire electronics | Available — [Breadboard Build Guide](https://patternflow.work/build/breadboard) | | Laser-cut acrylic or MDF sheet | Custom PCB | Community — [`remixes/simonepda-lasercut/`](../../hardware/case/remixes/simonepda-lasercut/README.md), by Simone Majocchi. Built by its author in both materials; its README stands in for the printing and case-assembly sections of the build guide | | 3D printed, glued halves with screwed-on covers | Custom PCB | Community — [`remixes/besoiobiy-printed/`](../../hardware/case/remixes/besoiobiy-printed/README.md), by Besoiobiy. Built by its author; the panel is bolted through the case into its own M3 sockets, so check your panel's thread size and socket depth first. Its README stands in for the printing and case-assembly sections of the build guide and sets the wiring order | +| 3D printed, horizontal on a desk, with stands | Custom PCB | Community — [`remixes/mbchars-horizontal-desktop-printed/`](../../hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md), by Mykyta Bilous (mbchars). Built by its author; the panel lies the long way with the controls on the right, and there is no power-bank bay — a separate USB-PD trigger module feeds `J4`, the screw terminal, at 5 V. Its README and assembly notes stand in for the printing and case-assembly sections of the build guide | | Any other enclosure | either | Not an official path. Community variants live in [`hardware/case/remixes/`](../../hardware/case/remixes/README.md); the old acrylic drawings, for the v1 and v2 boards, are in [`legacy_lasercut/`](../../hardware/case/legacy_lasercut/README.md) | The breadboard path exists to make Patternflow easier and cheaper to start. A breadboard build is not just a temporary prototype — if that form is enough for you, it is a valid Patternflow build. Want a more finished object later? Move to the PCB and printed-enclosure path whenever you like. @@ -41,7 +42,7 @@ The custom PCB path is stable. PCBA may become a later electronics path for peop To bring the hardware to life you flash it with firmware, and you can **create and run your own generative patterns with AI coding assistants**. -Custom patterns no longer need a local toolchain or a reflash: a pattern built in the browser installs on the panel over Wi-Fi as a `.pfm` module in seconds (the browser flasher writes the firmware itself over USB once, and sets up Wi-Fi while it is at it). The PlatformIO route remains only for firmware feature development or targeting a different LED matrix resolution. +Custom patterns no longer need a local toolchain or a reflash: a pattern built in the browser installs on the panel over Wi-Fi as a `.pfm` module in seconds (the browser flasher writes the firmware over USB once, with the DevKit out of the board and in download mode — hold BOOT, tap RST — and Wi-Fi is set after it, usually on a second go with the flasher or from the panel's own network; the Play guide's [Flash chapter](https://patternflow.work/guide/play#flash) has the order, and [`BUILD_GUIDE.md` §8](../../BUILD_GUIDE.md#8-firmware) the details). The PlatformIO route remains only for firmware feature development or targeting a different LED matrix resolution. - **[Make your own patterns](../../PATTERN_GUIDE.md)** — the Pattern Lab, the community, and sending a pattern to your device over Wi-Fi. [`firmware/CUSTOM_PATTERNS.md`](../../firmware/CUSTOM_PATTERNS.md) is the hands-on route. @@ -50,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 000000000..c9ec8b651 --- /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 000000000..7dd5dbd4b --- /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 000000000..86bd585cf --- /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 000000000..ef21309e7 --- /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 000000000..ba3f74ad0 --- /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 000000000..b0e0c37aa --- /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 000000000..7d0b43f11 --- /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 000000000..e0c93db68 --- /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 000000000..ed702e332 --- /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 000000000..03995d998 --- /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 000000000..d4d37d3dc --- /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 000000000..114351b02 --- /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 000000000..c2cc95d50 --- /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 000000000..ed05b768a --- /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/director-midi.md b/docs/director-midi.md index 22d39c63b..7b5a06205 100644 --- a/docs/director-midi.md +++ b/docs/director-midi.md @@ -106,7 +106,7 @@ where the `midi` feature lives. **Leave Remote on the Output row off.** With it on, Live sends every mapped parameter's value back to the panel on the same CC, and the panel reads that position as a relative turn: the knob pins itself to one value and ticks - whenever you move it. The same applies to the USB port on the MIDI edition. + whenever you move it. The same applies to the USB port on the MIDI bundle. **Then, to send a lane:** diff --git a/docs/investigations/2026-10-build-map-additions.md b/docs/investigations/2026-10-build-map-additions.md new file mode 100644 index 000000000..1cc0330f2 --- /dev/null +++ b/docs/investigations/2026-10-build-map-additions.md @@ -0,0 +1,95 @@ +# What the build map could add + +*Scouted on 18 October 2026 at `c830467` on `dev`, from the journal, the live +site, the community Workshop and the remix folders. Nothing here is +implemented. [`builds.ts`](../../web/src/components/sections/InsideGlobe/builds.ts) +stays as it is until the photos and dates in the last column arrive.* + +## What a pin is now + +The map at [patternflow.work/inside](https://patternflow.work/inside) has 18 +pins in 12 countries. Each is one entry in `builds.ts`, with its photos in +`web/public/builds//`. An entry has a `kind` and a `category`, and they +answer different questions. + +The kind is what the pin claims. `build` is a Patternflow someone put +together, `collaboration` is a separate work made with Patternflow, and `sold` +is a unit sold as a work. The category is the filter it sits under: Builds, +Projects or In use. Fifteen pins are builds. Two are projects, day's Raspberry +Pi port and Azmano's MOTIFLOW. One is in use, the first sale in San Francisco. + +A pin's place is as precise as the maker made it: a city label gets the +city's coordinates, a country label the country's centre. Pins on one spot +draw as a single pin with a count +([`groups.ts`](../../web/src/components/sections/InsideGlobe/groups.ts)), so a +second pin in Seoul is no problem. The slug is maker then region, and a second +pin for the same maker in the same region needs a slug of its own +(`simonepda-uae` is taken). The date is a month. + +Only `build` pins join the web of lines between pins, the web that shows how +far Patternflow has spread (`webArcs` in +[`Globe.tsx`](../../web/src/components/sections/InsideGlobe/Globe.tsx)). A +collaboration or a sale gets one line back to the pin it came from. + +## A pin for a showing + +None of the three kinds fits an exhibition of a unit from Seoul. Filed as +`build`, the *Anbu* exhibition (A1) joins the spread web and counts as +Patternflow reaching somewhere new, when it is the maintainer's own unit a few +kilometres from the first pin. `collaboration` fits *Anbu* itself, because its +patterns were made from the artists' works. It does not fit the Hongdae night +(A2) or the course exhibition (A3). + +The smallest change is a fourth kind for a unit shown somewhere, drawn the way +`sold` is: its own pin shape, a line back to its origin, filtered under In use. +That touches `BuildKind` in `builds.ts` and `PinBody` in `Globe.tsx`, and it is +the maintainer's call. Without it, A1 to A3 can go in as `in-use` with kind +`build`, and the web gets three strands it should not have. + +## The maintainer's shows and use + +| | What | Seen at | On the map | Send | +| :--- | :--- | :--- | :--- | :--- | +| A1 | *Anbu* (「안부」), an exhibition by visually impaired artists at Modu Art Space near Seoul Station. Patternflow was not the exhibit. It was a corner where visitors played the artists' paintings, illustrations and sculptures as patterns: one knob picks the work, three play it, and the sound follows the knobs, through the maintainer's build of Simone's flowlocal and a laptop running Ableton. Opened 3 October 2026, with an opening event on Sunday the 4th. Taken down by 13 October. | Journal: [the-messy-desk](https://patternflow.work/journal/the-messy-desk) (the plan), [a-strange-feeling](https://patternflow.work/journal/a-strange-feeling), [all-jumbled-up](https://patternflow.work/journal/all-jumbled-up), [fine](https://patternflow.work/journal/fine) (on the site, not yet in this tree; it calls it 서울역 전시). [README](../../README.md) timeline, October 2026. | New pin, `in-use`, Seoul, 2026-10. Kind: see [A pin for a showing](#a-pin-for-a-showing). | Photos of it on show, with people playing if any were taken (the *fine* post has one, "서울역 전시, 철거 전"). The closing date. Whether to name the space, the exhibition and the artists, and their OK. How the pin credits it: Seunghun LEE, or the artists with him. The exhibition's own page, if it has one. | +| A2 | A DJ night in an LP bar in Hongdae, the day after the *Anbu* opening. A Patternflow with the microphone fitted stood by the turntables and played to the sound, the first time the audio feature was used outside home. | Journal: [all-jumbled-up](https://patternflow.work/journal/all-jumbled-up), announced in [how-to-use-part-two](https://patternflow.work/journal/how-to-use-part-two) as a café. The post's photo is in the tree: [`turntable.jpg`](../../web/public/journal/all-jumbled-up/turntable.jpg). | New pin, `in-use`, Seoul, 2026-10. | The date. The bar's name, or an OK to leave it as "Hongdae, Seoul". The DJ's OK and how to name them. The photos and video from that night, with the credit line the photographer wants. Which edition it ran (Audio, presumably). | +| A3 | The exhibition of the Hongik course 작가적 디자인 스튜디오(1), June 2026. The written process was pinned up behind the unit. The unit died on day one, and that is how the SMD parts came off the board. | Journal: [refocus](https://patternflow.work/journal/refocus), with [`exhibition-process.jpg`](../../web/public/journal/refocus/exhibition-process.jpg). Roadmap node `biz-exhibition` (2026-06-15) in [`roadmap-data.ts`](../../web/src/app/roadmap/roadmap-data.ts). | New pin, `in-use`, Seoul, 2026-06. | Where it was shown (building or gallery). The dates (the post has it running on 19 June and coming down on Monday the 22nd). One or two photos of the wall. An OK to tell the day-one failure on the pin. | +| A4 | *USER EXPERIENCE*, the pop-up gallery in San Francisco the first sale went to. The unit is shown hands-on, and the show runs through SF Tech Week. | Pin `sf-swartz`. Journal: [good-but](https://patternflow.work/journal/good-but), [a-strange-dream](https://patternflow.work/journal/a-strange-dream), [fine](https://patternflow.work/journal/fine). | The existing pin, not a new one. Its text says the show "opens in October 2026" and needs the past tense once photos go on. | Photos of the unit in the gallery. The show's dates. The gallery's OK to use them. | +| A5 | A workshop at the Seoul Design Startup Center, planned. Parts for seven or eight units are on order for it. | Journal: [all-jumbled-up](https://patternflow.work/journal/all-jumbled-up), [fine](https://patternflow.work/journal/fine). | After it happens: a new pin, `in-use`, Seoul. Each unit someone takes home finished can be its own `build` pin. | The date, the number of people, photos, and each participant's OK. Nothing until it has happened. | +| A6 | The unit that lives at the Seoul Design Startup Center office ("사무실에 1개, 집에 2개"). | Journal: [all-over-the-place](https://patternflow.work/journal/all-over-the-place). | New pin, `in-use`, Seoul, if a unit in daily use is worth one. | A photo of it in the office, and a yes or no on the pin. | + +## Units that went out + +| | What | Seen at | On the map | Send | +| :--- | :--- | :--- | :--- | :--- | +| B1 | A unit sent to a studio near Philadelphia. Its owner is helping as a kind of ambassador and is to demo it at the Grammy Creator Summit. | Journal: [all-jumbled-up](https://patternflow.work/journal/all-jumbled-up). | New pin, USA. Kind `build` if it was a gift (the Paris pin set that precedent), `sold` if it was paid for. | The studio's or person's name and their OK. The city. The month it was sent. Gift or sale. After the summit: its date and photos of the demo. | +| B2 | A unit sent to an influencer in Spain, in the hope of an Instagram post before the campaign closed. | Journal: [fine](https://patternflow.work/journal/fine) only. | New pin, Spain, kind `build` (a gift), once the post is up. | The name or handle and their OK. The city. The month it was sent. A link to the post. | +| B3 | The three units built in September with the money from the first sale. | Journal: [how-to-use-patternflow](https://patternflow.work/journal/how-to-use-patternflow). | One pin for each that left Seoul. Some may already be A1, B1 or B2. | Where each of the three is now. | + +## Builds by others + +| | What | Seen at | On the map | Send | +| :--- | :--- | :--- | :--- | :--- | +| C1 | SimonePDA's laser-cut case, cut three times in clear acrylic and in MDF. The pin `uae-simonepda` (July 2026) shows his earlier build in a dark printed case. | [`simonepda-lasercut/README.md`](../../hardware/case/remixes/simonepda-lasercut/README.md) (verified 2026-10-05), its tab on [/build](https://patternflow.work/build?case=simonepda-lasercut), the [README](../../README.md). Four photos in [`photos/`](../../hardware/case/remixes/simonepda-lasercut/photos/), CC BY-SA and already resized for the site in `web/public/cases/simonepda-lasercut/`. | New pin, `build`, `builds`, 2026-10, linking the remix folder and the /build tab. A second pin rather than more photos on the first: it is a different build, in a case anyone can cut. | Ajman or Dubai: the pin says Ajman, the journal ([how-to-use-part-two](https://patternflow.work/journal/how-to-use-part-two)) says Dubai. An OK to reuse the four remix photos, and a photo of it lit if he has one. The month of the first cut. A line from Simone, if he wants one. | +| C2 | Besoiobiy's printed case, the second enclosure remix: a frame and a box glued side by side, PATTERNFLOW lettered down the box. No pin. | [`besoiobiy-printed/README.md`](../../hardware/case/remixes/besoiobiy-printed/README.md) (verified 2026-10-08), its tab on [/build](https://patternflow.work/build?case=besoiobiy-printed). One photo of the built case, [`photo_built.jpg`](../../hardware/case/remixes/besoiobiy-printed/images/photo_built.jpg). | New pin, `build`, `builds`, 2026-10, linking the remix folder and the /build tab. | Where it is, as a city or a country: the README does not say, and an order on aliexpress.ru is not a location. How Besoiobiy wants to be named. Two or three more photos, lit. A line from them, and their OK. | +| C3 | @zhu0ran's bridge that streams a TouchDesigner TOP to the panel at 64 × 32 and sends the knobs back over OSC. | Community Workshop, Connect channel: [Touch Designer to Patternflow](https://community.patternflow.work/community/workshop/c0/t/f9562f0b4bdf4c3b), 9 August 2026. | New pin, `projects`, like day's port. If @zhu0ran is Zhuoran, the first collaborator who has the Paris unit, it goes in Paris and can hang off `paris-v1`. | Whether @zhu0ran is Zhuoran. A photo or clip of a TouchDesigner frame on the panel. The place. Their OK. | +| C4 | Simone's MQTT link, tested between his unit in Dubai and the maintainer's in Seoul: a knob turned in Dubai changed the panel in Seoul. With it, his flowlocal, a box that gives a Patternflow its own hotspot. *Anbu* ran on the maintainer's build of one. | Journal: [how-to-use-part-two](https://patternflow.work/journal/how-to-use-part-two), [the-messy-desk](https://patternflow.work/journal/the-messy-desk), with a photo of the maintainer's own flowlocal, [`flowlocal.jpg`](../../web/public/journal/the-messy-desk/flowlocal.jpg). | New pin, `collaboration`, `projects`, in Dubai, with its line back to Seoul. Better after flowlocal is published. | Whether flowlocal is public yet. Simone's OK. A photo of his box. The month. | +| C5 | The breadboard build: the same electronics on jumper wires, no board order. The guide's eight photos are its record. | [/build/breadboard](https://patternflow.work/build/breadboard), photos in [`web/public/builds/breadboard/`](../../web/public/builds/breadboard/) (the page is dated 28 June 2026). | New pin, `build`, `builds`, Seoul, 2026-06, linking the guide. | A yes or no: it is the guide's build, not a unit that went anywhere. Which photo leads (`step8.jpg`, "It's alive!", is the lit one). | +| C6 | Builds shared on Discord that have no pin. The journal counts about 50 builders from 64 panel orders, and about 10 who shared a build ([its-a-failure](https://patternflow.work/journal/its-a-failure), 1 September), and more have been posted since ([all-jumbled-up](https://patternflow.work/journal/all-jumbled-up)). Leads: cartoonmonkeystudio (CE), who debugged the Wi-Fi on their own unit in August ([the investigation](2026-08-the-panel-clock-and-the-wifi-radio.md)); a black Patternflow on an Instagram story on 11 July ([worn-out](https://patternflow.work/journal/worn-out), perhaps Enerjoy's); a two-layer tracing-paper diffuser on 29 May ([today-is-my-birthday](https://patternflow.work/journal/today-is-my-birthday), perhaps shooter's). | Discord, which this scout cannot read. | One `build` pin each. | For each build on Discord that has no pin: the name the maker wants, the place, the month, two or three photos and their OK. | + +## Pins that already exist and can take more + +| | What | Seen at | On the map | Send | +| :--- | :--- | :--- | :--- | :--- | +| D1 | MOTIFLOW has been shown in a gallery and played at several events since the pin went up. | [README](../../README.md) ("shown in a gallery and played at events since"), journal [how-to-use-part-two](https://patternflow.work/journal/how-to-use-part-two). | The pin `iran-azmano`: a sentence and a photo. | The gallery and the events, with dates, and a photo from Azmano. | +| D2 | mbchars' pin links nowhere, while his case is the third remix in this repo and a tab on /build. | Pin `ukraine-mbchars`, [`mbchars-horizontal-desktop-printed/`](../../hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md). | Two links on the pin: the remix folder and [/build?case=mbchars-horizontal-desktop-printed](https://patternflow.work/build?case=mbchars-horizontal-desktop-printed). | Nothing. It can go in with any of the rows above. | + +## Looked at and left out + +- Press: the 14 pieces in the roadmap's media lane, Hackster.io in May to Midifan in September. They are about Patternflow, not places it is. +- The Nam June Paik Art Center (28 January) and the Gwangju Biennale. He went to look. +- Paris Fashion Week, planned for the Paris unit. It was never installed ([all-jumbled-up](https://patternflow.work/journal/all-jumbled-up)). +- Pitches and open calls: the Seoul Design Startup Center, SFAC, KAMS, the Lumen Prize, Art Korea Lab. None of them put a Patternflow in front of visitors. +- The museums that asked to buy in July, and the new museum in Istanbul. No names, and nothing closed. +- The interview and shoot for the project film at the Seoul Design Startup Center ([fine](https://patternflow.work/journal/fine)). +- Crowd Supply units. They ship after the campaign, and each becomes a pin when its owner shares it, the usual way. +- The XR club exhibition and the 2023 prototyping-class exhibition. Neither showed a Patternflow. diff --git a/docs/investigations/2026-10-cleanup-baseline.md b/docs/investigations/2026-10-cleanup-baseline.md new file mode 100644 index 000000000..92c1ec5a3 --- /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 000000000..70ec2b07b --- /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 000000000..a4d45af07 --- /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 000000000..61049e6c6 --- /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 000000000..f03c4f83e --- /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 000000000..eb5e53589 --- /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 000000000..0017a3b3f --- /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 000000000..3765b95e5 --- /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 000000000..633325262 --- /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 ae0b0dbed..ff70dcc59 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/manifesto.md b/docs/manifesto.md index 4d576b15d..cca673252 100644 --- a/docs/manifesto.md +++ b/docs/manifesto.md @@ -335,7 +335,7 @@ These names are canonical. Use them exactly, capitalized as shown. | --- | --- | | **Patternflow** | The instrument, the project, and the ecosystem. One word, capital P only. A trademark of SeungHun Lee. | | **Live Editor** | The taster, embedded in the website. A virtual Patternflow — same knobs, same detents, no account, no install, no hardware. | -| **Pattern Lab** | The studio. A full creation tool: batch generation, color ramps, layering, custom knob frames, compile straight to firmware. | +| **Pattern Lab** | The studio. A full creation tool: layers of code, pixel art and text, color ramps, custom knob frames, compile straight to firmware. | | **Community** | The sharing platform. Publish, browse, fork, and load a pattern onto your own device. | | **Origin** | The pattern the device boots into, and the first work in the Patternflow series. | diff --git a/docs/midi-spec.md b/docs/midi-spec.md index 68e0124d7..f31ca293b 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 000000000..271f5751c --- /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 000000000..1cd64c29f --- /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 000000000..56eede142 --- /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 000000000..48caf6ab3 --- /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 000000000..15aec9fc2 --- /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 000000000..b13a0feaa --- /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 000000000..707c0a6ac --- /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 000000000..b3532ce6a --- /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 000000000..74173ff96 --- /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 000000000..71b403f6a --- /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 000000000..8b49e6bd3 --- /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 000000000..937eca170 --- /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 000000000..6e43938b2 --- /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 000000000..f315fa360 --- /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 000000000..b38e25389 --- /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 000000000..4112487d1 --- /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 000000000..b4aeed980 --- /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 000000000..dfa213a23 --- /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 000000000..3566af9c1 --- /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 000000000..37a1cc42f --- /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 000000000..dc90b33e1 --- /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 000000000..a8d9d00ca --- /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 000000000..c4316d54c --- /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 000000000..d4f284356 --- /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 000000000..2ed1ecbd1 --- /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 000000000..4ba87021a --- /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 000000000..31a0bfac3 --- /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 000000000..3171c2dd2 --- /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 000000000..301c4bbd7 --- /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 000000000..687c1b07b --- /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 000000000..6b232676e --- /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 000000000..30ec819cb --- /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 000000000..8c4522f15 --- /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 000000000..670286e7d --- /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 000000000..d5de0b48a --- /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 000000000..6ceaa4be7 --- /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 000000000..09f29507d --- /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 000000000..e227f91c9 --- /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 000000000..e8393700c --- /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 000000000..38215085c --- /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 000000000..625f4c4c6 --- /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 000000000..f61329f62 --- /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 000000000..99f722cd0 --- /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 000000000..614ea2667 --- /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 000000000..e704da4e2 --- /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 000000000..1e3d4f999 --- /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 000000000..a3eb111ba --- /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 000000000..1dff2f2ce --- /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 000000000..21fdd2d2a --- /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 000000000..ca2aa603d --- /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 000000000..bf36b63c1 --- /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 000000000..7099e3294 --- /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 000000000..a0752f2d6 --- /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 000000000..599220fb9 --- /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 599220fb9..f6299efcd 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 000000000..ef7271f95 --- /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 ef7271f95..553028275 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 000000000..79c372ec5 --- /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 000000000..9eb3c05f0 --- /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 000000000..9dab58e7b --- /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 000000000..f4a2cb59d --- /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 000000000..407d171e2 --- /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 000000000..2b92dd8f9 --- /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 000000000..4ad258a27 --- /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 000000000..9a357027f --- /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 000000000..9b22f6fae --- /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 000000000..44cb57031 --- /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 000000000..517eef8a1 --- /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 000000000..cb1b75d70 --- /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 000000000..501b59cb5 --- /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 000000000..4fdb8e994 --- /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 000000000..3397e484f --- /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 000000000..7ef860662 --- /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 000000000..27f33f787 --- /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 000000000..d448304fe --- /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 000000000..5d642d57d --- /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 000000000..bd76ae67a --- /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 000000000..eb14bd669 --- /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 000000000..0f2dca818 --- /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 000000000..e8249de70 --- /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 000000000..525d35887 --- /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 000000000..d9ee0f458 --- /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 000000000..3cc4f5d5d --- /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 000000000..50a50e9e1 --- /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 000000000..ab55dec38 --- /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 000000000..2d0ac0727 --- /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 000000000..84e71a92a --- /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 000000000..80bbec28b --- /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 000000000..c9d82972e --- /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 000000000..16254fc18 --- /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 000000000..5f0039c92 --- /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 000000000..7de00efc7 --- /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 000000000..8e95976ee --- /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 000000000..f4d580769 --- /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 000000000..0b7d2e501 --- /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 000000000..1d61cd377 --- /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 000000000..a59190aec --- /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 000000000..8a8a8686e --- /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 000000000..76618bc48 --- /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 000000000..5bc888d4f --- /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 000000000..3809b9f27 --- /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 000000000..85f5214f3 --- /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 000000000..a2b1de4cb --- /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 000000000..11545bf9f --- /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 000000000..8963ab1f3 --- /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 000000000..467a3081a --- /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 000000000..b2e21522d --- /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 000000000..835d68aa9 --- /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 000000000..5037a54a7 --- /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 000000000..1fd06945b --- /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 000000000..5fa3568c1 --- /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 000000000..0820ee270 --- /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 000000000..072f5e189 --- /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 000000000..e0f15339b --- /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 000000000..1de3b29ce --- /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 000000000..9ed8dcb4e --- /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 000000000..5ab7153cb --- /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 000000000..9a6617a78 --- /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 000000000..18777bd60 --- /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 000000000..210c18868 --- /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 000000000..41e80de72 --- /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 000000000..5512e0498 --- /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 000000000..3dcbe1a19 --- /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 000000000..6c104f822 --- /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 000000000..0f147d12b --- /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 000000000..ab8dd6f23 --- /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 000000000..ef7fd256b --- /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 000000000..0c64b3abc --- /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 000000000..88c3abff0 --- /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 000000000..f4876ca5f --- /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 000000000..65f6e997b --- /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 000000000..56452b014 --- /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 000000000..05679d594 --- /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 000000000..1d30f9f58 --- /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 000000000..5abea2fc8 --- /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 9aafd1455..9c2bc8f79 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 000000000..69453e135 --- /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 000000000..3cfb00d15 --- /dev/null +++ b/docs/rfc-cleanup-progress.md @@ -0,0 +1,294 @@ +# 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: steps 0 to 5 done, and the gate holds.** 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 | done ([#23](https://github.com/Patternflow-lab/Patternflow/pull/23); plan: [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 | done ([#37](https://github.com/Patternflow-lab/Patternflow/pull/37); plan: [fourteen PRs](investigations/2026-10-cleanup-step2-plan.md)) | +| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | done ([#68](https://github.com/Patternflow-lab/Patternflow/pull/68); plan: [twenty-seven PRs](investigations/2026-10-cleanup-step3-plan.md)); k12 has eight upstream commits to go | +| 4 | Test spine: contract vectors ×5, virtual panel | done ([#92](https://github.com/Patternflow-lab/Patternflow/pull/92); plan: [twenty-four PRs](investigations/2026-10-cleanup-step4-plan.md)) | +| 5 | RFC drafts: any-resolution, Linux player | done ([#55](https://github.com/Patternflow-lab/Patternflow/pull/55); plan: [four PRs](investigations/2026-10-cleanup-step5-plan.md)) | + +Gate for any-resolution (§2.12): met at `9dff573` on 16 October 2026. Each +item is checked under [The gate](#the-gate). + +--- + +## 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. + +## Step 1 — document structure + +Seven PRs, 14 and 15 October 2026, in the three lanes of +[investigations/2026-10-cleanup-step1-plan.md](investigations/2026-10-cleanup-step1-plan.md): +A1 [#13](https://github.com/Patternflow-lab/Patternflow/pull/13), B1 +[#14](https://github.com/Patternflow-lab/Patternflow/pull/14), C1 +[#15](https://github.com/Patternflow-lab/Patternflow/pull/15), A2 +[#16](https://github.com/Patternflow-lab/Patternflow/pull/16), C2 +[#17](https://github.com/Patternflow-lab/Patternflow/pull/17), A3 +[#22](https://github.com/Patternflow-lab/Patternflow/pull/22), and C3 +[#23](https://github.com/Patternflow-lab/Patternflow/pull/23) to close it. +`docs/voice.md` and `docs/voice-banned.txt` came in beside them +([#18](https://github.com/Patternflow-lab/Patternflow/pull/18)), the step's +first prose PR, as answer 6 asks. + +The root holds the twelve files of answer 1 and the dotfiles. +`BUILD_GUIDE_v2.md` is `docs/legacy/BUILD_GUIDE_v2.md`, with no redirect: the +site's one link to it goes to github.com. `AUDIO_GUIDE.md` is +`docs/AUDIO_GUIDE.md`. `CODE_OF_CONDUCT.md`, `SECURITY.md` and `SUPPORT.md` +went to `.github/` as answer 1 says, where the plan's D5 had put them in +`docs/`. The README's story sections stay where they are (answer 2), so A3 +merged as an empty commit. + +`CHANGELOG.md` keeps the three newest releases in full and links the rest. +Each of the 30 releases back to v1.0.0 has its own file in `docs/releases/`, +and the two long-form notes already there became `v3.0.0-notes.md` and +`v3.1.0-notes.md` (D1). `release.py cut` writes the next file. It also stops +when the section leaving the root differs from its file, a check the plan did +not ask for. + +`npm run doc-map` also writes +[investigations/2026-10-doc-parity.csv](investigations/2026-10-doc-parity.csv). +None of the three pair kinds §2.3 names exists, so the list reads the pairs +the repository does keep: journal posts, the guide's routes and copy, and the +roadmap's and the atlas's fields. The map reads the YAML under `.github/` as +well (C2). Questions 7 to 11 have no answer, and each lane took the plan's +default. + +At [#23](https://github.com/Patternflow-lab/Patternflow/pull/23) the root is +as answer 1 says and `docs-links.yml` is green. The map has 1066 links out of +212 files, every one resolving. The parity list has 1366 pairs: 1340 ok, 18 +ko-short, 7 en-short and one missing-ko. No translation was done; the list is +the deliverable. + +## Step 2 — drift checks + +Thirteen PRs, 15 October 2026: +[#25](https://github.com/Patternflow-lab/Patternflow/pull/25) to +[#36](https://github.com/Patternflow-lab/Patternflow/pull/36) as cut by +[investigations/2026-10-cleanup-step2-plan.md](investigations/2026-10-cleanup-step2-plan.md), +and [#37](https://github.com/Patternflow-lab/Patternflow/pull/37) to close it. +k14 was not opened. It waited on drift in the roadmap check's first CI run, +and [that run](https://github.com/Patternflow-lab/Patternflow/actions/runs/38665590179) +found none. + +`npm run check:drift` in `web/` runs each file in `web/scripts/drift/`. At the +close there were eight: the seven of §2.4 and `check:changelog`, which step 1 +sent here. `check:ci` ends with it. `.github/workflows/drift.yml` runs it on +every pull request with `PF_DRIFT_STRICT=1`, so a side a check cannot read +fails there instead of skipping, and runs `check:human-lock` against the base +commit (§2.9). `check:voice` (§2.10) is run by hand, by question 14's +default. `AGENTS.md` ends with the §2.10 rule and has a line for each command +([#36](https://github.com/Patternflow-lab/Patternflow/pull/36)). + +`check:bom-schematic` finds the BOM's nine designators in the schematic's text +layer, so it was strict from its first day and the warn-only clause of §2.4 +went unused. `check:roadmap` reads the repository from `roadmap-data.ts`, +`engmung/Patternflow`, and its list of issues open on purpose is empty +(question 12). The lock reads Part 1 of every `docs/rfc-*.md` rather than a +fixed list, so step 5's drafts were locked the day they landed. Two handoffs +from the step never landed and are still open at `9dff573`. A +`links-absolute` skip exits 0, so at a desk the runner prints it as passed +(`web/scripts/doc-map.ts` line 260); in CI strict mode fails it, as it should. +And `web/README.md` has no line for `check:voice`. + +At [#37](https://github.com/Patternflow-lab/Patternflow/pull/37) all eight +pass strict in CI ([run 38671531965](https://github.com/Patternflow-lab/Patternflow/actions/runs/38671531965)). +`check:roadmap` read 14 issues, every one closed and every one on a `done` +node. The lock holds 74 journal files, 33 release files, the manifesto, the +README's three story sections, Part 1 of two RFCs and 166 roadmap strings. +`docs/.human-written` lists no path (answer 5). The doc map has 1072 links out +of 214 files, every one resolving, and the parity list is unchanged. + +## Step 3 — code hygiene + +Twenty-seven PRs, 15 October 2026, as cut by +[investigations/2026-10-cleanup-step3-plan.md](investigations/2026-10-cleanup-step3-plan.md): +k1 to k26 between [#38](https://github.com/Patternflow-lab/Patternflow/pull/38) +and [#67](https://github.com/Patternflow-lab/Patternflow/pull/67), and +[#68](https://github.com/Patternflow-lab/Patternflow/pull/68) to close it. +#40, #50, #51 and #55 in that range are step 5's. + +`npm run lint` went from the baseline's 12 warnings to none. Five were fixed +in the code. The seven `` tags each carry a one-line exception with its +reason, and `web-ci.yml` runs lint with `--max-warnings=0`. `typecheck` was +clean and stayed clean. Every legacy path the plan found now says what wrote +it or what links to it, with the date and the commit. Code with no reader +went, `saveDraft` among it. The rest stays until questions 18 and 19 have an +answer. + +`shared/constants.json` holds the values §2.5 named, the 24 detents a turn +among them. `npm run gen:shared` writes `core_shared.h` and `constants.ts` +from it, and the firmware and the site read those. `check:shared` runs in +`check:ci` and in `drift.yml`. The MIDI page counted 20 detents a turn and +counts 24 now ([#46](https://github.com/Patternflow-lab/Patternflow/pull/46)), +a fix reviewed as one. The `markers` check holds first-party code at no +`TODO` or `FIXME`. The five markers §2.5 counts are not in this repository +(question 16). Each web dependency family moved to its newest release inside +its major on the day its PR opened. Next went from 16.3.0 to 16.4.1, which +closed nine security advisories, and React from 19.2.4 to 19.3.1. Majors wait +(answer 4). + +`src/hub75/UPSTREAM.md` names upstream's tag 3.0.13 and its commit `a622186`, +and `patternflow.patch` holds every line Patternflow changed, in 15 hunks +([#47](https://github.com/Patternflow-lab/Patternflow/pull/47)). k12 is where +the step parts from its plan. The plan gives each upstream commit after the +tag that touches no hunk a PR of its own, with a flash-and-look on the +reference panel that the maintainer notes in the PR (§2.5). #47 found nine +such commits. One merged: +[#48](https://github.com/Patternflow-lab/Patternflow/pull/48) takes `7bf3b6c`, +which sets `line_decoder` in the config constructor. At 3.0.13 nothing sets +that field, and `c09362a` reads it to address every row, so `7bf3b6c` went +first, ahead of older commits. Its flash-and-look is not noted on #48 yet. +The other eight have no PR: `c09362a`, `538cb41`, `3ab92ad`, `b40a838`, +`203502b`, `a15a887`, `6a49f09` and `c005c7b`, in that order. Each needs the +maintainer at the panel. §2.5's "done when" asks for `UPSTREAM.md` and the +patch, not for the merges, so #68 closed the step with k12 open, and the gate +does not wait on it. + +At [#68](https://github.com/Patternflow-lab/Patternflow/pull/68) `check:ci` +passes its 26 suites and `check:panels` its 478 tests. Lint and typecheck are +clean. `drift.yml` passes ten checks strict +([run 38734640534](https://github.com/Patternflow-lab/Patternflow/actions/runs/38734640534)). +The doc map has 1095 links out of 217 files, every one resolving. + +## Step 4 — the test spine + +Twenty-four PRs, 15 and 16 October 2026: +[#69](https://github.com/Patternflow-lab/Patternflow/pull/69) to +[#91](https://github.com/Patternflow-lab/Patternflow/pull/91) as cut by +[investigations/2026-10-cleanup-step4-plan.md](investigations/2026-10-cleanup-step4-plan.md), +and [#92](https://github.com/Patternflow-lab/Patternflow/pull/92) to close it. +Every row of the plan has its PR, and each stayed inside its files. k12 +([#82](https://github.com/Patternflow-lab/Patternflow/pull/82)) went past its +row: besides the channels, the snapshot and `/api/mqtt`, its 13 vectors cover +the prefix that names a channel and the Publisher's eight-second heartbeat. + +`tools/virtual-panel/` is a Patternflow with no board. It is the guide's +`DeviceSim` behind the ports the firmware listens on, and patterns run on +`PatternRuntime`, the one the site runs them on. `web/src/lib/harness/`, the +folder §2.6 names, does not exist, and `PatternRuntime` stands in for it. +Eight files in `transports/` speak the five wire contracts, each the subset +its spec names, on Node's own sockets. The lockfile did not move. +`GET /frame.png` is the frame as the LEDs would show it, and `/vp/` is a hand +and the clock. The firmware has neither. + +The five vector folders hold 111 vectors: 53 for REST, 15 for MQTT, 11 for +OSC, 20 for MIDI and 12 for the audio socket. Each is a JSON file that quotes +one sentence of its spec, in the format [contract-vectors.md](contract-vectors.md) +gives. A suite starts a fresh panel for each vector on a clock that stands +still between steps, so a 500 ms release costs no real time. +`check:virtual-panel` runs the 34 presets that have a JS twin for two seconds +of panel time each. `calib` is firmware-only and is left out. The six suites +are files in `web/scripts/drift/`, so `check:ci` and `drift.yml` run them. + +The vectors follow the spec. Where the spec said two things, +[#91](https://github.com/Patternflow-lab/Patternflow/pull/91) made it say one, +in ten lines of `rest-api.md` and `midi-spec.md`, by question 27's default: +the route's own section wins. Nobody has ruled on those lines yet. Where the +firmware disagrees with its spec, the vector keeps the spec and a real panel +would fail it. #92 counts 18 such places, 11 of them in vectors. Each PR lists +its own under "for an issue" with the spec line and the firmware line, and +question 26 leaves opening the issues to the maintainer. Nothing under +`firmware/` changed in this step. Questions 26 to 29 have no answer, and every +PR took the default. Web CI starts only on `web/`, so a PR that edits only +`tools/virtual-panel/` is typechecked at a desk and nowhere else. + +At [#92](https://github.com/Patternflow-lab/Patternflow/pull/92) `check:ci` +passes its 26 suites and `check:panels` its 487 tests. Lint and typecheck are +clean. `drift.yml` passes 16 checks strict +([run 38814004535](https://github.com/Patternflow-lab/Patternflow/actions/runs/38814004535)). +The doc map has 1138 links out of 224 files, every one resolving. + +## Step 5 — RFC drafts + +Four PRs, 15 October 2026, as cut by +[investigations/2026-10-cleanup-step5-plan.md](investigations/2026-10-cleanup-step5-plan.md): +[#40](https://github.com/Patternflow-lab/Patternflow/pull/40), +[#50](https://github.com/Patternflow-lab/Patternflow/pull/50), +[#51](https://github.com/Patternflow-lab/Patternflow/pull/51), and +[#55](https://github.com/Patternflow-lab/Patternflow/pull/55) to close it. The +step ran beside steps 3 and 4, as §2.12 allows once step 2 is merged. + +[rfc-any-resolution.md](rfc-any-resolution.md) and +[rfc-linux-player.md](rfc-linux-player.md) are Part 2 only, 298 lines each, +with facts from `dev` at `adbe923`. Part 1 is an empty heading in both, left +for the maintainer. Each sets out the order of the work as a table of PRs, +then ends with its open questions: six in the any-resolution draft, nine in +the player's. Both have a line in `docs/README.md`, and each links the other. +[investigations/2026-10-panel-geometry.md](investigations/2026-10-panel-geometry.md) +is the record of every place the panel size is set or assumed, by file and +line, and the any-resolution draft carries a table per file from it +(question 31). + +`check:human-lock` reads Part 1 of every `docs/rfc-*.md`, so both drafts were +locked from the start, and the plan's finding 11 needed no fix. Questions 30 +to 34 have no answer. At [#55](https://github.com/Patternflow-lab/Patternflow/pull/55) +the doc map has 1092 links out of 217 files, every one resolving. + +## The gate + +Checked on 16 October 2026 at `9dff573`, the merge of +[#92](https://github.com/Patternflow-lab/Patternflow/pull/92). The web +commands ran in a cloud container on that tree, Node 22.22.0 and npm 10.9.4 +after `npm ci`, as at the baseline. CI ran on `2892326`, the first commit of +the PR that adds this entry. It is `9dff573` plus an empty commit, with the +same tree. + +1. Every command in §2.1(1) passes. `npm run check:ci` passes its 26 suites in + 82 s. Its drift step passes 15 checks and skips `roadmap`, which needs a + `gh` login this container does not have; CI runs it in item 2. + `check:panels` passes 35 test files and 487 tests. `typecheck` prints + nothing, and so does `lint`, with `--max-warnings=0` as Web CI runs it. + `firmware/bundles/build.sh all` ran on GitHub Actions, as at the baseline: + [Firmware builds run 38815552568](https://github.com/Patternflow-lab/Patternflow/actions/runs/38815552568), + dispatched on `2892326`, built default, audio, performance, clock and midi, + scanned each image for its features, and found every edition within + tolerance. The last commit to touch `firmware/` is + [#59](https://github.com/Patternflow-lab/Patternflow/pull/59). +2. Every script in §2.4, §2.5 (`check:shared`), §2.6 and §2.9 runs in CI and + passes. + [Drift checks run 38815274649](https://github.com/Patternflow-lab/Patternflow/actions/runs/38815274649) + on `2892326` passes 16 checks with `PF_DRIFT_STRICT=1`: the seven of §2.4 + (`roadmap` reading `engmung/Patternflow`), `shared`, the five vector suites + and `virtual-panel`, and `changelog` and `markers` besides. Its next step, + `check:human-lock` against `9dff573`, says no locked words changed. +3. The virtual panel speaks REST (`rest-state.ts`, `rest-patterns.ts`) and + MQTT (`mqtt.ts`, `mqtt-http.ts`), and OSC, MIDI and the audio socket + besides. In the same run `check:virtual-panel` finds the 34 presets with a + JS twin on the panel's list and runs each for 2000 ms at 128×64 with no + error. `calib`, the 35th header in `firmware/patternflow/presets/`, is + firmware-only by `check_presets.py`. +4. The doc map has no unresolved row. `npm run doc-map` reads 1138 links out + of 224 files at `9dff573`, and 0 do not resolve. The map committed with + this entry and the new status line in `rfc-cleanup.md` reads 1144 out of + 225, and 0 do not resolve. +5. The root holds `AGENTS.md`, `BUILD_GUIDE.md`, `CHANGELOG.md`, + `CITATION.cff`, `CLAUDE.md`, `CONTRIBUTING.md`, `FEATURE_GUIDE.md`, + `LICENSE`, `LICENSE-CC-BY-SA`, `LICENSE-MIT`, `PATTERN_GUIDE.md`, + `README.md` and the dotfiles, the list in answer 1. +6. `docs/rfc-any-resolution.md` and `docs/rfc-linux-player.md` exist (step 5). + +All six hold. §2.12 lets `rfc-any-resolution.md` move from draft to proposed +now. The move is the maintainer's, and its status line still says "Draft". diff --git a/docs/rfc-cleanup.md b/docs/rfc-cleanup.md new file mode 100644 index 000000000..e787caebd --- /dev/null +++ b/docs/rfc-cleanup.md @@ -0,0 +1,387 @@ +# RFC: cleanup before expansion + +*Tidying the tree before any-resolution work starts. Written 2026-10-14.* + +> **Status, 16 October 2026.** Done. 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) +> held at `9dff573`, and [rfc-cleanup-progress.md](rfc-cleanup-progress.md) +> records each step and the check of each item. + +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 000000000..d0e02a015 --- /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 000000000..e0a9a4c62 --- /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 000000000..b4da5b23f --- /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/CUSTOM_PATTERNS.md b/firmware/CUSTOM_PATTERNS.md index 02c0ae85d..b4af51ff2 100644 --- a/firmware/CUSTOM_PATTERNS.md +++ b/firmware/CUSTOM_PATTERNS.md @@ -2,16 +2,19 @@ Patternflow includes a workflow for creating patterns without writing low-level rendering code. Two paths converge in the same place — the Live Editor at [patternflow.work](https://patternflow.work) → **Pattern** section → **Live Editor**. -- **AI-assisted** — describe the pattern in plain language, paste the AI's output into the editor, tune knobs, copy to C++, flash. No shader knowledge required. +- **AI-assisted** — describe the pattern in plain language, paste the AI's output into the editor, tune knobs, copy to C++, send it to the board. No shader knowledge required. - **Direct** — write the JavaScript pattern by hand in the editor, then convert to C++. For anyone comfortable with fragment-shader-style code. -> **New: you no longer need the Arduino IDE.** The last step — turning a pattern into firmware and getting it onto the board — now runs in the browser: the server compiles, your browser flashes over USB. See [step 5](#5-convert-to-c-and-flash). A local build is still the route for firmware development itself; that is PlatformIO through `firmware/bundles/build.sh`, not the Arduino IDE (the IDE cannot build the editions' library set). - -> **Newer still: patterns can install without any flashing at all.** On firmware -> with loadable-module support, the same `.h` builds into a tiny `.pfm` module -> that installs over Wi-Fi from the device's `/patterns` page — a few KB, no -> reflash, no reboot, unlimited slots. The community cart does this end to end. -> Details: [firmware/README.md → Loadable pattern modules](README.md#loadable-pattern-modules-pfm). +> **No Arduino IDE, no flashing, no cable.** The last step — getting a pattern +> onto the board — runs in the browser: a server compiles the `.h` into a +> small `.pfm` module (a few KB) and your browser hands it to the board over +> Wi-Fi, where it appears in the list with no reflash and no reboot (up to 128 +> of them). See [step 5](#5-convert-to-c-and-flash); a community pattern's +> **Send to my Patternflow** does the same end to end. Details: +> [firmware/README.md → Loadable pattern modules](README.md#loadable-pattern-modules-pfm). +> A local build is still the route for firmware development itself; that is +> PlatformIO through `firmware/bundles/build.sh`, not the Arduino IDE (the IDE +> cannot build the editions' library set). The 5-step path below is the AI-assisted route. The direct route reuses the same editor; skip to [Direct coding](#direct-coding-no-ai) if you don't need AI help. @@ -119,7 +122,7 @@ Drop the generated header file into the firmware folder: firmware/patternflow/ ├── patternflow.ino ├── config.h ← hardware pins / display / limits / LED calibration -├── net_config.h ← Wi-Fi / OTA defaults (+ feature tuning defaults) +├── net_config.h ← Wi-Fi / Improv / OTA / hotspot defaults (core only) ├── patternflow_secrets.example.h ← copy to patternflow_secrets.h for credentials ├── pattern_registry.h ← edit this ├── _TEMPLATE.h ← starting point for a new preset diff --git a/firmware/README.md b/firmware/README.md index 3b89cced9..6f5e310fe 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -12,6 +12,7 @@ This file is long because it is the firmware's reference. Most people need one p | you want to | read | | --- | --- | +| put Patternflow on a new board and get it on Wi-Fi | not this file: [`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware), or the Play guide's [Flash chapter](https://patternflow.work/guide/play#flash). The short version is [First flash and Wi-Fi](#first-flash-and-wi-fi) | | build and flash the firmware locally | [Setup](#setup) — the board package, PlatformIO, the libraries | | write a feature or cut an edition | [`docs/EDITIONS.md`](../docs/EDITIONS.md) first, then [Adding features — the constraints that matter](#adding-features--the-constraints-that-matter) here | | write a pattern | [`PATTERN_GUIDE.md`](../PATTERN_GUIDE.md) (nothing here is needed); the hands-on route is [`CUSTOM_PATTERNS.md`](CUSTOM_PATTERNS.md) | @@ -34,6 +35,48 @@ patterns and settings intact. feature is, how to write one, how to cut an edition, and the promises every build keeps so switching stays safe. Read it before writing a feature. +## First flash and Wi-Fi + +Nothing on this page is needed to put the released firmware on a board: the +browser does it. The full walk-through is +[`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware) and, on a board you can +turn, [patternflow.work/guide/play#flash](https://patternflow.work/guide/play#flash). +In order: + +1. The DevKit **out of the board**, on its **left** USB-C port (`USB`, the + native one), with a cable that carries data. Desktop Chrome or Edge. The + port is for flashing and data only — the panel is powered through `J4`, + the screw terminal, and nothing else. +2. **Download mode, every first flash:** hold **BOOT**, tap **RST**, let go + of BOOT. The port then shows as `USB JTAG/serial debug unit`. +3. **Flash Patternflow** (the Pattern tab on patternflow.work, or the guide), + pick that port, *Install Patternflow*, confirm. About two minutes. From + download mode the flasher cannot tell a board already runs Patternflow, + so it erases the whole flash, patterns and saved Wi-Fi included — nothing + to lose on a new build; a running board updates over Wi-Fi instead (see + [Wireless update from the browser](#wireless-update-from-the-browser)). +4. **The Wi-Fi step usually does not appear** after an install from download + mode, because the board does not restart into Patternflow by itself + ([`core_improv.h`](#core_improvh--patternflowimprov) can only answer once it + runs). Expected, not a failure: press **RST** once, press **Flash + Patternflow** again, pick the port, and the dialog opens on Patternflow's + own menu. *Connect to Wi-Fi*; after about 10 s of "Scanning" (the firmware + sends no list) type the network's name exactly and its password. 2.4 GHz + only. +5. **Or skip the flasher for Wi-Fi.** Seat the DevKit and power the panel: + about 15 s after it finds no network it knows (`PF_HOTSPOT_AUTO_AFTER_MS`), + it raises its own, `patternflow-xxxx` (the last two MAC bytes), password + `patternflow` ([`core_hotspot.h`](#core_hotspoth--patternflowhotspot)). + Hold **K2** and the NETWORK screen shows `HOTSPOT` and the name. Not in + the phone's list yet? It only exists from about 15 s after power-on: + wait, refresh the list, and match the exact name on K2. Join it (the phone + says "no internet", which is true; stay on it), open + `http://192.168.4.1/`, and pick your network on the Wi-Fi page. + +Once it is on your network, hold **K2** for its address; the console is at +`patternflow.local` (on Android, type the IP). A new board runs Origin only +— the Basics pack installs from the decks page over Wi-Fi. + ## Setup ### Required board package @@ -113,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/`. @@ -152,6 +198,7 @@ firmware/ │ │ ├── core_sleep.h # Sleep mode: panel off, board idle, still on the network │ │ ├── core_wifi.h # Multi-network Wi-Fi (up to 5 saved, tried in order) │ │ ├── core_improv.h # Improv-Serial provisioning from the browser flasher +│ │ ├── core_hotspot.h # The panel's own Wi-Fi network when no saved one answers │ │ ├── core_http.h # The one port-80 WebServer every page registers on │ │ ├── core_net_task.h # The network task on Core 0: Wi-Fi, HTTP, Improv, OTA │ │ ├── core_loop_sync.h # Hands a handler's frame-touching work to the loop task @@ -167,7 +214,7 @@ firmware/ │ │ ├── pf_feature.h # The hook interface a feature implements │ │ ├── pf_features.h # The dispatcher │ │ ├── features.h # The default list (empty) and the escape hatch -│ │ ├── osc/ audio/ audio_in/ midi/ ble/ mqtt/ show/ weather/ +│ │ ├── osc/ audio/ audio_in/ midi/ ble/ mqtt/ show/ weather/ clock/ │ │ └── README.md # What a feature is and the hooks it can answer │ ├── console/ # The device's web pages as plain HTML — edit these │ └── toolchain/ # PlatformIO extra_scripts (must stay sketch-relative) @@ -339,7 +386,7 @@ PatternflowOta::handle(); // first line of loop() — UDP poll, ~free when idle Shares Wi-Fi credentials and connection with OSC (if both are enabled, the connection is reused). When `PF_OTA_ENABLED` is 0 everything compiles to a no-op. See the [OTA Updates](#ota-updates-for-developers) section below for the user-facing workflow. ### `core_improv.h` — PatternflowImprov -Self-contained [Improv-Serial](https://www.improv-wifi.com/serial/) implementation so the **browser flasher** (ESP Web Tools, behind the website's "Flash" button) can set Wi-Fi over USB right after flashing — no rebuild, no `patternflow_secrets.h`. The main sketch only needs: +Self-contained [Improv-Serial](https://www.improv-wifi.com/serial/) implementation so the **browser flasher** (ESP Web Tools, behind the website's **Flash Patternflow** button) can set Wi-Fi over USB once the firmware is running — no rebuild, no `patternflow_secrets.h`. The main sketch only needs: ```cpp PatternflowImprov::begin(); // in setup() — announces it speaks Improv @@ -348,13 +395,19 @@ PatternflowImprov::handle(); // in loop() — drains Serial, drives provisioni The flasher sends the SSID/password over serial; `core_wifi.h` stores them in NVS (separate `pf_wifi` namespace) and uses them in preference to the built-in `PF_WIFI_SSID/PASS` placeholders on every boot. Shares the debug USB Serial — the host parser scans for the `IMPROV` header and ignores `println()` noise. Compiled in only when Wi-Fi is actually used (one of OTA/OSC/audio enabled) and `PF_IMPROV_ENABLED` is 1; otherwise a no-op. Set `#define PF_IMPROV_ENABLED 0` in `patternflow_secrets.h` to drop it. +The flasher can only ask for Wi-Fi once Patternflow is running and answering on that port. After an install from download mode (BOOT + RST) the board usually does not restart into Patternflow by itself, so the flasher's dialog ends at *Installation complete!* with no Wi-Fi step; pressing RST and opening the flasher again brings up Patternflow's own menu with *Connect to Wi-Fi*. The flasher waits up to 20 s for that answer after an install (`new_install_improv_wait_time` in `web/public/flash/manifest.json`), and a join is given up on after 30 s. + +### `core_hotspot.h` — PatternflowHotspot +The panel as a network of its own, so Wi-Fi can be set up without the flasher, and a panel can be used where there is no network at all. Core, not a feature: every image since 3.10.5 has it. In the default `auto` mode, once the station has had no link for `PF_HOTSPOT_AUTO_AFTER_MS` (15 s) the panel raises a WPA2 access point named `patternflow-xxxx` (the hostname plus the last two bytes of its MAC, `patternflow-a1b2`), password `PF_HOTSPOT_PASS` (`patternflow`) until it is changed on `/wifi`, and the whole console answers at `http://192.168.4.1/`. The NETWORK screen (hold K2) shows `HOTSPOT` and the name while it is what there is. On the Wi-Fi page the network-name field offers the networks the hotspot's channel scan saw, so a phone user picks one instead of typing a case-sensitive name; the panel then tries it at once and reports on the page and on its LEDs whether it joined. The hotspot answers every DNS name with the panel and fails the phone's internet probe at once, so the phone says "no internet" — true — and stays connected. Modes `off` / `auto` / `always` are kept in NVS and set on `/wifi` or `POST /api/hotspot` ([`docs/rest-api.md`](../docs/rest-api.md)). The header comment records what the bench decided and why, line by line. + ## Patterns The pattern list has two tiers: -- **Presets** — the curated showcase in `presets/`, compiled into `firmware.bin`. - Pattern 1 is always `Origin`. They work whether or not the filesystem mounts, - and switching between them is instant. +- **Presets** — compiled into `firmware.bin`. Today that is `Origin` alone, + pattern 1, the failsafe; the rest of the curated set in `presets/` ships as + the Basics pack. A preset works whether or not the filesystem mounts, and + switching to one is instant. - **Modules** — `.pfm` files on the FATFS partition, discovered at boot and appended after the presets. This is how new patterns normally arrive now: a ~3–22 KB file installed over Wi-Fi at `http://patternflow.local/patterns`, @@ -438,7 +491,7 @@ surface — the math headers are literally the same files, included with `-DPF_MODULE_BUILD`. **Install paths**, easiest first: -- Community cart → "Send over Wi-Fi": opens `/patterns?src=`, and +- A community deck or pattern page → "Send over Wi-Fi" (or a deck's "Install to my board"): opens `/patterns?src=`, and the page fetches and installs every file itself. Nothing downloaded. - Drag any number of `.pfm`/`.json` files onto `/patterns` — a visible queue uploads them one by one with progress, retries, and per-file results. @@ -785,9 +838,9 @@ For the original defaults: ### Longpress actions - **Encoder 1 longpress (≥1s)** — enter/exit global brightness mode. While active, K1 rotation adjusts panel brightness (5–255, ~5 per detent), the active pattern does not see K1 input, and a "BRIGHTNESS XX%" overlay shows the current level. Exits on a second longpress or after 5 seconds of idle. Value persists across reboots via NVS. -- **Encoder 2 longpress (≥1s)** — enter/exit the NETWORK screen (portrait status view: Wi-Fi state, local IP, OSC on/off and its remote host/port, audio-react on/off). Inside the screen, settings are toggled by **knob rotation, not clicks** — right = ON, left = OFF — so that holding K2 to leave can't flip anything on the way out: - - **turn K2** → OSC send/receive on/off (persists in NVS, so the device reboots into the same state). Wi-Fi stays connected either way; the toggle only enables/disables traffic. If the firmware was built with `PF_OSC_ENABLED 0` the row still shows but the toggle is inert ("REBUILD WITH PF_OSC_ENABLED=1"). - - **turn K3** → audio-react on/off (also persisted). +- **Encoder 2 longpress (≥1s)** — enter/exit the NETWORK screen (portrait status view: the Wi-Fi state and the board's IP address — or, while the panel has no Wi-Fi and its own hotspot is up, `HOTSPOT` and the hotspot's name — plus a row for each of the first two features in the build that can be switched at runtime). The core does not know what those rows are: a feature appears by declaring a short name and a switch (`features/README.md`). On the default build there are none; on the Audio edition they are `OSC` and `AUD` (audio-react). Inside the screen, rows are toggled by **knob rotation, not clicks** — right = ON, left = OFF — so that holding K2 to leave can't flip anything on the way out: + - **turn K2** → the first row on/off (persists in NVS, so the device reboots into the same state). Wi-Fi stays connected either way; the switch only enables/disables that feature's traffic. + - **turn K3** → the second row on/off (also persisted). - **turn K1** → sleep (see [Sleep mode](#sleep-mode) below). Either direction, unlike the toggles above: the screen is about to go dark, so "right = on" has nothing to mean. - **turn K4** → hand off to the UPDATE screen, which arms the `/update` endpoint (the arming *is* the security model — see `core_web_update.h`). @@ -895,21 +948,21 @@ 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. Two switches, for a local build: -- **Compile-time** — whether a feature is linked in at all is the bundle: `firmware/bundles//features_local.h` lists the features an edition carries ([`bundles/README.md`](bundles/README.md)). A feature's own tunables live in `features//_config.h` (the audio WebSocket port and knob scale in `features/audio/audio_config.h`, the OSC ports in `features/osc/osc_config.h`, the broker defaults in `features/mqtt/mqtt_config.h`), every one `#ifndef`-guarded so a line in `patternflow_secrets.h` wins. `PF_OSC_ENABLED 0` in the secrets file compiles OSC out of an edition that would otherwise carry it; the K2 info screen then shows `OFF (compile-time)`. -- **Runtime** — once compiled in, OSC and MIDI are switched on and off from the panel's NETWORK screen (hold K2), the state saved in NVS; the K2 screen shows `WAIT HOST` until the first OSC packet teaches the panel your computer's address. +- **Compile-time** — whether a feature is linked in at all is the bundle: `firmware/bundles//features_local.h` lists the features an edition carries ([`bundles/README.md`](bundles/README.md)). A feature's own tunables live in `features//_config.h` (the audio WebSocket port and knob scale in `features/audio/audio_config.h`, the OSC ports in `features/osc/osc_config.h`, the broker defaults in `features/mqtt/mqtt_config.h`), every one `#ifndef`-guarded so a line in `patternflow_secrets.h` wins. `PF_OSC_ENABLED 0` in the secrets file compiles OSC's traffic out of an edition that would otherwise carry it (its NETWORK row stays, and switches nothing). +- **Runtime** — once compiled in, a feature with a runtime switch gets a row on the panel's NETWORK screen (hold K2; turn K2 for the first row, K3 for the second), the state saved in NVS. On the Audio edition those rows are OSC and AUD; MIDI is switched on its console page (`/midi`). OSC learns your computer's address from the first packet it receives — send `/patternflow/ping` (the Max for Live bridge's Connect button does) — see [`docs/osc-spec.md`](../docs/osc-spec.md). ## Wireless update from the browser Once the device is on Wi-Fi, it can flash itself from any browser on the same network — no Arduino IDE, no USB cable, no TLS setup ([#232](https://github.com/engmung/Patternflow/issues/232)). -1. Get a firmware `.bin`: build one on [patternflow.work](https://patternflow.work) and hit *Download .bin*, or export one locally (`arduino-cli compile --output-dir …` — use the app image, not a merged full-flash image). -2. Open `http://patternflow.local` — the device serves a small console (audio sync / firmware update) — and pick **Firmware Update**, or go straight to `/update`. If `.local` doesn't resolve (common on Android), use `http:///update`; the device shows its IP on the NETWORK screen (hold K2) and on the UPDATE screen. +1. Get a firmware `.bin`, or let the site fetch it for you: when a newer release exists the console says so and links to [patternflow.work/update](https://patternflow.work/update), which hands the image to the panel; an edition installs the same way from [the shelf](https://patternflow.work/editions). A file of your own is a release's `patternflow.ino.bin` (or `patternflow-.ino.bin`), or a local build (`./firmware/bundles/build.sh`, or `build.sh flash `, which also sends it) — the app image, not a merged full-flash image. +2. Open `http://patternflow.local` — the device's console — and pick **Update** in its header, or go straight to `/update`. If `.local` doesn't resolve (common on Android), use `http:///update`; the device shows its IP on the NETWORK screen (hold K2) and on the UPDATE screen. 3. Drop the `.bin` on the page. The panel shows flash progress; the device verifies, reboots, and comes back on the new firmware in about ten seconds. Notes: diff --git a/firmware/bundles/README.md b/firmware/bundles/README.md index 73546715b..4741b04a3 100644 --- a/firmware/bundles/README.md +++ b/firmware/bundles/README.md @@ -7,7 +7,8 @@ Named firmwares, built from this repository. Everything Patternflow does lives in `patternflow/` — the panel, the pattern -loader, Wi-Fi, sleep, OSC, MQTT, the show player, weather, audio. **A bundle +loader, Wi-Fi and the hotspot, sleep, and in `patternflow/features/` OSC, +MQTT, the show player, weather, audio, MIDI, the clock. **A bundle does not add code.** It is two files saying which of that gets compiled in, and which settings differ. @@ -26,12 +27,12 @@ lives in an edition built from these two files. ```bash ./firmware/bundles/build.sh # the default: no features ./firmware/bundles/build.sh audio # the audio bundle -./firmware/bundles/build.sh midi # the MIDI edition, in its own env (bundles/midi/env) +./firmware/bundles/build.sh midi # the MIDI bundle, in its own env (bundles/midi/env) ./firmware/bundles/build.sh audio flash patternflow.local ./firmware/bundles/build.sh all # every composition + the marker scan # proving each binary carries exactly # its features -./firmware/bundles/shelf.sh audio v0.5.0 # a publishable image: secrets +./firmware/bundles/shelf.sh audio vA.B.C # a publishable image: secrets # moved aside, scanner proven against a # control build, four files staged for # the shelf diff --git a/firmware/patternflow/README.md b/firmware/patternflow/README.md index a9dd99d96..4f3507ddd 100644 --- a/firmware/patternflow/README.md +++ b/firmware/patternflow/README.md @@ -28,11 +28,10 @@ FATFS partition (on the device, not in this repo) and appended after the presets. No reflash, no reboot, up to 128 of them. Build one with `firmware/toolchain/build_module.py`. -> The old `custom1.h`–`custom3.h` slots in the sketch root are **gone** — -> modules are what they were for, and no longer cost a rebuild. Only `Origin` -> is compiled in today; the rest of the curated set ships as the Basics pack -> (`web/public/packs/basics.zip`, built from `presets/` by -> `firmware/toolchain/make_pack.py`) and installs from the Patterns page. +> Only `Origin` is compiled in today; the rest of the curated set ships as the +> Basics pack (`web/public/packs/basics.zip`, built from `presets/` by +> `firmware/toolchain/make_pack.py`) and installs from the Patterns page. The +> old `custom1.h`–`custom3.h` slots are gone — see [Registry](#registry). ## Source of truth @@ -169,8 +168,9 @@ soon as anything else wanted RAM. One preset plus a pack you choose leaves The preset sources are still in `presets/` as the editable originals — `toolchain/port_preset.py` is what turns one back into a module. -> **The `custom1..3` slots are gone.** They were the hand-edited way to get a -> pattern on a device, and uploading a `.pfm` replaced them. `PF_CUSTOM_SLOT_COUNT` +> **The `custom1.h`–`custom3.h` slots in the sketch root are gone.** They were +> the hand-edited way to get a pattern on a device, and uploading a `.pfm` +> replaced them — modules are what they were for, and no longer cost a rebuild. `PF_CUSTOM_SLOT_COUNT` > is `0`, and the region between the `PF_CUSTOM_SLOTS_BEGIN` / `PF_CUSTOM_SLOTS_END` > markers exists only so the web build service can compile a submitted pattern > into a whole image for devices whose firmware predates the module loader. @@ -184,19 +184,22 @@ The preset sources are still in `presets/` as the editable originals — Two things about that set which the file itself won't tell you: - **Not every pattern made the cut.** 0516, 0517, 0519-2, 0524, 0524-2, 0526, 0529 and 0530 were left out for performance lag or rendering problems on real ESP32 hardware. A pattern that looks fine in the browser can still miss frame budget on the device. 0609, 0614, 0614-2, 0619, 0622 and 0624 were posted after the module loader made the compiled-in showcase unnecessary and were never ported. `firmware/toolchain/check_presets.py` (run in CI) keeps this list honest: a new web preset has to be ported or added there with a reason. -- **Patterns posted to Instagram after 0602 aren't bundled here.** They're on Discord; convert them yourself, or install them as `.pfm` modules. +- **New patterns are not added here.** The set runs from 0510 to 0719 (with the gaps above); patterns since are published from the Pattern Lab to the Community, and install on a board from there as `.pfm` modules. Anything not compiled in is one upload away — build a module and send it to the device over Wi-Fi, no reflash. ## Licensing (read before submitting) -Patterns are licensed **CC-BY-SA-4.0** (same commons as the hardware; see +The patterns in `presets/` — the curated set, and so the Basics pack — are +licensed **CC-BY-SA-4.0** (same commons as the hardware; see `/LICENSE-CC-BY-SA`). Two simple, community-enforceable rules: **give credit, -and share alike.** +and share alike.** A pattern you publish to the Community instead carries the +license you pick there ([`docs/LICENSE-SUMMARY.md`](../../docs/LICENSE-SUMMARY.md)); +publishing is how patterns are shared now, not a pull request +([`CONTRIBUTING.md`](../../CONTRIBUTING.md)). -**Inbound = outbound:** by submitting a pattern (PR, issue, or Discord) you -agree to license it under **CC-BY-SA-4.0**, with attribution kept in the -metadata header. No copyright assignment (no CLA) — you keep authorship; the +**Inbound = outbound:** a pattern that does come into this tree is licensed +under **CC-BY-SA-4.0**, with attribution kept in the metadata header. No copyright assignment (no CLA) — you keep authorship; the project just gets the right to bundle and redistribute it. Authors may set a different license in the metadata header, as long as it diff --git a/firmware/patternflow/config.h b/firmware/patternflow/config.h index c396dce97..af335c10c 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/README.md b/firmware/patternflow/console/README.md index 2b60139d1..728003a3a 100644 --- a/firmware/patternflow/console/README.md +++ b/firmware/patternflow/console/README.md @@ -1,7 +1,8 @@ # The device console -These are the pages the panel serves over your LAN. They are ordinary HTML -files — open one in a browser, edit it, refresh. +These are the pages the panel serves over your LAN, and on its own hotspot +at `http://192.168.4.1/` when it has no network (`src/core_hotspot.h`). They +are ordinary HTML files — open one in a browser, edit it, refresh. You do not need the firmware, a device, or a toolchain to work on them. diff --git a/firmware/patternflow/console/midi.html b/firmware/patternflow/console/midi.html index 08f51d1b3..b5a249a6c 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/README.md b/firmware/patternflow/features/README.md index 36126b313..85657fcd8 100644 --- a/firmware/patternflow/features/README.md +++ b/firmware/patternflow/features/README.md @@ -10,13 +10,13 @@ Features that attach to the core without the core knowing they exist. This directory is the answer to one question: *when the core updates, how does somebody else's firmware take the update without pain?* Git merges are only painful when both sides edit the same files — so the rule is that a -variant edits none. +firmware built on the core edits none. -> **A variant adds directories here and two files of its own. It edits +> **A firmware adds directories here and two files of its own. It edits > nothing.** `git merge upstream` has nothing to conflict over, ever. An earlier version of this said "and one line to `features.h`" — which was -one line in a *core* file, so every variant conflicted on it at every core +one line in a *core* file, so every firmware conflicted on it at every core update, forever, on the same line. One line is enough to make taking updates a chore that eventually stops happening, and a firmware that stops taking updates is the fork this directory exists to prevent. @@ -49,14 +49,15 @@ for why any of this exists. | `features.h` | The default list, and the escape hatch. **The core owns it; no firmware edits it.** | | `feature_presets.h` | Patterns contributed by features. Same arrangement: `#ifndef PF_FEATURE_PRESETS`, so a firmware can decline them. | -A variant's `features_local.h` defines `PF_FEATURE_LIST` and `features.h` steps +A firmware's `features_local.h` defines `PF_FEATURE_LIST` and `features.h` steps aside. It may add, drop and reorder; `PF_FEATURES_NONE` builds with none at all: ```c +#include "osc/feature_osc.h" #include "audio_in/feature_audio_in.h" -#define PF_FEATURE_LIST \\ - &PFFeatureOsc::descriptor, \\ +#define PF_FEATURE_LIST \ + &PFFeatureOsc::descriptor, \ &PFFeatureAudioIn::descriptor ``` @@ -67,7 +68,7 @@ it reaches settings the feature list cannot: #define PF_VARIANT "audio" #define PF_VARIANT_VERSION "v0.1.0" // reported in /api/status, worn as // a badge in the console header -#define PF_WIFI_TX_POWER WIFI_POWER_17dBm +#define PF_AUDIO_IN_DRIVES_KNOBS 1 // a feature's own default, changed #define PF_FEATURE_PRESETS // no presets from features you lack ``` @@ -180,7 +181,7 @@ Then one line in the bundle that carries it — `firmware/bundles//feat | `audio/` | the WebSocket on port 81 that a client capturing sound elsewhere (the browser extension, the phone app) writes the four knob lanes through, and the switch that lets it (`/api/audio`) | The one with a server of its own, and a row in the device's own menu. The analysis and the mapping happen in the client; no page is served from here. `docs/audio-ws-spec.md`. | | `audio_in/` | the on-board PDM microphone: FFT on a Core-0 task, four bands onto the knob lanes, `/audio-in` and `/api/audio-in` | The first feature that did not already exist in the core; four hooks, no core edits. Its console page is assembled from the audio extension's mapping editor and the panel's own half in `console/_audio_in_*` (`toolchain/build_audio_in_page.py`, run by `console_pages.py build`). | | `osc/` | Max / TouchDesigner / Ableton, both directions | The fifth, and the first that did not fit — see below. | -| `midi/` | MIDI in and out over RTP-MIDI (Wi-Fi) and, in the MIDI edition, USB: CC 20–23 absolute, 24–27 relative, notes, Program Change | Transport-agnostic mapping in `core_midi.h`; the network transport beside it (`core_midi_rtp.h`), and the USB one (`core_midi_usb.h`) that compiles in only when the build's USB port is the OTG stack — a build flag, which is why the MIDI edition has its own env (`bundles/midi/env`). `docs/midi-spec.md`. | +| `midi/` | MIDI in and out over RTP-MIDI (Wi-Fi) and, in the MIDI bundle, USB: CC 20–23 absolute, 24–27 relative, notes, Program Change | Transport-agnostic mapping in `core_midi.h`; the network transport beside it (`core_midi_rtp.h`), and the USB one (`core_midi_usb.h`) that compiles in only when the build's USB port is the OTG stack — a build flag, which is why the MIDI bundle has its own env (`bundles/midi/env`). `docs/midi-spec.md`. | | `clock/` | the time cut out of the running pattern, `/clock`, `/api/clock` | Settings in its own NVS namespace, a page with a live preview, and the first user of `composeFrame`: huge anti-aliased digits the pattern shows through (typefaces rasterised offline by `toolchain/build_clock_glyphs.py`, several faces), with what fills the digits and what surrounds them chosen separately. Owns the time zone as a POSIX string with its DST rule; the core keeps the time (`src/core_clock.h`), this shows it. | | `ble/` | Wi-Fi provisioning from a phone over Improv-BLE | **In no edition.** The lifecycle works on hardware (advertise only while the panel cannot join, release the controller's memory after) but linking it costs ~12 KB of internal RAM and the one phone it was tried with never listed the panel. Opt-in for whoever finishes it; the header records what was measured. | diff --git a/firmware/patternflow/features/midi/core_midi.h b/firmware/patternflow/features/midi/core_midi.h index c9cf381e5..1efac19b5 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 2e3961dcf..b49c28fae 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 b5134678f..2ebb142d8 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 cb4094307..f451f8ebf 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/presets/README.md b/firmware/patternflow/presets/README.md index eb5553710..649962bbc 100644 --- a/firmware/patternflow/presets/README.md +++ b/firmware/patternflow/presets/README.md @@ -1,12 +1,12 @@ # presets/ -The curated patterns, as C++ headers. `preset_origin.h` is the one compiled into every firmware image — the failsafe a board can always boot into. The rest are built into `.pfm` modules by `firmware/toolchain/make_pack.py` and ship as the **Basics pack** (`web/public/packs/`), which a new board installs in one click. `presetPatterns[]` in [`../pattern_registry.h`](../pattern_registry.h) decides what is compiled in; [`../README.md`](../README.md) explains the registry. +The curated patterns, as C++ headers. `preset_origin.h` is the one compiled into every firmware image — the failsafe a board can always boot into. The rest are ported to module sources by `firmware/toolchain/port_preset.py`, built into `.pfm` modules by `build_module.py`, bundled by `make_pack.py`, and ship as the **Basics pack** (`web/public/packs/`), which a new board installs in one click. `presetPatterns[]` in [`../pattern_registry.h`](../pattern_registry.h) decides what is compiled in; [`../README.md`](../README.md) explains the registry. **This set is curated by the maintainer.** Patterns are not contributed here: they are made in the [Pattern Lab](https://patternflow.work/pattern-lab) and published to the Community, where every Patternflow can play them. [`../../CUSTOM_PATTERNS.md`](../../CUSTOM_PATTERNS.md) is the hands-on route for your own board. ## Twins -Every header here has a JavaScript twin in `web/src/lib/presets/`, and **the JavaScript is the source of truth**: the header is generated from it (the Pattern Lab's *Copy C++ prompt* flow, or `firmware/toolchain/port_preset.py` in the other direction). `firmware/toolchain/check_presets.py` runs in CI and fails when the two sets drift. Its `WEB_ONLY` list names the web presets that deliberately have no header — the ones that missed frame budget on the real ESP32, and the ones posted after the module loader made a compiled-in showcase unnecessary; delete an entry there the day its header lands. `preset_calib.h` is the display-calibration pattern and has no web twin on purpose. +Every header here has a JavaScript twin in `web/src/lib/presets/`, and **the JavaScript is the source of truth**: the header is generated from it (the Pattern Lab's *Copy C++ prompt* flow), and `firmware/toolchain/port_preset.py` takes the header on from there, into a module. `firmware/toolchain/check_presets.py` runs in CI and fails when the two sets drift. Its `WEB_ONLY` list names the web presets that deliberately have no header — the ones that missed frame budget on the real ESP32, and the ones posted after the module loader made a compiled-in showcase unnecessary; delete an entry there the day its header lands. `preset_calib.h` is the display-calibration pattern and has no web twin on purpose. ## Names diff --git a/firmware/patternflow/src/core_bus.h b/firmware/patternflow/src/core_bus.h index 29dfedcfc..d423228e1 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 0a1dfbb0a..3ecf687d3 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 d471cb7d4..53e8d0041 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 84cd93e81..b0d61299a 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 000000000..4a7f65ae8 --- /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/fonts/README.md b/firmware/patternflow/src/fonts/README.md index 77324ebab..1689e3756 100644 --- a/firmware/patternflow/src/fonts/README.md +++ b/firmware/patternflow/src/fonts/README.md @@ -2,18 +2,13 @@ From [trip5/Matrix-Fonts](https://github.com/trip5/Matrix-Fonts) (CC-BY). -| File | Use | -|------|-----| -| `MatrixLight8X.h` | SELECT titles, MQTT message banners | -| `MatrixLight6.h` | NETWORK / OSC / AUD / KNOB MAP / chrome hints | +**Optional — not the fonts the panel uses by default.** The panel's own screens draw with the Adafruit GFX built-in 5×7 (pattern names on SELECT), TomThumb (NETWORK rows, hints, status lines) and Org_01 (banners and titles); `../core_ui_fonts.h` binds those three roles. This pair is kept here for anyone who prefers it — the fonts Simone Majocchi's tree uses — and is switched in from `patternflow_secrets.h` or an edition's `overrides.h`, with the block at the bottom of `../core_ui_fonts.h`. -Regenerate after updating a `.bdf`: +| File | Role it can take | +|------|-----| +| `MatrixLight8X.h` | SELECT titles, banners (`PF_UI_FONT_SELECT`, `PF_UI_FONT_TITLE`) | +| `MatrixLight6.h` | NETWORK / KNOB MAP / chrome hints (`PF_UI_FONT_CHROME`) | -```bash -python toolchain/bdf_to_gfxfont.py src/fonts/MatrixLight8X.bdf \ - -o src/fonts/MatrixLight8X.h --name MatrixLight8X -python toolchain/bdf_to_gfxfont.py src/fonts/MatrixLight6.bdf \ - -o src/fonts/MatrixLight6.h --name MatrixLight6 -``` +The `.bdf` sources sit beside the headers. The `toolchain/bdf_to_gfxfont.py` this page used to name for regenerating them is not in this repository, so regenerating a header after editing a `.bdf` needs a BDF → Adafruit GFXfont converter of your own. ASCII `0x20`–`0x7E` only (overlays run text through `asciiFold`). 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 05a3c56af..354f18761 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 000000000..28abd48eb --- /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 d457c7a3c..0ea88367b 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 000000000..d4c00813d --- /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/README.md b/firmware/toolchain/README.md index 8ca9933ac..729aeced4 100644 --- a/firmware/toolchain/README.md +++ b/firmware/toolchain/README.md @@ -2,10 +2,12 @@ The repository-level scripts around the firmware: what builds a module or a pack, what assembles a console page, and the `check_*.py` that CI runs. Each script's docstring is its manual (`python firmware/toolchain/ diff --git a/web/scripts/cf-env-check.ts b/web/scripts/cf-env-check.ts new file mode 100644 index 000000000..364790441 --- /dev/null +++ b/web/scripts/cf-env-check.ts @@ -0,0 +1,38 @@ +// Run first by `npm run cf:build`. Vercel and the Pi never run it. +// +// OpenNext copies every key in web/.env, .env.local, .env.production and +// .env.production.local into the Worker it builds, where it becomes +// process.env, and writes the .env.development and .env.test files into the +// same bundle beside them (@opennextjs/cloudflare, compile-env-files.js). A +// desk set up for the community from .env.example has COMMUNITY_ENABLED=1 and +// a BETTER_AUTH_SECRET in .env.local, and a Worker built there would carry the +// secret in its code and send /api/community to a SQLite file it cannot open. +// +// So a Cloudflare build stops here unless those files hold only NEXT_PUBLIC_ +// names, the ones Next.js writes into the pages anyway. A server value goes +// to the deployed Worker with `npx wrangler secret put NAME`, and to the local +// preview through .dev.vars. + +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const FILES = [".env", ".env.local"].concat( + ...["production", "development", "test"].map((mode) => [`.env.${mode}`, `.env.${mode}.local`]), +); + +const found: string[] = []; +for (const file of FILES) { + const full = path.join(process.cwd(), file); + if (!existsSync(full)) continue; + for (const match of readFileSync(full, "utf8").matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)) { + if (!match[1].startsWith("NEXT_PUBLIC_")) found.push(`${file}: ${match[1]}`); + } +} + +if (found.length > 0) { + console.error("cf-env-check: OpenNext would build these into the Worker:"); + for (const line of found) console.error(` ${line}`); + console.error("Take them out of those files for the Cloudflare build. DEPLOY-cloudflare.md says where each one goes instead."); + process.exit(1); +} +console.log(`cf-env-check: ${FILES.filter((file) => existsSync(path.join(process.cwd(), file))).length} env files, NEXT_PUBLIC_ names only`); diff --git a/web/scripts/cf-headers.ts b/web/scripts/cf-headers.ts new file mode 100644 index 000000000..fb3828e93 --- /dev/null +++ b/web/scripts/cf-headers.ts @@ -0,0 +1,60 @@ +// Run by `npm run cf:build` after OpenNext has written .open-next/assets. +// Vercel and the Pi never run it. +// +// On Cloudflare a file from public/ is answered by Workers Static Assets +// before Next.js runs, so the headers() rules in next.config.ts never reach +// it. On 25 October 2026 the local preview served /flash/manifest.json and +// /flash/bin/* without Access-Control-Allow-Origin, which is what a panel's +// own console needs to read the manifest and to fetch an image for a wireless +// update. Static Assets reads its rules from a _headers file in the assets +// directory instead, so this writes one from the same headers(): next.config.ts +// stays the one place they are kept, and pages, which do go through Next.js, +// keep getting them from there. +// +// One rule is added that next.config.ts does not have. Vercel serves the +// build's hashed files under /_next/static as immutable by itself, and Static +// Assets answers everything with max-age=0 unless told otherwise. + +import { writeFileSync } from "node:fs"; +import path from "node:path"; + +type Rule = { source: string; headers: { key: string; value: string }[]; has?: unknown; missing?: unknown }; + +const OUT = path.join(process.cwd(), ".open-next", "assets", "_headers"); + +const IMMUTABLE_BUILD_FILES: Rule = { + source: "/_next/static/:path*", + headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }], +}; + +/** next.config's `/flash/bin/:path*` is `/flash/bin/*` in _headers. */ +function pattern(source: string): string { + const splat = source.replace(/\/:[a-z]+\*$/i, "/*"); + if (splat.includes(":")) { + throw new Error(`cf-headers: ${source} has a parameter _headers cannot express; write its rule by hand`); + } + return splat; +} + +async function main() { + // next.config.ts reads NODE_ENV when it loads: the sandbox's cache header + // is no-store in development and a year everywhere else. + Object.assign(process.env, { NODE_ENV: "production" }); + const { default: config } = await import("../next.config"); + const rules: Rule[] = [...((await config.headers?.()) ?? []), IMMUTABLE_BUILD_FILES]; + + const blocks = rules.map((rule) => { + if (rule.has || rule.missing) { + throw new Error(`cf-headers: ${rule.source} has has/missing conditions, which _headers cannot express`); + } + return [pattern(rule.source), ...rule.headers.map(({ key, value }) => ` ${key}: ${value}`)].join("\n"); + }); + + writeFileSync(OUT, `${blocks.join("\n\n")}\n`); + console.log(`cf-headers: ${rules.length} rules written to ${path.relative(process.cwd(), OUT)}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/web/scripts/cf-proxy-trace.ts b/web/scripts/cf-proxy-trace.ts new file mode 100644 index 000000000..e18d4d0c0 --- /dev/null +++ b/web/scripts/cf-proxy-trace.ts @@ -0,0 +1,31 @@ +// Run by open-next.config.ts after `next build`, before OpenNext bundles the +// Worker. Vercel and the Pi never run it. +// +// OpenNext copies the files Next traced for proxy.ts into .open-next and +// bundles the copy with esbuild, which resolves a package through the +// "module" field of its package.json. Next's tracer asks for +// @opentelemetry/api, and the trace holds only that package's CommonJS build +// (build/src), not the build/esm its "module" field names, so the bundle stops +// at "Could not resolve @opentelemetry/api". OpenNext 1.20.12 steps around +// this only for an app that does not install the package; this one has it +// through drizzle-orm and better-auth. Tracing includes in next.config.ts do +// not reach the proxy's trace under Turbopack (tried on 25 October 2026). +// +// Taking the package out of the proxy's trace leaves no copy in .open-next, +// so esbuild walks up to node_modules/@opentelemetry/api, the whole package, +// and bundles that. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const TRACE = path.join(process.cwd(), ".next", "server", "middleware.js.nft.json"); +const PACKAGE = "node_modules/@opentelemetry/api/"; + +if (!existsSync(TRACE)) { + console.log(`cf-proxy-trace: no ${path.relative(process.cwd(), TRACE)}, nothing to change`); +} else { + const trace = JSON.parse(readFileSync(TRACE, "utf8")) as { files: string[] }; + const kept = trace.files.filter((file) => !file.includes(PACKAGE)); + writeFileSync(TRACE, JSON.stringify({ ...trace, files: kept })); + console.log(`cf-proxy-trace: took ${trace.files.length - kept.length} @opentelemetry/api files out of the proxy's trace`); +} diff --git a/web/scripts/cut-led-panel.ts b/web/scripts/cut-led-panel.ts new file mode 100644 index 000000000..9714844ce --- /dev/null +++ b/web/scripts/cut-led-panel.ts @@ -0,0 +1,141 @@ +/** + * Cuts the LED panel out of the official case model for the guide: + * public/cases/official/model.glb → public/guide/led-panel.glb. + * + * npx tsx scripts/cut-led-panel.ts (inside web/) + * + * The guide's stage takes one mesh of that file, the LED panel "l" (its case, + * board and DevKit are files of the guide's own, stage/parts.ts). The file is + * 317 KB and the panel is 10 KB of it, so a phone opening the Build guide on + * a slow connection spent a second and a half on a board and a DevKit it + * never drew. The cut keeps the node as the file has it, its place, its mesh + * and its material, byte for byte, and nothing else. + * + * Run it again whenever tools/case-models writes model.glb anew. + * stage/ledPanel.test.ts cuts the file in memory and fails while the copy in + * public/guide is not what this writes. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const WEB = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const SOURCE = path.join(WEB, "public/cases/official/model.glb"); +export const TARGET = path.join(WEB, "public/guide/led-panel.glb"); +export const NODE = "l"; + +type Json = Record & { + nodes: { name?: string; mesh?: number; children?: number[] }[]; + meshes: { primitives: Primitive[] }[]; + accessors: { bufferView?: number }[]; + bufferViews: { buffer: number; byteOffset?: number; byteLength: number }[]; + materials?: Record[]; +}; +type Primitive = { + attributes: Record; + indices?: number; + material?: number; + extensions?: { KHR_draco_mesh_compression?: { bufferView: number } }; +}; + +const pad4 = (n: number) => (n + 3) & ~3; + +function chunks(glb: Uint8Array): { json: Json; bin: Uint8Array } { + const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength); + if (view.getUint32(0, true) !== 0x46546c67) throw new Error("not a GLB"); + const jsonLength = view.getUint32(12, true); + const json = JSON.parse(new TextDecoder().decode(glb.subarray(20, 20 + jsonLength))) as Json; + const binAt = 20 + jsonLength; + const binLength = view.getUint32(binAt, true); + return { json, bin: glb.subarray(binAt + 8, binAt + 8 + binLength) }; +} + +/** The GLB holding one root node of `glb`, by name: its mesh, accessors, material and the bytes they use. */ +export function cutNode(glb: Uint8Array, name: string, generator: string): Uint8Array { + const { json, bin } = chunks(glb); + const node = json.nodes.find((n) => n.name === name); + if (!node || node.mesh === undefined) throw new Error(`no mesh node "${name}"`); + if (node.children?.length) throw new Error(`"${name}" has children; this cuts a single mesh`); + + const views: number[] = []; + const view = (i: number) => (views.includes(i) ? views.indexOf(i) : views.push(i) - 1); + const accessors: Json["accessors"] = []; + const accessor = (i: number) => { + const a = { ...json.accessors[i] }; + if (a.bufferView !== undefined) a.bufferView = view(a.bufferView); + return accessors.push(a) - 1; + }; + const materials: Record[] = []; + const material = (i: number) => { + const m = json.materials![i]; + if (JSON.stringify(m).includes('"index"')) throw new Error(`material ${i} has textures; this cuts none`); + return materials.push(m) - 1; + }; + const mesh = { + ...json.meshes[node.mesh], + primitives: json.meshes[node.mesh].primitives.map((p) => { + const out: Primitive = { ...p, attributes: Object.fromEntries(Object.entries(p.attributes).map(([k, a]) => [k, accessor(a)])) }; + if (p.indices !== undefined) out.indices = accessor(p.indices); + if (p.material !== undefined) out.material = material(p.material); + const draco = p.extensions?.KHR_draco_mesh_compression; + if (draco) out.extensions = { ...p.extensions, KHR_draco_mesh_compression: { ...draco, bufferView: view(draco.bufferView) } }; + return out; + }), + }; + + // The bytes: each view that is kept, in the order first used, on a 4-byte boundary. + const bufferViews: Json["bufferViews"] = []; + let length = 0; + for (const i of views) { + const v = json.bufferViews[i]; + bufferViews.push({ ...v, buffer: 0, byteOffset: length }); + length = pad4(length + v.byteLength); + } + const outBin = new Uint8Array(length); + views.forEach((i, k) => { + const v = json.bufferViews[i]; + const at = v.byteOffset ?? 0; + outBin.set(bin.subarray(at, at + v.byteLength), bufferViews[k].byteOffset); + }); + + const out: Record = { + asset: { ...(json.asset as object), generator }, + ...(json.extensionsUsed ? { extensionsUsed: json.extensionsUsed } : {}), + ...(json.extensionsRequired ? { extensionsRequired: json.extensionsRequired } : {}), + scene: 0, + scenes: [{ name: "Scene", nodes: [0] }], + nodes: [{ ...node, mesh: 0 }], + meshes: [mesh], + accessors, + ...(materials.length ? { materials } : {}), + bufferViews, + buffers: [{ byteLength: length }], + }; + const text = new TextEncoder().encode(JSON.stringify(out)); + const jsonLength = pad4(text.length); + const total = 12 + 8 + jsonLength + 8 + length; + const glbOut = new Uint8Array(total); + const dv = new DataView(glbOut.buffer); + dv.setUint32(0, 0x46546c67, true); + dv.setUint32(4, 2, true); + dv.setUint32(8, total, true); + dv.setUint32(12, jsonLength, true); + dv.setUint32(16, 0x4e4f534a, true); + glbOut.fill(0x20, 20, 20 + jsonLength); + glbOut.set(text, 20); + dv.setUint32(20 + jsonLength, length, true); + dv.setUint32(24 + jsonLength, 0x004e4942, true); + glbOut.set(outBin, 28 + jsonLength); + return glbOut; +} + +/** What this script writes, from the model on disk. */ +export function render(): Uint8Array { + return cutNode(new Uint8Array(fs.readFileSync(SOURCE)), NODE, "patternflow web/scripts/cut-led-panel.ts, node \"l\" of public/cases/official/model.glb"); +} + +if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + const out = render(); + fs.writeFileSync(TARGET, out); + console.log(`wrote ${path.relative(WEB, TARGET)} (${out.length} bytes, from ${fs.statSync(SOURCE).size})`); +} diff --git a/web/scripts/doc-map.ts b/web/scripts/doc-map.ts new file mode 100644 index 000000000..a785d1417 --- /dev/null +++ b/web/scripts/doc-map.ts @@ -0,0 +1,459 @@ +/** + * 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 and exits with SKIPPED from + * scripts/drift/_lib.ts, so scripts/drift.ts prints it as skipped and not as + * passed. 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"; + +import { SKIPPED } from "./drift/_lib"; + +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 : unread.length ? SKIPPED : 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 000000000..5f8df4db2 --- /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 000000000..629a1b2a0 --- /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 000000000..48a29948f --- /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 000000000..f4379364a --- /dev/null +++ b/web/scripts/drift/_lib.ts @@ -0,0 +1,74 @@ +/** + * 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. The check then exits SKIPPED, which fails + * nothing and which scripts/drift.ts prints as skipped. 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; done() then exits SKIPPED, or 1 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 000000000..91aa7bfce --- /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 000000000..61dde6a52 --- /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 000000000..e3af41304 --- /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 000000000..ad501f264 --- /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 000000000..ae8dd4e6c --- /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 000000000..0e75e6c5d --- /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 000000000..64d4e8afe --- /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 000000000..894986f0f --- /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 000000000..5298ff335 --- /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 000000000..6c3147341 --- /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 000000000..d55f65f49 --- /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 000000000..24cf696d8 --- /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 000000000..fd1c38cca --- /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 000000000..25d304498 --- /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 000000000..4f05c3002 --- /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 000000000..de7e77748 --- /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 000000000..1c7aca6cd --- /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 000000000..3557e0905 --- /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 000000000..c8b32504e --- /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 000000000..9a45888f1 --- /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/enqueue-test-build.ts b/web/scripts/enqueue-test-build.ts index ccdd01ac2..7e78fc8ce 100644 --- a/web/scripts/enqueue-test-build.ts +++ b/web/scripts/enqueue-test-build.ts @@ -1,9 +1,13 @@ /** * Put one build on the queue, for testing the worker without the web UI. * - * npm run build:enqueue # whole image from the template - * npm run build:enqueue -- path/to.h # or a header of your choosing - * npm run build:enqueue -- --pfm path/to.h # loadable modules instead + * npm run build:enqueue # the pattern template + * npm run build:enqueue -- path/to.h # or a header of your choosing + * + * The worker compiles the header into a .pfm module and zips it, as it does + * for a pattern sent from the site. Whole-image builds ended on 12 August + * (4c13474). --pfm, which used to pick modules over an image, is still + * accepted and changes nothing. * * Prints the build id, then poll it with: npm run build:status -- */ @@ -18,9 +22,7 @@ async function main() { const { getDb } = await import("../src/lib/community/server/db"); const { user } = await import("../src/lib/community/server/schema"); - const args = process.argv.slice(2); - const pfm = args.includes("--pfm"); - const headerArg = args.find((a) => a !== "--pfm"); + const headerArg = process.argv.slice(2).find((a) => a !== "--pfm"); const headerPath = headerArg ? path.resolve(process.cwd(), headerArg) : path.resolve(process.cwd(), "../firmware/patternflow/_TEMPLATE.h"); @@ -38,13 +40,9 @@ async function main() { } const code = fs.readFileSync(headerPath, "utf8"); - const id = await enqueueBuild( - accounts[0].id, - [{ label: path.basename(headerPath), code }], - pfm ? "pfm" : "bin", - ); + const id = await enqueueBuild(accounts[0].id, [{ label: path.basename(headerPath), code }], "pfm"); - console.log(`queued ${pfm ? "module" : "image"} build ${id}`); + console.log(`queued module build ${id}`); console.log(` header : ${headerPath} (${(code.length / 1024).toFixed(1)} KB)`); console.log(` as : ${accounts[0].name}`); console.log(`\nnpm run build:status -- ${id}`); diff --git a/web/scripts/gen-shared.ts b/web/scripts/gen-shared.ts new file mode 100644 index 000000000..e28df5170 --- /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 000000000..bfd412a22 --- /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/lab-director-smoke.ts b/web/scripts/lab-director-smoke.ts index 18342cf78..28dd89a58 100644 --- a/web/scripts/lab-director-smoke.ts +++ b/web/scripts/lab-director-smoke.ts @@ -169,7 +169,7 @@ function key(t: number, v: number, mode: "hold" | "curve" = "hold"): DirectorKey knobLabels: ["A", "B", "C", "D"], forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW" as const, refs: 6, colorMode: "vfield" as const }, + gen: { colorMode: "vfield" as const }, director: show, name: "Smoke Piece", }; diff --git a/web/scripts/lab-edit-smoke.ts b/web/scripts/lab-edit-smoke.ts index a93a0bd2d..7208a19f1 100644 --- a/web/scripts/lab-edit-smoke.ts +++ b/web/scripts/lab-edit-smoke.ts @@ -130,7 +130,7 @@ async function main() { knobLabels: ["A", "B", "C", "D"], forkOf: null, editOf: EDIT, - gen: { count: 5, thinking: "LOW" as const, refs: 6, colorMode: "vfield" as const }, + gen: { colorMode: "vfield" as const }, director: emptyShow(), name: "Edit Smoke", }; diff --git a/web/scripts/lab-flatten-smoke.ts b/web/scripts/lab-flatten-smoke.ts index f98a05a4f..2b9409f47 100644 --- a/web/scripts/lab-flatten-smoke.ts +++ b/web/scripts/lab-flatten-smoke.ts @@ -254,7 +254,7 @@ async function stackSmoke() { knobLabels: ["Hue", "Speed", "Freq", "Mix"], forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW" as const, refs: 6, colorMode: "vfield" as const }, + gen: { colorMode: "vfield" as const }, director: emptyShow(), name: "Flatten Smoke", }; diff --git a/web/scripts/lib/pr-base.ts b/web/scripts/lib/pr-base.ts new file mode 100644 index 000000000..26b14b562 --- /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/license-smoke.ts b/web/scripts/license-smoke.ts index a6f8d89b7..b28d23f7d 100644 --- a/web/scripts/license-smoke.ts +++ b/web/scripts/license-smoke.ts @@ -5,8 +5,12 @@ * silent when they break: a fork must credit what it came from, and a fork must * not be published under looser terms than its parent. */ +import { buildHExport } from "../src/lib/lab/hExport"; +import { codeLayerFromSource } from "../src/lib/lab/store/shared"; +import { DEFAULT_MATRIX } from "../src/lib/pattern/matrix"; import { LICENSE_OPTIONS, + buildSharedHeaderFile, buildSharedPatternFile, forkLicenseAllowed, forkLicenseOptions, @@ -132,6 +136,36 @@ check( true, ); +console.log("\n── a downloaded .h carries one licence ──"); +// Pattern Lab's scaffold opens with a CC-BY-SA-4.0 SPDX line whatever the +// author picked. Published under CC BY 4.0, the download said CC-BY-SA-4.0 on +// line 1 and CC-BY-4.0 on line 18. +const spdxLines = (text: string) => text.match(/SPDX-License-Identifier: \S+/g) ?? []; +const ccBy: ShareMeta = { ...meta(), license: licenseBySpdx("CC-BY-4.0") }; +const { scaffold } = buildHExport({ + name: "Ripple", + matrix: DEFAULT_MATRIX, + layers: [codeLayerFromSource(BODY, "Code 1").layer], + knobs: [0, 0, 0, 0], + ranges: [[0, 1], [0, 1], [0, 1], [0, 1]], + knobLabels: ["A", "B", "C", "D"], +}); +check("the Lab scaffold still opens with its own SPDX line", spdxLines(scaffold), ["SPDX-License-Identifier: CC-BY-SA-4.0"]); +const labH = buildSharedHeaderFile(scaffold, ccBy); +check("a Lab header shared under CC BY 4.0 says only CC-BY-4.0", spdxLines(labH), ["SPDX-License-Identifier: CC-BY-4.0"]); +check("the scaffold's other top comments stay", labH.startsWith("// Pattern: Ripple — Pattern Lab layer-stack composition"), true); +check("#pragma once stays above the licence block", labH.indexOf("#pragma once") < labH.indexOf("===== Patternflow pattern ====="), true); +const presetH = "// SPDX-License-Identifier: CC-BY-SA-4.0\n// Pattern: Origin\n\n#pragma once\nvoid draw() {}"; +check("a preset-style header loses its SPDX line too", spdxLines(buildSharedHeaderFile(presetH, ccBy)), ["SPDX-License-Identifier: CC-BY-4.0"]); +const noPragma = "// SPDX-License-Identifier: CC-BY-SA-4.0\nvoid draw() {}"; +check("so does one with no #pragma once", spdxLines(buildSharedHeaderFile(noPragma, ccBy)), ["SPDX-License-Identifier: CC-BY-4.0"]); +const inCode = '#pragma once\nconst char* NOTE = "x";\n// SPDX-License-Identifier: MIT\nvoid draw() {}'; +check("a SPDX line inside the code stays", spdxLines(buildSharedHeaderFile(inCode, ccBy)), [ + "SPDX-License-Identifier: CC-BY-4.0", + "SPDX-License-Identifier: MIT", +]); +check("wrapping a downloaded .h again keeps one licence", spdxLines(buildSharedHeaderFile(labH, ccBy)), ["SPDX-License-Identifier: CC-BY-4.0"]); + console.log("\n── footers inside a flattened layer stack ──"); // Pattern Lab flattens a layer stack with each layer's source embedded // mid-file, licence footers and all, and the composite draw() AFTER them. A @@ -142,15 +176,15 @@ const communityFooter = [ "// please keep this notice and the author credit above when you reuse,", "// remix, or redistribute this pattern. Do not delete it.", ].join("\n"); -const geminiFooter = [ +const labAiFooter = [ "// ---", "// Made with Patternflow Pattern Lab — https://patternflow.work/pattern-lab", "// Licensed CC-BY-SA-4.0. Keep this notice if you share or remix.", ].join("\n"); -const stack = `(function () {\n function draw() {}\n${communityFooter}\n})();\n(function () {\n function draw() {}\n${geminiFooter}\n})();\nexport function draw(display) {}`; +const stack = `(function () {\n function draw() {}\n${communityFooter}\n})();\n(function () {\n function draw() {}\n${labAiFooter}\n})();\nexport function draw(display) {}`; check("embedded community footer leaves the code after it alone", stripShareWrapping(stack), stack); check("trailing community footer still comes off", stripShareWrapping(`${BODY}\n\n${communityFooter}\n`), BODY); -check("trailing gemini footer still comes off", stripShareWrapping(`${BODY}\n\n${geminiFooter}\n`), BODY); +check("trailing Lab AI footer still comes off", stripShareWrapping(`${BODY}\n\n${labAiFooter}\n`), BODY); check( "a @stack line after the body survives a trailing footer", stripShareWrapping(`${BODY}\n\n// @stack v1 d:AAAA\n\n${communityFooter}\n`), diff --git a/web/scripts/performance-smoke.ts b/web/scripts/performance-smoke.ts index 217a89c04..1caf63b21 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/sandbox-smoke.ts b/web/scripts/sandbox-smoke.ts index 8d9ba38a9..648256548 100644 --- a/web/scripts/sandbox-smoke.ts +++ b/web/scripts/sandbox-smoke.ts @@ -203,5 +203,26 @@ for (const [label, frameMs, frames] of [ check("…and schedules no frame of its own", s.asleep(), true); } -console.log(failures === 0 ? "\nAll sandbox clock checks passed." : `\n${failures} FAILED`); +// ── A fine knob range reads as the Lab reads it ───────────────────────────── +// The page divided by no less than 0.001 where the Lab divides by 0.0001, so a +// 0.0001..0.0004 knob in the middle of its range reached the pattern as 0.15. +{ + const s = boot(); + s.send({ + type: "pf-load", running: true, + code: ` +// @matrix 128x64 +export function update(dt, input, p){ p.n0 = input.knobNormalized[0]; p.n1 = input.knobNormalized[1]; } +export function draw(display, p, time){}`, + knobValues: [0.00025, 0.0015, 0.5, 0.5], knobRanges: [[0.0001, 0.0004], [0.001, 0.002], [0, 1], [0, 1]], + knobWrap: [false, false, false, false], knobUnitsPerTurn: [1, 1, 1, 1], + }); + s.frame(0); + s.frame(16); + const params = s.live().runtime.params; + check("the middle of 0.0001..0.0004 reaches the pattern as 0.5", Number(params.n0?.toFixed(9)), 0.5); + check("the middle of 0.001..0.002 reaches the pattern as 0.5", Number(params.n1?.toFixed(9)), 0.5); +} + +console.log(failures === 0 ? "\nAll sandbox checks passed." : `\n${failures} FAILED`); process.exit(failures === 0 ? 0 : 1); diff --git a/web/scripts/voice-lint.ts b/web/scripts/voice-lint.ts new file mode 100644 index 000000000..2dddda200 --- /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/atlas/route.ts b/web/src/app/api/community/atlas/route.ts index 2cd779fa1..f13c690a3 100644 --- a/web/src/app/api/community/atlas/route.ts +++ b/web/src/app/api/community/atlas/route.ts @@ -3,6 +3,7 @@ import { ENTRY_BY_ID } from "@/lib/atlas/data"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { listAtlasPins } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; @@ -84,12 +85,8 @@ async function handleWrite(request: Request, mode: "place" | "remove") { return Response.json({ error: "Too many moves — let the pin settle a moment." }, { status: 429 }); } - let payload: Record; - try { - payload = (await request.json()) as Record; - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; const patternId = typeof payload.patternId === "string" ? payload.patternId : ""; if (!patternId) { diff --git a/web/src/app/api/community/builds/route.ts b/web/src/app/api/community/builds/route.ts index 57ec45231..e8aa6f459 100644 --- a/web/src/app/api/community/builds/route.ts +++ b/web/src/app/api/community/builds/route.ts @@ -8,6 +8,7 @@ import { type BuildPatternInput, } from "@/lib/community/server/builds"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled } from "@/lib/community/server/db"; import { rateLimit } from "@/lib/community/ratelimit"; import { CPP_MAX } from "@/lib/community/validate"; @@ -70,16 +71,17 @@ async function handlePost(request: Request) { ); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - // "bin" is still accepted as a word so an older page gets an explanation - // rather than a validation error it cannot act on. - const rawFormat = (body as Record).format ?? "pfm"; + // "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.format ?? "pfm"; if (rawFormat === "bin") { return Response.json( { @@ -95,7 +97,7 @@ async function handlePost(request: Request) { } const format: BuildFormat = rawFormat; - const raw = (body as Record).patterns; + const raw = body.patterns; if (!Array.isArray(raw) || raw.length === 0) { return Response.json({ error: "Send at least one pattern to build." }, { status: 400 }); } diff --git a/web/src/app/api/community/comments/[id]/route.ts b/web/src/app/api/community/comments/[id]/route.ts index 8a9307705..cf3b030bb 100644 --- a/web/src/app/api/community/comments/[id]/route.ts +++ b/web/src/app/api/community/comments/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getCommentStub } from "@/lib/community/server/queries"; @@ -64,14 +65,10 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own comments." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - const next = cleanComment((body as Record).body); + const next = cleanComment(body.body); if (!next) { return Response.json( { error: `A comment cannot be empty, and must be under ${COMMENT_MAX} characters.` }, diff --git a/web/src/app/api/community/decks/[id]/route.test.ts b/web/src/app/api/community/decks/[id]/route.test.ts new file mode 100644 index 000000000..08662a42d --- /dev/null +++ b/web/src/app/api/community/decks/[id]/route.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// Sharing a deck and editing it used to count against the same five a minute. +// The visibility select sends a PATCH on every change, so somebody who shared +// a deck and then flipped it public and back a couple of times was refused on +// the fifth edit. Driven through the real routes with real sessions, on a +// database of its own. + +const ENV_KEYS = ["COMMUNITY_DB_PATH", "COMMUNITY_ENABLED", "BETTER_AUTH_SECRET"] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; + +/** Signs somebody up with one public pattern and returns their Cookie header. */ +let enrol: (username: string) => Promise; +/** Shares a private deck holding that person's pattern. */ +let share: (cookie: string, username: string) => Promise<{ status: number; id?: string }>; +let edit: (cookie: string, deckId: string, body: Record) => Promise; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-deck-rate-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "deck-rate-test-secret-not-a-real-one"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + const createRoute = await import("../route"); + const deckRoute = await import("./route"); + + const db = getDb(); + const auth = getAuth(); + + enrol = async (username) => { + const response = await auth.api.signUpEmail({ + body: { + email: `${username}@patternflow.local`, + password: "deck-rate-test-password", + name: username, + username, + }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error(`no session cookie for ${username}`); + const [author] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + const now = new Date(); + await db.insert(schema.patterns).values({ + id: `p-${username}`, + userId: author!.id, + title: `${username}'s pattern`, + code: "// js", + license: "CC-BY-SA-4.0", + visibility: "public", + createdAt: now, + updatedAt: now, + }); + return setCookie.split(";")[0]; + }; + + // No Origin header below, which is the same-origin case originBlocked() + // lets through. The session is the gate under test. + share = async (cookie, username) => { + const response = await createRoute.POST( + new Request("http://localhost:3000/api/community/decks", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify({ + title: `${username}'s running order`, + visibility: "private", + patternIds: [`p-${username}`], + }), + }), + ); + const payload = (await response.json()) as { id?: string }; + return { status: response.status, id: payload.id }; + }; + + edit = async (cookie, deckId, body) => { + const response = await deckRoute.PATCH( + new Request(`http://localhost:3000/api/community/decks/${deckId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: deckId }) }, + ); + return response.status; + }; +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it("lets the owner edit a deck twenty times in the minute they shared it", async () => { + const ada = await enrol("ada"); + const shared = await share(ada, "ada"); + expect(shared.status).toBe(201); + const deckId = shared.id!; + + const statuses: number[] = []; + for (let n = 0; n < 20; n += 1) { + statuses.push(await edit(ada, deckId, { visibility: n % 2 === 0 ? "public" : "private" })); + } + expect(statuses).toEqual(Array(20).fill(200)); + + // Edits still have a ceiling, the same twenty a minute a pattern's have. + expect(await edit(ada, deckId, { title: "One too many" })).toBe(429); + // And they never ate into sharing: a new deck still goes through. + expect((await share(ada, "ada")).status).toBe(201); +}, 30_000); + +it("keeps sharing new decks at five a minute", async () => { + const bea = await enrol("bea"); + const statuses: number[] = []; + for (let n = 0; n < 6; n += 1) statuses.push((await share(bea, "bea")).status); + expect(statuses).toEqual([201, 201, 201, 201, 201, 429]); +}, 30_000); diff --git a/web/src/app/api/community/decks/[id]/route.ts b/web/src/app/api/community/decks/[id]/route.ts index 9cad0bdcb..992ef7ddc 100644 --- a/web/src/app/api/community/decks/[id]/route.ts +++ b/web/src/app/api/community/decks/[id]/route.ts @@ -7,6 +7,7 @@ import { } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck"; import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; @@ -84,7 +85,11 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Sign in to edit your deck." }, { status: 401 }); } - if (!rateLimit(`deck:${session.user.id}`, 5, 60_000)) { + // Edits count on a key of their own. The visibility select sends a PATCH on + // every change, and while edits shared `deck:` with sharing a new deck, the + // fifth edit within a minute of the share was refused. Twenty, the same as + // a pattern's or a post's edits. + if (!rateLimit(`deckpatch:${session.user.id}`, 20, 60_000)) { return Response.json({ error: "Too many deck updates — wait a minute and try again." }, { status: 429 }); } @@ -97,13 +102,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own decks." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (moderating) { if (!moderatorVisibilityPatchOnly(raw)) { diff --git a/web/src/app/api/community/decks/route.ts b/web/src/app/api/community/decks/route.ts index 3aa0ab1fb..99b6f6c40 100644 --- a/web/src/app/api/community/decks/route.ts +++ b/web/src/app/api/community/decks/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck"; import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; @@ -42,13 +43,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many deck updates — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); diff --git a/web/src/app/api/community/featured/route.ts b/web/src/app/api/community/featured/route.ts index e80d3a825..4ef3bc9c4 100644 --- a/web/src/app/api/community/featured/route.ts +++ b/web/src/app/api/community/featured/route.ts @@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { featuredPatterns, patterns } from "@/lib/community/server/schema"; @@ -35,14 +36,10 @@ async function handlePut(request: Request) { return Response.json({ error: "Not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; - const raw = (payload as { patternIds?: unknown }).patternIds; + const raw = payload.patternIds; if (!Array.isArray(raw) || raw.some((id) => typeof id !== "string")) { return Response.json({ error: "Expected patternIds: string[]." }, { status: 400 }); } diff --git a/web/src/app/api/community/nullBody.test.ts b/web/src/app/api/community/nullBody.test.ts new file mode 100644 index 000000000..0c4a4c807 --- /dev/null +++ b/web/src/app/api/community/nullBody.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// `null` is valid JSON, so it got past the `catch` around every write route's +// `request.json()` and threw a TypeError on the first field read. Next.js +// answered that with a bare 500: no JSON saying why, and no CORS headers, so a +// Pattern Lab on another origin could not read even the status. Every route +// below is driven with a signed-in moderator and the Lab's Origin, on a +// database of its own, with something for each id in the path to find. +// +// territories/[code]/pin is not here: its body is optional, and a `null` one +// already pins with no note. + +const LAB_ORIGIN = "https://lab.patternflow.test"; +const ENV_KEYS = [ + "COMMUNITY_DB_PATH", + "COMMUNITY_ENABLED", + "BETTER_AUTH_SECRET", + "COMMUNITY_ADMIN_USERNAMES", + "COMMUNITY_ALLOWED_ORIGINS", + "BUILD_ENABLED", +] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; +let cookie = ""; + +type Handler = (request: Request, context: { params: Promise> }) => Promise; +type RouteModule = Record; + +// [method, path, module loader, path params] +const ROUTES: [string, string, () => Promise, Record][] = [ + ["POST", "atlas", () => import("./atlas/route"), {}], + ["DELETE", "atlas", () => import("./atlas/route"), {}], + ["POST", "builds", () => import("./builds/route"), {}], + ["PATCH", "comments/c1?on=pattern", () => import("./comments/[id]/route"), { id: "c1" }], + ["POST", "decks", () => import("./decks/route"), {}], + ["PATCH", "decks/d1", () => import("./decks/[id]/route"), { id: "d1" }], + ["PUT", "featured", () => import("./featured/route"), {}], + ["POST", "patterns", () => import("./patterns/route"), {}], + ["PATCH", "patterns/p1", () => import("./patterns/[id]/route"), { id: "p1" }], + ["POST", "patterns/p1/comments", () => import("./patterns/[id]/comments/route"), { id: "p1" }], + ["POST", "patterns/p1/header", () => import("./patterns/[id]/header/route"), { id: "p1" }], + ["POST", "patterns/p1/performance", () => import("./patterns/[id]/performance/route"), { id: "p1" }], + ["PATCH", "ports/h1", () => import("./ports/[id]/route"), { id: "h1" }], + ["POST", "posts", () => import("./posts/route"), {}], + ["PATCH", "posts/s1", () => import("./posts/[id]/route"), { id: "s1" }], + ["POST", "posts/s1/comments", () => import("./posts/[id]/comments/route"), { id: "s1" }], + ["POST", "posts/s1/pin", () => import("./posts/[id]/pin/route"), { id: "s1" }], + ["POST", "presence", () => import("./presence/route"), {}], + ["POST", "reports", () => import("./reports/route"), {}], + ["PATCH", "reports/r1", () => import("./reports/[id]/route"), { id: "r1" }], + ["POST", "territories", () => import("./territories/route"), {}], + ["PATCH", "territories/A1", () => import("./territories/[code]/route"), { code: "A1" }], +]; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-null-body-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "null-body-test-secret-not-a-real-one"; + process.env.COMMUNITY_ADMIN_USERNAMES = "ada"; + process.env.COMMUNITY_ALLOWED_ORIGINS = LAB_ORIGIN; + process.env.BUILD_ENABLED = "1"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + + const response = await getAuth().api.signUpEmail({ + body: { email: "ada@patternflow.local", password: "null-body-test-password", name: "ada", username: "ada" }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error("no session cookie for ada"); + cookie = setCookie.split(";")[0]; + + const db = getDb(); + const [ada] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, "ada")) + .limit(1); + const userId = ada!.id; + const now = new Date(); + await db.insert(schema.patterns).values({ + id: "p1", userId, title: "ada's pattern", code: "// js", visibility: "public", createdAt: now, updatedAt: now, + }); + await db.insert(schema.decks).values({ id: "d1", userId, title: "ada's deck", createdAt: now, updatedAt: now }); + await db.insert(schema.comments).values({ id: "c1", patternId: "p1", userId, body: "first", createdAt: now }); + await db.insert(schema.patternHeaders).values({ + id: "h1", patternId: "p1", userId, codeCpp: "#pragma once\n", createdAt: now, + }); + await db.insert(schema.territories).values({ id: "t1", code: "A1", title: "Workshop", createdAt: now }); + await db.insert(schema.posts).values({ + id: "s1", territoryId: "t1", userId, title: "ada's post", body: "hello", createdAt: now, updatedAt: now, + }); + await db.insert(schema.reports).values({ + id: "r1", targetType: "pattern", targetId: "p1", reporterId: userId, reason: "other", createdAt: now, + }); +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it.each(ROUTES)("%s /api/community/%s answers a null body with a 400 the Lab can read", async ( + method, + route, + load, + params, +) => { + const handler = (await load())[method] as Handler; + const response = await handler( + new Request(`http://localhost:3000/api/community/${route}`, { + method, + headers: { "Content-Type": "application/json", Origin: LAB_ORIGIN, cookie }, + body: "null", + }), + { params: Promise.resolve(params) }, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Expected a JSON object." }); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe(LAB_ORIGIN); +}); diff --git a/web/src/app/api/community/patterns/[id]/comments/route.ts b/web/src/app/api/community/patterns/[id]/comments/route.ts index cf794febe..16bf38756 100644 --- a/web/src/app/api/community/patterns/[id]/comments/route.ts +++ b/web/src/app/api/community/patterns/[id]/comments/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyCommentAdded } from "@/lib/community/server/notify"; import { getPatternStub, newId } from "@/lib/community/server/queries"; @@ -46,14 +47,10 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "Pattern not found." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - const text = cleanComment((body as Record).body); + const text = cleanComment(body.body); if (!text) { return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); } diff --git a/web/src/app/api/community/patterns/[id]/header/route.ts b/web/src/app/api/community/patterns/[id]/header/route.ts index 026df279d..ffc6b3a47 100644 --- a/web/src/app/api/community/patterns/[id]/header/route.ts +++ b/web/src/app/api/community/patterns/[id]/header/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { notifyPortAdded } from "@/lib/community/server/notify"; @@ -106,13 +107,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "No such pattern." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const codeCpp = cleanCpp(raw.codeCpp); if (!codeCpp) { diff --git a/web/src/app/api/community/patterns/[id]/performance/route.ts b/web/src/app/api/community/patterns/[id]/performance/route.ts index d903fa64d..c14c712bf 100644 --- a/web/src/app/api/community/patterns/[id]/performance/route.ts +++ b/web/src/app/api/community/patterns/[id]/performance/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyPerformanceAdded } from "@/lib/community/server/notify"; import { @@ -129,13 +130,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "No such pattern." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (typeof raw.performanceJson !== "string" || raw.performanceJson.length === 0) { return Response.json({ error: "performanceJson (a Director show, as its JSON string) is required." }, { status: 400 }); diff --git a/web/src/app/api/community/patterns/[id]/route.test.ts b/web/src/app/api/community/patterns/[id]/route.test.ts new file mode 100644 index 000000000..70413c546 --- /dev/null +++ b/web/src/app/api/community/patterns/[id]/route.test.ts @@ -0,0 +1,179 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// MIT and CC0 left the licence picker, and a pattern published under one keeps +// it. The edit route said it would refuse a change TO one of them, then looked +// the new value up in the list that still holds both, so a direct PATCH moved +// a CC BY-SA original to CC0 and rewrote the SPDX line in its source. Driven +// through the real route with real sessions, on a database of its own. + +const ENV_KEYS = ["COMMUNITY_DB_PATH", "COMMUNITY_ENABLED", "BETTER_AUTH_SECRET"] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; + +/** Signs somebody up and returns their user id and Cookie header. */ +let enrol: (username: string) => Promise<{ id: string; cookie: string }>; +/** Puts a pattern straight into the table, as a past publish left it. */ +let seed: (row: { id: string; userId: string; license: string; parentId?: string }) => Promise; +let edit: ( + cookie: string, + patternId: string, + body: Record, +) => Promise<{ status: number; error?: string }>; +/** The licence column and the SPDX line baked into the stored source. */ +let stored: (patternId: string) => Promise<{ license: string; spdx: string | undefined }>; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-pattern-licence-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "pattern-licence-test-secret-not-a-real-one"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + const patternRoute = await import("./route"); + + const db = getDb(); + const auth = getAuth(); + + enrol = async (username) => { + const response = await auth.api.signUpEmail({ + body: { + email: `${username}@patternflow.local`, + password: "pattern-licence-test-password", + name: username, + username, + }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error(`no session cookie for ${username}`); + const [author] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + return { id: author!.id, cookie: setCookie.split(";")[0] }; + }; + + seed = async ({ id, userId, license, parentId }) => { + const now = new Date(); + await db.insert(schema.patterns).values({ + id, + userId, + title: id, + code: "function draw() {}", + license, + parentId: parentId ?? null, + visibility: "public", + createdAt: now, + updatedAt: now, + }); + }; + + // No Origin header below, which is the same-origin case originBlocked() + // lets through. + edit = async (cookie, patternId, body) => { + const response = await patternRoute.PATCH( + new Request(`http://localhost:3000/api/community/patterns/${patternId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: patternId }) }, + ); + const payload = (await response.json()) as { error?: string }; + return { status: response.status, error: payload.error }; + }; + + stored = async (patternId) => { + const [row] = await db + .select({ license: schema.patterns.license, code: schema.patterns.code }) + .from(schema.patterns) + .where(eq(schema.patterns.id, patternId)) + .limit(1); + return { + license: row!.license, + spdx: /SPDX-License-Identifier: (\S+)/.exec(row!.code)?.[1], + }; + }; +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it("refuses to move an original pattern to MIT or CC0", async () => { + const ada = await enrol("ada"); + await seed({ id: "p-ada", userId: ada.id, license: "CC-BY-SA-4.0" }); + // One edit first, so the source carries a baked header to compare against. + expect((await edit(ada.cookie, "p-ada", { title: "Tide" })).status).toBe(200); + + for (const license of ["CC0-1.0", "MIT"]) { + const refused = await edit(ada.cookie, "p-ada", { license }); + expect(refused.status).toBe(400); + expect(refused.error).toBe( + `${license} is retired. A pattern published under it keeps it, but no pattern can change to it.`, + ); + } + expect(await stored("p-ada")).toEqual({ license: "CC-BY-SA-4.0", spdx: "CC-BY-SA-4.0" }); +}, 30_000); + +it("still moves an original between the two licences the picker offers", async () => { + const bea = await enrol("bea"); + await seed({ id: "p-bea", userId: bea.id, license: "CC-BY-SA-4.0" }); + + expect((await edit(bea.cookie, "p-bea", { license: "CC-BY-4.0" })).status).toBe(200); + expect(await stored("p-bea")).toEqual({ license: "CC-BY-4.0", spdx: "CC-BY-4.0" }); + expect((await edit(bea.cookie, "p-bea", { license: "CC-BY-SA-4.0" })).status).toBe(200); + expect(await stored("p-bea")).toEqual({ license: "CC-BY-SA-4.0", spdx: "CC-BY-SA-4.0" }); +}, 30_000); + +it("lets a pattern published under a retired licence keep it, or leave it for an offered one", async () => { + const cal = await enrol("cal"); + await seed({ id: "p-cal", userId: cal.id, license: "MIT" }); + + // The edit modal sends the current licence back with every save. + expect((await edit(cal.cookie, "p-cal", { title: "Typo fixed", license: "MIT" })).status).toBe(200); + expect(await stored("p-cal")).toEqual({ license: "MIT", spdx: "MIT" }); + + // Retired to retired is still a change to a retired licence. + expect((await edit(cal.cookie, "p-cal", { license: "CC0-1.0" })).status).toBe(400); + expect(await stored("p-cal")).toEqual({ license: "MIT", spdx: "MIT" }); + + expect((await edit(cal.cookie, "p-cal", { license: "CC-BY-SA-4.0" })).status).toBe(200); + expect(await stored("p-cal")).toEqual({ license: "CC-BY-SA-4.0", spdx: "CC-BY-SA-4.0" }); +}, 30_000); + +it("refuses CC0 on a fork whose parent puts no limit on it", async () => { + // An MIT parent lets a fork take any licence (forkLicenseAllowed), so the + // fork check alone let this one through. + const dee = await enrol("dee"); + const eli = await enrol("eli"); + await seed({ id: "p-dee", userId: dee.id, license: "MIT" }); + await seed({ id: "p-eli", userId: eli.id, license: "CC-BY-4.0", parentId: "p-dee" }); + + expect((await edit(eli.cookie, "p-eli", { license: "CC0-1.0" })).status).toBe(400); + expect((await edit(eli.cookie, "p-eli", { license: "CC-BY-SA-4.0" })).status).toBe(200); + expect((await stored("p-eli")).license).toBe("CC-BY-SA-4.0"); +}, 30_000); + +it("refuses a licence it has never offered", async () => { + const fay = await enrol("fay"); + await seed({ id: "p-fay", userId: fay.id, license: "CC-BY-SA-4.0" }); + + expect(await edit(fay.cookie, "p-fay", { license: "GPL-3.0-only" })).toEqual({ + status: 400, + error: "Unknown licence.", + }); +}, 30_000); diff --git a/web/src/app/api/community/patterns/[id]/route.ts b/web/src/app/api/community/patterns/[id]/route.ts index 482dff73c..afa177c08 100644 --- a/web/src/app/api/community/patterns/[id]/route.ts +++ b/web/src/app/api/community/patterns/[id]/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { @@ -39,7 +40,13 @@ import { cleanTitle, } from "@/lib/community/validate"; import { cleanVisibility } from "@/lib/community/visibility"; -import { KNOWN_LICENSES, forkLicenseAllowed, stripShareWrapping } from "@/lib/pattern/share"; +import { + KNOWN_LICENSES, + LICENSE_OPTIONS, + RETIRED_LICENSE_OPTIONS, + forkLicenseAllowed, + stripShareWrapping, +} from "@/lib/pattern/share"; // PATCH /api/community/patterns/[id] — the author edits their own pattern. // @@ -128,13 +135,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own patterns." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // A moderator's reach into someone else's pattern is its visibility or its // .h, never both at once. Every other field below simply never runs for @@ -182,11 +184,25 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st let license = pattern.license; if (raw.license !== undefined) { - // KNOWN_LICENSES, not LICENSE_OPTIONS: a pattern published under a licence - // that has since been retired must stay editable by its author. Keeping the - // stored value is always allowed; only *changing* it is restricted. - const match = KNOWN_LICENSES.find((option) => option.spdx === raw.license); - if (!match) return Response.json({ error: "Unknown licence." }, { status: 400 }); + // Keeping the stored value is always allowed, so it is looked up in + // KNOWN_LICENSES: a pattern published under a licence that has since been + // retired must stay editable by its author. Changing it picks from + // LICENSE_OPTIONS, the two the publish route and both pickers offer. A + // retired licence stays on the work that already has it, and no pattern + // moves to one. + const choosable = raw.license === pattern.license ? KNOWN_LICENSES : LICENSE_OPTIONS; + const match = choosable.find((option) => option.spdx === raw.license); + if (!match) { + const retired = RETIRED_LICENSE_OPTIONS.find((option) => option.spdx === raw.license); + return Response.json( + { + error: retired + ? `${retired.spdx} is retired. A pattern published under it keeps it, but no pattern can change to it.` + : "Unknown licence.", + }, + { status: 400 }, + ); + } if (match.spdx !== pattern.license && parent && !forkLicenseAllowed(parent.license, match.spdx)) { return Response.json( { diff --git a/web/src/app/api/community/patterns/route.ts b/web/src/app/api/community/patterns/route.ts index 4ab900e46..f66f1e60c 100644 --- a/web/src/app/api/community/patterns/route.ts +++ b/web/src/app/api/community/patterns/route.ts @@ -1,6 +1,7 @@ import { and, eq, gte } from "drizzle-orm"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { MAX_FEED_PAGE_SIZE } from "@/lib/community/feedView"; import { notifyForkPublished } from "@/lib/community/server/notify"; @@ -124,13 +125,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many uploads — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); @@ -149,8 +145,8 @@ async function handlePost(request: Request) { if (!code) return Response.json({ error: "Pattern code is missing or over 100KB." }, { status: 400 }); // Any licence block the code arrived with belongs to someone else (a lab - // preset's header, a Gemini stamp, a previously exported file), so drop it - // before we write our own. + // preset's header, the Lab's old AI gallery stamp, a previously exported + // file), so drop it before we write our own. if (stripShareWrapping(code).length === 0) { return Response.json({ error: "Pattern code is empty once the licence header is removed." }, { status: 400 }); } diff --git a/web/src/app/api/community/ports/[id]/route.ts b/web/src/app/api/community/ports/[id]/route.ts index 63d15bc4c..a20b1727b 100644 --- a/web/src/app/api/community/ports/[id]/route.ts +++ b/web/src/app/api/community/ports/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { clearNotificationsFor, notifyHeaderModerated } from "@/lib/community/server/notify"; @@ -96,13 +97,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st const port = await getPortStub(id); if (!port) return Response.json({ error: "Port not found." }, { status: 404 }); - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const codeCpp = cleanCpp(raw.codeCpp); if (codeCpp === null) { diff --git a/web/src/app/api/community/posts/[id]/comments/route.ts b/web/src/app/api/community/posts/[id]/comments/route.ts index 3c2e45ac7..e38d30af3 100644 --- a/web/src/app/api/community/posts/[id]/comments/route.ts +++ b/web/src/app/api/community/posts/[id]/comments/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyCommentAdded } from "@/lib/community/server/notify"; import { getPostStub, newId } from "@/lib/community/server/queries"; @@ -40,14 +41,10 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "Post not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; - const text = cleanComment((payload as Record).body); + const text = cleanComment(payload.body); if (!text) { return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); } diff --git a/web/src/app/api/community/posts/[id]/pin/route.ts b/web/src/app/api/community/posts/[id]/pin/route.ts index b39f0ee18..10c216b56 100644 --- a/web/src/app/api/community/posts/[id]/pin/route.ts +++ b/web/src/app/api/community/posts/[id]/pin/route.ts @@ -2,6 +2,7 @@ import { eq, isNotNull } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { getPostStub } from "@/lib/community/server/queries"; import { posts } from "@/lib/community/server/schema"; @@ -36,13 +37,9 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str const post = await getPostStub(id); if (!post) return Response.json({ error: "Post not found." }, { status: 404 }); - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const pinned = (body as Record).pinned; + const body = await readJsonObject(request); + if (body instanceof Response) return body; + const pinned = body.pinned; if (typeof pinned !== "boolean") { return Response.json({ error: "Expected { pinned: true | false }." }, { status: 400 }); } diff --git a/web/src/app/api/community/posts/[id]/route.ts b/web/src/app/api/community/posts/[id]/route.ts index 5a607e70c..36ee7b0eb 100644 --- a/web/src/app/api/community/posts/[id]/route.ts +++ b/web/src/app/api/community/posts/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getPostStub } from "@/lib/community/server/queries"; @@ -79,13 +80,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Too many edits — wait a minute and try again." }, { status: 429 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // Partial update: only the fields present in the body change. const patch: { title?: string; body?: string; updatedAt: Date } = { updatedAt: new Date() }; diff --git a/web/src/app/api/community/posts/route.ts b/web/src/app/api/community/posts/route.ts index 9ce0d9f9e..45f3d970b 100644 --- a/web/src/app/api/community/posts/route.ts +++ b/web/src/app/api/community/posts/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { cleanPinNote, cleanTerritoryCode } from "@/lib/community/workshop"; import { notifyNewThread } from "@/lib/community/server/notify"; @@ -43,14 +44,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many posts — wait a minute and try again." }, { status: 429 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) { return Response.json({ error: "Title is empty or too long." }, { status: 400 }); diff --git a/web/src/app/api/community/presence/route.ts b/web/src/app/api/community/presence/route.ts index f5c26ae0c..0385ef7df 100644 --- a/web/src/app/api/community/presence/route.ts +++ b/web/src/app/api/community/presence/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { STAGE_HEIGHT, @@ -69,12 +70,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many moves — stand still a moment." }, { status: 429 }); } - let payload: Record; - try { - payload = (await request.json()) as Record; - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; const hasPosition = payload.x !== undefined || payload.y !== undefined; const hasStatus = payload.status !== undefined; diff --git a/web/src/app/api/community/reports/[id]/route.ts b/web/src/app/api/community/reports/[id]/route.ts index b747578a4..d1d9940af 100644 --- a/web/src/app/api/community/reports/[id]/route.ts +++ b/web/src/app/api/community/reports/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { reports } from "@/lib/community/server/schema"; @@ -34,13 +35,9 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Not found." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const status = (body as Record).status; + const body = await readJsonObject(request); + if (body instanceof Response) return body; + const status = body.status; if (typeof status !== "string" || !STATUSES.includes(status as never)) { return Response.json({ error: "Unknown status." }, { status: 400 }); } diff --git a/web/src/app/api/community/reports/route.ts b/web/src/app/api/community/reports/route.ts index dd2c24d0b..7f80c5666 100644 --- a/web/src/app/api/community/reports/route.ts +++ b/web/src/app/api/community/reports/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { newId, reportTarget } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; @@ -35,13 +36,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many reports — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (!isReportTargetType(raw.targetType)) { return Response.json({ error: "Unknown report target." }, { status: 400 }); diff --git a/web/src/app/api/community/territories/[code]/route.ts b/web/src/app/api/community/territories/[code]/route.ts index cec3a0083..7e554b835 100644 --- a/web/src/app/api/community/territories/[code]/route.ts +++ b/web/src/app/api/community/territories/[code]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { QUESTION_MAX, @@ -78,13 +79,8 @@ async function handlePatch(request: Request, context: Context) { if (resolved.error) return resolved.error; const { territory } = resolved; - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // Every field is optional — the editor sends what changed, and archiving // arrives as its own one-key body. diff --git a/web/src/app/api/community/territories/route.ts b/web/src/app/api/community/territories/route.ts index 231bd7b46..eb5e3b08a 100644 --- a/web/src/app/api/community/territories/route.ts +++ b/web/src/app/api/community/territories/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { cleanQuestions, @@ -39,13 +40,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const code = cleanTerritoryCode(raw.code); if (!code) { diff --git a/web/src/app/build/breadboard/breadboard.module.css b/web/src/app/build/breadboard/breadboard.module.css new file mode 100644 index 000000000..e46b71b93 --- /dev/null +++ b/web/src/app/build/breadboard/breadboard.module.css @@ -0,0 +1,81 @@ +/* A step photo sits under its diagram, and on a wide screen beside the step, + in the space between the 772px column and the window's edge. */ +.photo { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + margin-top: 16px; +} + +.photo img { + width: 100%; + height: auto; + display: block; + border-radius: 8px; + border: 1px solid var(--pf-rule); +} + +/* The photo is centred in that space and at most 360px wide. 50vw - 394px is + the space with room left for an 8px half of a desktop scrollbar. The photo + used to sit 120px out at a fixed 360px, which needs a 1,732px window: at + 1440px the page scrolled 146px sideways and the left-hand photos were cut + off where no scroll reaches. */ +@media (min-width: 1280px) { + .photoRight, + .photoLeft { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: calc(50vw - 394px); + margin-top: 0; + padding: 0 32px; + align-items: center; + } + + .photoRight { + left: 100%; + } + + .photoLeft { + right: 100%; + } + + .photoRight > *, + .photoLeft > * { + width: 100%; + max-width: 360px; + } +} + +@media print { + .page { + background: #ffffff !important; + color: #000000 !important; + } + + .page main { + padding: 0 !important; + max-width: none !important; + } + + .page section { + break-inside: avoid; + page-break-inside: avoid; + } + + .page header { + display: none !important; + } + + .photoRight, + .photoLeft { + position: static; + margin-top: 16px; + transform: none; + width: 100%; + max-width: 440px; + padding: 0; + align-items: stretch; + } +} diff --git a/web/src/app/build/breadboard/layout.tsx b/web/src/app/build/breadboard/layout.tsx new file mode 100644 index 000000000..d1e867d88 --- /dev/null +++ b/web/src/app/build/breadboard/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; + +// The metadata lives here from when page.tsx was a client component, which +// cannot export it. Without it the guide's browser tab and its link preview +// were the home page's. +export const metadata: Metadata = { + title: "Breadboard Build Guide / Patternflow", + description: + "Build Patternflow with no custom PCB: one snapped-off breadboard power strip and a handful of jumper wires. The guide leads with the picture, because the wiring is the hard part.", + alternates: { canonical: "/build/breadboard" }, + openGraph: { ...SITE_OPEN_GRAPH, url: "/build/breadboard" }, +}; + +export default function BreadboardLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/src/app/build/breadboard/page.tsx b/web/src/app/build/breadboard/page.tsx index bdb2b9b42..9435eb456 100644 --- a/web/src/app/build/breadboard/page.tsx +++ b/web/src/app/build/breadboard/page.tsx @@ -1,12 +1,10 @@ -'use client'; - -import React from 'react'; import Link from 'next/link'; import BreadboardDiagram from '@/components/docs/BreadboardDiagram'; +import styles from './breadboard.module.css'; export default function BreadboardBuildPage() { return ( -
+
{/* Navigation Header */}
⚡ Flash the firmware first

- Flashing is fiddly once everything is wired into a tangle of jumpers. Connect the bare ESP32-S3 to your computer by its left USB-C port (the native USB one, or the Wi-Fi step never appears), open the{' '} - Pattern tab and click “Flash Patternflow” under Got the hardware? (Chrome / Edge desktop). Confirm it boots — then start the build. + Flashing is fiddly once everything is wired into a tangle of jumpers, so do it on the bare ESP32-S3. Connect it to your computer by its left USB-C port (the native USB one, with a cable that carries data), then put it in download mode: hold BOOT, tap RST, let go of BOOT. Now open the{' '} + Pattern tab, click “Flash Patternflow” under Got the hardware? (Chrome / Edge desktop) and pick the port, listed as USB JTAG/serial debug unit. +

+

+ The Wi-Fi step usually doesn’t appear after the install, and that’s expected: press RST, click Flash Patternflow again, pick the port and choose Connect to Wi-Fi (2.4 GHz only). The dialog opening on Patternflow’s own menu is your sign it boots — then start the build.{' '} + The Play guide’s 01 Flash shows every screen, and the panel’s own network for when the Wi-Fi won’t take.

Prefer Arduino IDE? See{' '} @@ -386,11 +388,15 @@ 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

↑ Encoders fixed, GND wires on the − rail. @@ -427,11 +433,15 @@ export default function BreadboardBuildPage() {

↑ U1 (ESP32-S3) on the left, and J1 (HUB75E) on the right. Wire the pins that share the same color-coded labels (e.g. R1, B1, A, B...).

-
+
+ {/* 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

↑ ESP32-S3 with HUB75E jumpers connected. @@ -468,11 +478,15 @@ export default function BreadboardBuildPage() {

↑ Four rotary encoders on the left, and ESP32 on the right. Wire the signal channels (A, B, SW) to their matching colored tags (e.g. 1A, 2A...).

-
+
+ {/* 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

↑ Encoder A, B, SW wires all connected. @@ -506,11 +520,15 @@ export default function BreadboardBuildPage() {

-
+
+ {/* 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

↑ Power leads stripped and twisted onto jumpers. @@ -549,10 +567,15 @@ export default function BreadboardBuildPage() { { src: '/builds/breadboard/step8.jpg', label: "It's alive! 🎉" } ].map((img, idx) => (

+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} {img.label}

{img.label} @@ -608,57 +631,6 @@ export default function BreadboardBuildPage() {

- - {/* Scoped style to support printing and avoiding split sections */} -
); } diff --git a/web/src/app/build/page.tsx b/web/src/app/build/page.tsx index 145bd2060..be5bbfe0c 100644 --- a/web/src/app/build/page.tsx +++ b/web/src/app/build/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import HomeView from "@/components/HomeView"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; export const metadata: Metadata = { title: "Build Your Own — Patternflow", @@ -7,6 +8,7 @@ export const metadata: Metadata = { "Build the open-source Patternflow LED synthesizer from scratch: the hand-soldered v3.9 board, a printed or laser-cut case, and firmware flashed from the browser. Every file and the full build guide.", alternates: { canonical: "/build" }, openGraph: { + ...SITE_OPEN_GRAPH, title: "Build Your Own — Patternflow", description: "Build the open-source Patternflow LED synthesizer from scratch — firmware, PCB, a printed or laser-cut case, and the browser flasher.", diff --git a/web/src/app/business/page.tsx b/web/src/app/business/page.tsx index f89de0e26..e553eb18c 100644 --- a/web/src/app/business/page.tsx +++ b/web/src/app/business/page.tsx @@ -1,5 +1,9 @@ import { redirect } from "next/navigation"; +// /business was never a page. It has sent people to /contact since the contact +// page arrived on 12 May 2026 (bbb6a21a), and that page still wears the +// business-* classes in globals.css. Nothing in the repository links /business. + export default function BusinessPage() { redirect("/contact"); } diff --git a/web/src/app/community/layout.tsx b/web/src/app/community/layout.tsx index 6e7b86002..978a12ce1 100644 --- a/web/src/app/community/layout.tsx +++ b/web/src/app/community/layout.tsx @@ -22,9 +22,16 @@ export const metadata: Metadata = { export default async function CommunityLayout({ children }: { children: React.ReactNode }) { // Community pages only run where the SQLite database lives (the self-hosted - // deployment). Everywhere else — e.g. the Vercel site — this path is just an - // old or mistyped URL, so send the visitor straight to the community host - // rather than parking them on a page whose only content is another link. + // deployment). Everywhere else, e.g. the Vercel site, this path is reached + // through links that name patternflow.work instead of the community host, + // and the community writes one into every pattern: since 22 July 2026 + // (edaa07da) each published pattern's licence footer has carried + // patternflow.work/community, and a fork's credit line links + // /community/p/ (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; @@ -73,7 +80,7 @@ export default async function CommunityLayout({ children }: { children: React.Re
- + Patternflow diff --git a/web/src/app/contact/page.tsx b/web/src/app/contact/page.tsx index 7edfc9998..eada130df 100644 --- a/web/src/app/contact/page.tsx +++ b/web/src/app/contact/page.tsx @@ -1,10 +1,12 @@ import type { Metadata } from "next"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; import ContactPage from "@/components/contact/ContactPage"; export const metadata: Metadata = { title: "Contact / Patternflow", description: "Partnership, installation, distribution, and custom AV inquiries for Patternflow.", + openGraph: { ...SITE_OPEN_GRAPH, url: "/contact" }, }; export default function ContactRoute() { diff --git a/web/src/app/editions/EditionCard.tsx b/web/src/app/editions/EditionCard.tsx index a8e451191..ad450d9fb 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/TryInstall.tsx b/web/src/app/features/TryInstall.tsx index 60f1281c6..787c3f2cb 100644 --- a/web/src/app/features/TryInstall.tsx +++ b/web/src/app/features/TryInstall.tsx @@ -20,7 +20,7 @@ import type { TryOut } from "./features-data"; const SITE = "https://patternflow.work"; export default function TryInstall({ t }: { t: TryOut }) { - const { deviceHost, changeDeviceHost } = useDeviceHost(); + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); // The panel's page fetches the image, so the URL has to be absolute. The // production origin is the server-side answer, so the first paint matches; // a preview or a dev server reads its own in on the client. A store that @@ -32,7 +32,6 @@ export default function TryInstall({ t }: { t: TryOut }) { () => SITE, ); - const host = deviceHost.trim() || "patternflow.local"; const src = new URL(t.url, origin).toString(); const href = `http://${host}/update?src=${encodeURIComponent(src)}`; diff --git a/web/src/app/features/features-data.ts b/web/src/app/features/features-data.ts index bfd7a9cdd..f618868ad 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/features/page.tsx b/web/src/app/features/page.tsx index e1c54df02..ca1e00e07 100644 --- a/web/src/app/features/page.tsx +++ b/web/src/app/features/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; import shelf from "../editions/Editions.module.css"; import ShelfTabs from "../editions/ShelfTabs"; import styles from "./Features.module.css"; @@ -26,6 +27,7 @@ export const metadata: Metadata = { description: "What a Patternflow panel can do, one feature at a time: a microphone, audio from a browser or phone, OSC, MIDI, a clock, MQTT, sequences, the weather. What each needs, which firmware carries it, and a reel of it running.", alternates: { canonical: "/features" }, + openGraph: { ...SITE_OPEN_GRAPH, url: "/features" }, }; export const dynamic = "force-static"; @@ -102,7 +104,7 @@ export default function FeaturesPage() {
- + Patternflow diff --git a/web/src/app/feed.xml/route.ts b/web/src/app/feed.xml/route.ts index 59022f81d..31492646e 100644 --- a/web/src/app/feed.xml/route.ts +++ b/web/src/app/feed.xml/route.ts @@ -3,6 +3,11 @@ import { getAllJournalPosts } from "@/lib/journal"; const siteUrl = "https://patternflow.work"; +// Built once with the site. A GET handler runs on every request unless it +// says otherwise, and this one reads content/journal, which is on disk while +// the site builds and nowhere inside a Cloudflare Worker. +export const dynamic = "force-static"; + export function GET() { const posts = getAllJournalPosts(); const feed = new Feed({ diff --git a/web/src/app/fonts.test.ts b/web/src/app/fonts.test.ts new file mode 100644 index 000000000..ad9f50b08 --- /dev/null +++ b/web/src/app/fonts.test.ts @@ -0,0 +1,223 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { TextDecoder } from 'node:util'; +import { brotliDecompressSync } from 'node:zlib'; +import { describe, expect, it, vi } from 'vitest'; + +// Pretendard's parts against the @font-face rules in globals.css: each +// weight's ranges still cover all of Unicode, the Hangul is the 2,350 +// syllables of KS X 1001 in one file and the other 8,822 in another, and the +// common file is the one the browser tries first. Reads each WOFF2's cmap. +// Then the Google fonts the root layout loads, and which of them every page +// preloads. + +const FONTS = path.resolve(__dirname, '../../public/fonts'); +const CSS = fs.readFileSync(path.resolve(__dirname, 'globals.css'), 'utf8'); + +/** The one Hangul file a Korean page fetched before the split: Regular 593,848 bytes, Bold 617,400. */ +const COMMON_BUDGET = 170_000; + +type Range = [number, number]; + +interface Face { + weight: number; + file: string; + ranges: Range[]; +} + +function parseRanges(value: string): Range[] { + return value.split(',').map((part) => { + const [from, to] = part.trim().replace(/^U\+/i, '').split('-'); + return [parseInt(from, 16), parseInt(to ?? from, 16)]; + }); +} + +const faces: Face[] = [...CSS.matchAll(/@font-face\s*{([^}]*)}/g)] + .map((m) => m[1]) + .filter((body) => /font-family:\s*'Pretendard'/.test(body)) + .map((body) => ({ + weight: Number(/font-weight:\s*(\d+)/.exec(body)![1]), + file: /url\('\/fonts\/([^']+)'\)/.exec(body)![1], + ranges: parseRanges(/unicode-range:\s*([^;]+);/.exec(body)![1]), + })); + +const inRanges = (ranges: Range[], cp: number) => ranges.some(([a, b]) => cp >= a && cp <= b); + +/** UIntBase128 from the WOFF2 table directory. */ +function base128(buf: Buffer, at: { pos: number }): number { + let value = 0; + for (let i = 0; i < 5; i++) { + const byte = buf[at.pos++]; + value = value * 128 + (byte & 0x7f); + if (!(byte & 0x80)) return value; + } + throw new Error('UIntBase128 longer than five bytes'); +} + +/** The code points a WOFF2 maps to a glyph other than .notdef. */ +function woff2Cmap(file: string): Set { + const buf = fs.readFileSync(path.join(FONTS, file)); + expect(buf.toString('latin1', 0, 4), `${file} is not WOFF2`).toBe('wOF2'); + const numTables = buf.readUInt16BE(12); + const compressedSize = buf.readUInt32BE(20); + const at = { pos: 48 }; + let offset = 0; + let cmap: { offset: number; length: number } | null = null; + for (let i = 0; i < numTables; i++) { + const flags = buf[at.pos++]; + const known = flags & 0x3f; + const tag = known === 0x3f ? buf.toString('latin1', at.pos, (at.pos += 4)) : known; + const origLength = base128(buf, at); + const version = flags >> 6; + // glyf (10) and loca (11) are transformed at version 0; every other table at any other version. + const transformed = tag === 10 || tag === 11 ? version === 0 : version !== 0; + const length = transformed ? base128(buf, at) : origLength; + if (tag === 0) cmap = { offset, length }; + offset += length; + } + expect(cmap, `${file} has no cmap`).not.toBeNull(); + const data = brotliDecompressSync(buf.subarray(at.pos, at.pos + compressedSize)); + const table = data.subarray(cmap!.offset, cmap!.offset + cmap!.length); + + const subtables = new Map(); + for (let i = 0; i < table.readUInt16BE(2); i++) { + const rec = 4 + i * 8; + subtables.set(`${table.readUInt16BE(rec)},${table.readUInt16BE(rec + 2)}`, table.readUInt32BE(rec + 4)); + } + const cps = new Set(); + const full = subtables.get('3,10') ?? subtables.get('0,4'); + if (full !== undefined) { + expect(table.readUInt16BE(full), `${file}: full-repertoire cmap is not format 12`).toBe(12); + const groups = table.readUInt32BE(full + 12); + for (let g = 0; g < groups; g++) { + const rec = full + 16 + g * 12; + const start = table.readUInt32BE(rec); + const end = table.readUInt32BE(rec + 4); + const glyph = table.readUInt32BE(rec + 8); + for (let cp = start; cp <= end; cp++) if (glyph + cp - start !== 0) cps.add(cp); + } + return cps; + } + const bmp = subtables.get('3,1') ?? subtables.get('0,3'); + expect(bmp, `${file} has no Unicode cmap`).toBeDefined(); + expect(table.readUInt16BE(bmp!), `${file}: BMP cmap is not format 4`).toBe(4); + const segs = table.readUInt16BE(bmp! + 6) / 2; + const ends = bmp! + 14; + const starts = ends + segs * 2 + 2; + const deltas = starts + segs * 2; + const rangeOffsets = deltas + segs * 2; + for (let s = 0; s < segs; s++) { + const end = table.readUInt16BE(ends + s * 2); + const start = table.readUInt16BE(starts + s * 2); + const delta = table.readInt16BE(deltas + s * 2); + const rangeOffset = table.readUInt16BE(rangeOffsets + s * 2); + for (let cp = start; cp <= end; cp++) { + let glyph: number; + if (rangeOffset === 0) glyph = (cp + delta) & 0xffff; + else { + glyph = table.readUInt16BE(rangeOffsets + s * 2 + rangeOffset + (cp - start) * 2); + if (glyph !== 0) glyph = (glyph + delta) & 0xffff; + } + if (glyph !== 0) cps.add(cp); + } + } + return cps; +} + +/** The 2,350 Hangul syllables of KS X 1001: EUC-KR lead bytes B0–C8, trail bytes A1–FE. */ +function ksx1001Syllables(): Set { + const decoder = new TextDecoder('euc-kr'); + const out = new Set(); + for (let lead = 0xb0; lead <= 0xc8; lead++) { + for (let trail = 0xa1; trail <= 0xfe; trail++) { + const cp = decoder.decode(Uint8Array.of(lead, trail)).codePointAt(0)!; + if (cp >= 0xac00 && cp <= 0xd7a3) out.add(cp); + } + } + return out; +} + +const isSyllable = (cp: number) => cp >= 0xac00 && cp <= 0xd7a3; + +describe.each([ + [400, 'Regular'], + [700, 'Bold'], +] as const)('Pretendard %i', (weight, name) => { + const parts = faces.filter((face) => face.weight === weight); + const common = `Pretendard-${name}.hangul.woff2`; + const rare = `Pretendard-${name}.hangul-rare.woff2`; + + it('covers all of Unicode between its parts, as the whole file did', () => { + const ranges = parts.flatMap((face) => face.ranges).sort((x, y) => x[0] - y[0]); + let next = 0; + for (const [a, b] of ranges) { + expect(a, `nothing covers U+${next.toString(16).toUpperCase()}`).toBeLessThanOrEqual(next); + next = Math.max(next, b + 1); + } + expect(next).toBe(0x110000); + }); + + it('writes hangul-rare before hangul, so the browser tries hangul first', () => { + const files = parts.map((face) => face.file); + expect(files).toContain(common); + expect(files).toContain(rare); + expect(files.indexOf(rare)).toBeLessThan(files.indexOf(common)); + for (const file of [common, rare]) { + const face = parts.find((p) => p.file === file)!; + expect(inRanges(face.ranges, 0xac00) && inRanges(face.ranges, 0xd7a3), `${file} claims the syllable block`).toBe(true); + } + }); + + it('keeps each glyph of each part inside that part\'s unicode-range', () => { + for (const face of parts) { + const outside = [...woff2Cmap(face.file)].filter((cp) => !inRanges(face.ranges, cp)); + expect(outside.map((cp) => `U+${cp.toString(16).toUpperCase()}`), face.file).toEqual([]); + } + }); + + it('puts the jamo and the 2,350 syllables of KS X 1001 in hangul, and the other 8,822 in hangul-rare', () => { + const ks = ksx1001Syllables(); + expect(ks.size).toBe(2350); + const commonCps = woff2Cmap(common); + const rareCps = woff2Cmap(rare); + expect([...commonCps].filter(isSyllable).sort()).toEqual([...ks].sort()); + // Hangul Compatibility Jamo, ㄱ to ㅣ, as typed alone. + for (let cp = 0x3131; cp <= 0x3163; cp++) expect(commonCps.has(cp)).toBe(true); + expect([...rareCps].every(isSyllable)).toBe(true); + expect(rareCps.size).toBe(11172 - 2350); + expect([...rareCps].filter((cp) => ks.has(cp))).toEqual([]); + }); + + it('keeps a Korean page\'s Hangul under a quarter of the file it replaced', () => { + expect(fs.statSync(path.join(FONTS, common)).size).toBeLessThan(COMMON_BUDGET); + }); +}); + +// Each loader call the root layout makes, with its options. A font added there +// without a line here fails the import: the mock has no export for it. +const googleFonts = vi.hoisted(() => [] as { name: string; preload?: boolean }[]); +vi.mock('next/font/google', () => { + const loader = (name: string) => (options: { preload?: boolean }) => { + googleFonts.push({ name, preload: options.preload }); + return { variable: '', className: '' }; + }; + return { + Inter: loader('Inter'), + JetBrains_Mono: loader('JetBrains_Mono'), + Silkscreen: loader('Silkscreen'), + Newsreader: loader('Newsreader'), + }; +}); + +describe('the root layout\'s Google fonts', () => { + // Next puts a preload link for each font the root layout loads in the head + // of every page. On 22 October DM Sans, which no text used, and Newsreader + // and Silkscreen, which only the journal, /terms and the guide use, were + // 166,368 bytes on / and on /pattern-lab that no letter there was set in. + it('preloads only Inter and JetBrains Mono, the two every page sets text in', async () => { + await import('@/app/layout'); + const preloaded = googleFonts.filter((font) => font.preload !== false).map((font) => font.name); + expect(preloaded.sort()).toEqual(['Inter', 'JetBrains_Mono']); + expect(googleFonts.map((font) => font.name).sort()).toEqual(['Inter', 'JetBrains_Mono', 'Newsreader', 'Silkscreen']); + }); +}); diff --git a/web/src/app/globals.css b/web/src/app/globals.css index a1360c8c2..9c2339379 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,13 +1,32 @@ @import "tailwindcss"; - /* Pretendard, each weight in four parts. The whole file is 770–790 KB, most + /* Pretendard, each weight in five parts. The whole file is 770–790 KB, most of it Hangul, and a page fetched all of it for one glyph Inter lacks — an English page for the arrow in a link. The parts are the same glyphs, outlines and metrics, cut by script (pyftsubset on the files beside them); their ranges cover all of Unicode between them, so whatever the whole file drew, one part still draws. A page now fetches only the parts it has characters for: an arrow costs 38 KB, and the Hangul comes with - the first Korean word. */ + the first Korean word. + + The Hangul is two parts. hangul holds the jamo and the 2,350 syllables + of KS X 1001 in 158–161 KB; on 21 October 2026 those were every + syllable in web/src and web/content. hangul-rare holds the other 8,822 + in 463–484 KB. Both claim the syllable block, and hangul-rare is + written first on purpose: where two faces' ranges overlap, the browser + tries the one written last and moves to the earlier one only for a + syllable it has no glyph for. A Korean page fetches hangul alone, and + hangul-rare waits for a name like 똠 or 뷁. Listing the 2,350 syllables + in unicode-range instead takes 1,613 runs, 29 KB of CSS on every page, + English ones included. + + Cut with fonttools 4.67 from the files beside them: + pyftsubset Pretendard-Regular.woff2 --unicodes-file= + --layout-features='*' --name-IDs='*' --flavor=woff2 + where the hangul list is U+1100-11FF, U+3130-318F, U+A960-A97F, + U+D7B0-D7FF and every syllable Python's euc_kr codec decodes from + lead bytes B0-C8, trail bytes A1-FE; the hangul-rare list is the rest + of U+AC00-D7A3. src/app/fonts.test.ts holds the files to that split. */ @font-face { font-family: 'Pretendard'; font-weight: 400; @@ -16,6 +35,14 @@ src: url('/fonts/Pretendard-Regular.latin.woff2') format('woff2'); unicode-range: U+0000-052F, U+1E00-1EFF; } + @font-face { + font-family: 'Pretendard'; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url('/fonts/Pretendard-Regular.hangul-rare.woff2') format('woff2'); + unicode-range: U+AC00-D7A3; + } @font-face { font-family: 'Pretendard'; font-weight: 400; @@ -48,6 +75,14 @@ src: url('/fonts/Pretendard-Bold.latin.woff2') format('woff2'); unicode-range: U+0000-052F, U+1E00-1EFF; } + @font-face { + font-family: 'Pretendard'; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url('/fonts/Pretendard-Bold.hangul-rare.woff2') format('woff2'); + unicode-range: U+AC00-D7A3; + } @font-face { font-family: 'Pretendard'; font-weight: 700; @@ -203,6 +238,16 @@ .viewer-hint { top: 40px; } + /* Where the 3D view would be, in a browser that cannot draw it (ViewerPanel). */ + .viewer-off { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; + } @media (max-width: 900px) { .app-layout { flex-direction: column; } @@ -369,6 +414,31 @@ border-color: var(--ink); text-decoration: none; } + /* Stop and Play for the hero video, in its bottom-right corner. Dark with a + light rule, so it shows on the poster and on the video. 40px square, for a + finger on a phone. The focus ring stays inside the frame, which clips + anything past its edge. */ + .hero-video-toggle { + position: absolute; + right: 10px; + bottom: 10px; + width: 40px; + height: 40px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(244, 239, 230, 0.55); + background: rgba(20, 20, 20, 0.62); + color: var(--cream); + cursor: pointer; + transition: background-color 0.15s ease; + } + .hero-video-toggle:hover { background: rgba(20, 20, 20, 0.85); } + .hero-video-toggle:focus-visible { + outline: 2px solid var(--pf-led); + outline-offset: 2px; + } /* At laptop height the column — wordmark, video, the two facts, the buttons — can be taller than the window with the full 80px above it, and the buttons' note falls below the fold. Take the difference from the top. The nav is out @@ -580,7 +650,7 @@ .panel-header { margin-bottom: 40px; } - .panel-header h2 { + .panel-header h1 { font-weight: 500; font-size: 64px; line-height: 1; @@ -793,7 +863,7 @@ @media (max-width: 900px) { .tab-bar { padding: 20px 24px 16px; gap: 20px; } .deck-content { padding: 0 24px 60px; } - .panel-header h2 { font-size: 42px; } + .panel-header h1 { font-size: 42px; } .panel-header .sub { font-size: 16px; } .panel-body p { font-size: 17px; } .panel-content { padding: 40px 0; min-height: auto; } diff --git a/web/src/app/homeLinkPrefetch.test.ts b/web/src/app/homeLinkPrefetch.test.ts new file mode 100644 index 000000000..754175524 --- /dev/null +++ b/web/src/app/homeLinkPrefetch.test.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// A in view prefetches the home page, and the home page's +// client chunks come with it: three.js (237,537 bytes gzip) and the +// post-processing chunk (212,365). At 390px in Chromium on 22 October, +// /terms, /contact and a 404 fetched 767,286 bytes of script with the +// prefetch and 148,456 without, and /community 987,765 and 408,515. With +// prefetch={false} the home page loads when the link is followed. Pattern +// Lab (579,250 bytes) and the guide (107,109) still prefetch it, as do the +// breadboard guide, journal, editions and compliance. They are left out +// below until they are done the same way. + +const SRC = path.resolve(__dirname, '..'); +const LEFT_OUT = [ + 'components/guide', + 'app/build/breadboard', + 'app/pattern-lab', + 'components/journal', + 'app/editions', + 'app/compliance', +]; + +function sources(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sources(full); + return entry.name.endsWith('.tsx') && !entry.name.includes('.test.') ? [full] : []; + }); +} + +/** Each `` opening tag, read to the `>` outside any braces. */ +function linkTags(source: string): string[] { + const tags: string[] = []; + for (const m of source.matchAll(/' && depth === 0) break; + } + tags.push(source.slice(m.index, end + 1)); + } + return tags; +} + +const homeLinks = sources(SRC).flatMap((file) => { + const rel = path.relative(SRC, file).split(path.sep).join('/'); + return linkTags(fs.readFileSync(file, 'utf8')) + .filter((tag) => /\bhref="\/"/.test(tag)) + .map((tag) => ({ rel, tag })); +}); + +describe('links to the home page', () => { + it('are still found', () => { + expect(homeLinks.map((l) => l.rel)).toContain('app/not-found.tsx'); + }); + + it('skip the prefetch outside the pages left out', () => { + const prefetching = homeLinks + .filter(({ rel }) => !LEFT_OUT.some((dir) => rel.startsWith(`${dir}/`))) + .filter(({ tag }) => !/\bprefetch=\{false\}/.test(tag)) + .map(({ rel }) => rel); + expect(prefetching).toEqual([]); + }); +}); diff --git a/web/src/app/hoverUnderline.test.ts b/web/src/app/hoverUnderline.test.ts new file mode 100644 index 000000000..d227d6a25 --- /dev/null +++ b/web/src/app/hoverUnderline.test.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// globals.css underlines every link on hover with `a:hover`, which is (0,1,1). +// A CSS module class that says `text-decoration: none` is (0,1,0) and loses +// to it, so a link drawn as a button or a card grew an underline when pointed +// at: 453 links on 16 pages in Chromium on 21 October, and the guide's +// "Stuck? Tell us" drew one under the line it already draws. Each such class +// says it again in a `:hover` rule. Community (31 classes), editions and +// compliance still lose. They are left out below until they are done the +// same way. + +const SRC = path.resolve(__dirname, '..'); +const LEFT_OUT = ['components/community', 'app/community', 'app/editions', 'app/compliance', 'app/journal']; + +function modules(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return modules(full); + return entry.name.endsWith('.module.css') ? [full] : []; + }); +} + +interface Rule { + selectors: string[]; + body: string; +} + +/** Every rule with no braces inside it; an @media block's rules come out on their own. */ +function rules(css: string): Rule[] { + const bare = css.replace(/\/\*[\s\S]*?\*\//g, ''); + return [...bare.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ + selectors: m[1].split(',').map((s) => s.trim()), + body: m[2], + })); +} + +const NO_LINE = /text-decoration(-line)?:\s*none/; + +/** Classes that turn the line off on their own, with no :hover rule saying anything about it. */ +function losing(css: string): string[] { + const all = rules(css); + const off = new Set( + all.flatMap((r) => (NO_LINE.test(r.body) ? r.selectors.filter((s) => /^\.[\w-]+$/.test(s)) : [])), + ); + return [...off].filter((cls) => { + const own = all.filter((r) => r.selectors.includes(cls)); + // A class that takes no pointer is never hovered (update's .goDisabled). + if (own.some((r) => /pointer-events:\s*none/.test(r.body))) return false; + return !all.some((r) => r.selectors.includes(`${cls}:hover`) && /text-decoration/.test(r.body)); + }); +} + +describe('hover underline', () => { + it('is still the global rule the classes have to beat', () => { + const globals = fs.readFileSync(path.join(SRC, 'app/globals.css'), 'utf8'); + expect(globals).toMatch(/\n\s*a:hover \{ text-decoration: underline;/); + }); + + it('stays off on hover for every class that turns it off', () => { + const files = modules(SRC).filter((f) => !LEFT_OUT.some((dir) => f.includes(`${path.sep}${dir}${path.sep}`))); + const found = files.flatMap((f) => + losing(fs.readFileSync(f, 'utf8')).map((cls) => `${path.relative(SRC, f)} ${cls}`), + ); + expect(found).toEqual([]); + }); + + it('finds the class the rule was written for', () => { + expect(losing('.card { text-decoration: none; }\n.card:hover { color: red; }')).toEqual(['.card']); + expect(losing('.card { text-decoration: none; }\n.card:hover { text-decoration: none; }')).toEqual([]); + expect(losing('@media (x) {\n .card { text-decoration: none; }\n}')).toEqual(['.card']); + expect(losing('.row a { text-decoration: none; }')).toEqual([]); + }); +}); diff --git a/web/src/app/inside/[build]/page.tsx b/web/src/app/inside/[build]/page.tsx index 1e35b4074..1159c7634 100644 --- a/web/src/app/inside/[build]/page.tsx +++ b/web/src/app/inside/[build]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import HomeView from "@/components/HomeView"; import { builds, buildBySlug } from "@/components/sections/InsideGlobe/builds"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; const SITE_URL = "https://patternflow.work"; @@ -26,10 +27,12 @@ export async function generateMetadata({ params }: RouteParams): Promise ({ slug })); +} + type OpenGraphImageProps = { params: Promise<{ slug: string }>; }; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index bd827b842..93758472c 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,11 +1,11 @@ import type { Metadata } from "next"; -import { Inter, JetBrains_Mono, DM_Sans, Silkscreen, Newsreader } from "next/font/google"; +import { Inter, JetBrains_Mono, Silkscreen, Newsreader } from "next/font/google"; +import Script from "next/script"; import "./globals.css"; import { PostHogProvider } from "@/providers/PostHogProvider"; -import { Analytics } from "@vercel/analytics/next"; import DeviceHostCapture from "@/components/DeviceHostCapture"; -import { SpeedInsights } from "@vercel/speed-insights/next"; import { LEGACY_GUIDE_REDIRECT } from "@/components/guide/legacy"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; const inter = Inter({ variable: "--font-inter", @@ -17,15 +17,15 @@ const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], }); -const dmSans = DM_Sans({ - variable: "--font-dm-sans", - subsets: ["latin"], -}); - +// Next puts a preload link for each font loaded here in the head of every +// page. Silkscreen (the guide's kickers) and Newsreader (the journal, /terms, +// the guide's notes) are fetched only by the pages whose text uses them +// (app/fonts.test.ts). const silkscreen = Silkscreen({ weight: ['400', '700'], variable: "--font-silkscreen", subsets: ["latin"], + preload: false, }); const newsreader = Newsreader({ @@ -33,6 +33,7 @@ const newsreader = Newsreader({ style: ["normal", "italic"], variable: "--font-newsreader", subsets: ["latin"], + preload: false, }); export const metadata: Metadata = { @@ -64,27 +65,13 @@ export const metadata: Metadata = { apple: "/apple-touch-icon.png", shortcut: "/favicon.ico", }, - openGraph: { - type: "website", - url: "https://patternflow.work", - title: "Patternflow — An LED synthesizer", - description: - "An open-source LED synthesizer. Play light patterns with your fingertips — and every Patternflow plays every pattern we make.", - images: [ - { - url: "/og-image.png", - width: 1200, - height: 630, - type: "image/png", - }, - ], - }, + // No title, description or url: those are each page's (lib/siteMetadata.ts). + // The home page's own are in page.tsx. + openGraph: SITE_OPEN_GRAPH, + // The card only. Next copies the title, description and picture into it + // from the page's openGraph, and it copies nothing over a title set here. twitter: { card: "summary_large_image", - title: "Patternflow — An LED synthesizer", - description: - "Patternflow is an open-source LED synthesizer. Play light with your fingertips.", - images: ["/og-image.png"], }, alternates: { types: { @@ -100,6 +87,12 @@ export const metadata: Metadata = { const siteUrl = "https://patternflow.work"; +// Cloudflare Web Analytics: visits and Core Web Vitals, without cookies. The +// token is the site's own, from the Cloudflare dashboard (Web Analytics, the +// manual setup), and it is read when the page is built, so it has to be in +// the environment of the build. Unset, the beacon is left out. +const webAnalyticsToken = process.env.NEXT_PUBLIC_CF_WEB_ANALYTICS_TOKEN; + // Structured data (schema.org / JSON-LD) so search engines and AI crawlers can // resolve Patternflow as an entity: who makes it, what category it belongs to, // and how the project, person, and work relate. @@ -184,12 +177,17 @@ export default function RootLayout({ /> {children} - - + {webAnalyticsToken && ( +