memlnaut-nisps/docs/specs/plans/hardware-editor-spec.md
monkey-w1n5t0n 60584606a8 docs(specs): P5 architectural specs — 5a, 5b, 5c, 5d
Plan §6 says each P5 item is spec-first with its own session. These are the
specs; no implementation is authorised by them and none was written.

  plans/mode-layer-reunification.md   5a — ALIGNMENT defect 1, the largest
                                      architectural gap. Storage-policies the
                                      ModeBase orchestration the way P2 did
                                      MLPCore, rather than binding monolithic
                                      mode objects into WASM (which would
                                      contradict the locked two-instance RT
                                      architecture).
  plans/browser-mode-coverage-spec.md 5b — an audio-topology notion so Manifold
                                      stops cataloguing 4 modes that
                                      structurally cannot run in a browser.
  plans/curated-presets-spec.md       5c — ALIGNMENT defect 2, built on the
                                      operator's §7.6 definition: a curated
                                      preset is configuration only, network
                                      untrained.
  plans/hardware-editor-spec.md       5d — ALIGNMENT defect 3, applying
                                      useq-celium's existing discipline (C
                                      header as wire truth + TS mirror + parity
                                      test) to a MEMLNaut serial protocol.

2434 lines. I spot-checked their path citations mechanically against
git ls-files: of 170 backticked paths, every unresolved one is either a file
the spec proposes to create or a cross-reference to a sibling spec in this same
commit. None describes deleted code as live — which is the failure mode that
made half the existing corpus untrustworthy, and the reason the §8 pass earlier
today had so much to do.

The preset spec is the most valuable byproduct: en route it found five places
where existing docs still describe a deleted world, including
manifold-parity-features-spec.md §1.1 specifying a PipelineLayer over
engine/input-pipeline.ts and output-pipeline.ts, both deleted at one-core P4.

Also carries the doc sync for the telemetry and benchmark work in a77770f:
AGENT-REFERENCE gains the throughput and loss-history entries, and dock-spec
§1.3 records that its long-deferred diagnostics suite shipped PARTLY — the loss
curve and weight-health table are real, GradientFlow is not built and is not
planned as drawn (the core records no per-layer gradient magnitudes, and the
fabricated version was deleted in Phase 1).
2026-07-21 22:03:39 +02:00

465 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
kind: plan
status: active
---
# Manifold as Hardware Editor — MEMLNaut USB-Serial Protocol, Firmware Command Surface, On-Device Persistence
*Dated 2026-07-21. Spec for plan item **§6.5d** (`simplification-plan.md:130`), audit findings **A4** / **S14** /
**L29**, ALIGNMENT **defect 3** (and the live half of **defect 5**). Vision bullet 5: "Manifold doubles as
interface/editor for the hardware MEMLNaut (settings, presets, training, examples, visualisation)."*
**Nothing here is implemented. No code was written for this document.** Every `file:line` below was read from
the working tree on 2026-07-21 and is marked ✓ (verified by reading the file) or ✗ (could not verify — stated
as unknown, never asserted). The tree was mid-flight with concurrent edits from other sessions
(`git status` showed 20+ modified files including `src/main.cpp`, `codegen/generate.ts`, `ConsoleApp.tsx`);
re-check citations in those files before relying on an exact line number.
---
## 1. Ground truth — what exists today
### 1.1 The browser end is a connect-lifecycle shell
| Fact | Evidence |
|---|---|
| `MemlnautSerial` handles `requestPort`/`open`/`close` and a 5-state status store, nothing else | ✓ `manifold/src/serial/memlnaut-serial.ts` (140 lines, not the 237 the audit reports — stale) |
| `saveModel` returns `false`, `restoreModel` returns `null`, `getSettings` returns `{}`, each with a `TODO: real protocol` | ✓ same file, the three methods under `// ---- Protocol stubs ----` |
| Baud 115200 is explicitly a placeholder | ✓ same file, `TODO(memlnaut-serial): negotiate the real baud rate` |
| The only consumer is `EditorPanel.tsx`; the only consumer of *that* is the Settings-drawer `editor` case | ✓ `manifold/src/console/Drawers.tsx:33` (import), `:579` (render). Repo-wide grep for `memlnaut-serial`/`getMemlnautSerial` finds no other importer. |
| `editor` is a top-level dock **output mode**, `audio: false`, description already promises "configure / save / restore" | ✓ `manifold/src/console/output-mode.ts:69-75` |
| The panel already carries an honest "not yet wired" note | ✓ `EditorPanel.tsx`, final `<p>` |
`manifold/src/serial/web-serial.d.ts` is an **ambient** (global) declaration file. Its header comment claims it
declares "just the surface memlnaut-serial.ts uses" — false: `manifold/src/backends/cv-backend.ts:56,95,123,133`
type-checks against the same global `SerialPort`/`navigator.serial`. There is one Web Serial *type* surface and
two *transport* implementations, not three (✓ grep for `navigator.serial|SerialPort` over `manifold/src` returns
exactly `web-serial.d.ts`, `cv-backend.ts`, `memlnaut-serial.ts`).
### 1.2 The firmware end has a USB CDC port, and nothing listening on it
| Fact | Evidence |
|---|---|
| `Serial` (USB CDC) is opened at boot and used **only** for diagnostics | ✓ `firmware/MEMLNaut-NISPS/src/main.cpp:125` (`Serial.begin(115200)`) |
| Core 0's `loop()` prints `"."` ~10×/s and a perf line ~1×/s onto that same stream | ✓ `src/main.cpp:166`, `:172` (`Serial.printf("ml: %d, aud: %d, q: %f\n", …)`) |
| **The USB CDC port is otherwise free**: MIDI runs on `Serial2` (UART1), not USB | ✓ `lib/memllib/src/interface/MIDIInOut.cpp:15` `MIDI_CREATE_CUSTOM_INSTANCE(HardwareSerial, Serial2, MIDI, …)`, `:81-88` |
| USB-MIDI (TinyUSB composite) is compiled out — `MIDI_USB_CLIENT` is defined nowhere | ✓ `MIDIInOut.cpp:55` is `#ifdef MIDI_USB_CLIENT`; grep across `platformio.ini`, `src/`, `glue/` finds no definition. The device enumerates as a plain CDC port. |
| There is no serial *reader* anywhere in firmware — no `Serial.read()`, no parser, no command dispatch | ✓ grep over `src/` + `glue/` |
| Core 0's control cadence is 5 ms (`ML_INFERENCE_PERIOD_US 5000`), core 1's drain 1 ms | ✓ `src/main.cpp:152-160`, `:208-216` |
### 1.3 There is no on-device persistence, but two mechanisms are already compiled in
- **LittleFS** (internal flash). memllib wraps it in `lib/memllib/src/hardware/FlashFS.hpp` ✓ — which has **zero
consumers** in this repo (✓ grep for `FlashFS` hits only vendored TFT_eSPI example sketches under `.pio/`).
Two problems with that wrapper: it defines `void begin()` and `bool exists()` as **non-`inline` free functions
in a header** (✓ read in full — 22 lines), so including it from two translation units is a duplicate-symbol
link error; and using it at all buys nothing over calling `LittleFS` directly.
- **The filesystem is currently sized to zero.** `platformio.ini` does not set `board_build.filesystem_size`
(✓ grep), and the platform's default is `"0MB"` (✓ `~/.platformio/platforms/raspberrypi/builder/main.py:63`).
With `_size == 0`, `LittleFS.begin()` returns `false` immediately (✓
`~/.platformio/packages/framework-arduinopico/libraries/LittleFS/src/LittleFS.h:173`). **On-device flash
persistence therefore requires a `platformio.ini` change that alters the flash layout of all 16 envs.**
- **LittleFS writes park core 1.** ✓ `…/LittleFS/src/LittleFS.cpp:186-193` and `:203-209`:
`noInterrupts(); rp2040.idleOtherCore(); flash_range_program/flash_range_erase(...); rp2040.resumeOtherCore();
interrupts();`. Core 1 is the audio core. **Any flash write stalls audio for the duration of the erase +
program.** This is the single hardest constraint in this document.
- **SD card.** memllib ships `interface/SDCard.{hpp,cpp}` (SdFat over hardware SPI) ✓, the MEMLNaut board has
dedicated SD pins ✓ `lib/memllib/src/hardware/memlnaut/Pins.hpp:87-90` (`SD_CS 13`, `SD_SCK 14`, `SD_MISO 12`,
`SD_MOSI 15`), and `SDCard.cpp` **already compiles into every variant** ✓ (`.pio/build/pafsynth/…/interface/
SDCard.cpp.o` exists). SPI writes do **not** lock out core 1. It needs a physical card fitted; whether the
operator's units have one is ✗ unknown.
### 1.4 The model *is* transferable — the shapes already agree
- Firmware instantiates a compile-time `MLP<…>` whose dims come from the mode's generated schema; the browser
reshapes its `MLPCore<DynamicStorage>` to **the same schema dims** on every mode switch ✓
`manifold/src/console/ConsoleApp.tsx:252-257` (`engine.reshape({ inputSize, outputSize, hidden }, defaultSpread)`
from `mode.ml`). So for a given `mode_id` the two nets have the *same* `weight_count` by construction.
- **But not unconditionally.** The browser also offers an axis-count reshape that changes `inputSize` alone ✓
`ConsoleApp.tsx:290-322`, and `nisps_ml_create`'s default shape is a 32-input head ✓
`manifold/src/inputs/input-layer.ts:17,38`. A live browser net can therefore be at `6→[10,10,14]→33` while the
device is at `4→[10,10,14]→33`. **The protocol must carry the shape and refuse mismatches; it must never infer
compatibility from `mode_id` alone.**
- Flat weight layout is already specified and identical on both targets: `[l0_w][l1_w][l2_w][l3_w][l0_b][l1_b]
[l2_b][l3_b]`, little-endian `float32``nisps/wasm/bindings.cpp:25-34` (comment), `nisps/ml/mlp.hpp:385-414`
(`get_weights`/`set_weights`).
- Per-mode blob sizes (computed from `schemas/modes/*.json` ✓):
| mode | dims | weights+biases | bytes |
|---|---|---|---|
| `sound_analysis_midi` | 10→[10,10,14]→8 | 494 | 1 976 |
| `channel_strip`, `xiasri` | 4→[10,10,14]→24 | 674 | 2 696 |
| `paf_synth` | 4→[10,10,14]→33 | 809 | 3 236 |
| `elysiamorf` | 4→[10,14,18]→40 | 1 234 | 4 936 |
| `verb_fx` | 4→[10,14,18]→47 | 1 367 | 5 468 |
| `breakor`, `memlcelium`, `slp_workshop` | 4→[10,14,18]→56 | 1 538 | **6 152** |
Worst case is ~6 KB. Small — but not small enough to sit in one frame without either a 6 KB RX buffer or
chunking.
### 1.5 Training examples are **not** readable out of the core today
`MLPCore` exposes `example_count()``nisps/ml/mlp.hpp:441` and `clear_examples()``:443`, but the per-example
accessors `sample_features_(s)` / `sample_labels_(s)` are **private**`:577`, `:581`. Exporting the training
set from the device is therefore a real (small) addition to `nisps/ml/mlp.hpp`, not a wiring job.
Worse for the editor: the firmware's `FeedbackController` — which owns explore/place state and drives
`add_example` — is a **function-local static inside `bind_peripherals()`**
`firmware/MEMLNaut-NISPS/glue/peripherals.hpp:152`. It is unreachable from `main.cpp` or any command handler.
Any command that touches feedback state requires hoisting it first.
### 1.6 The discipline to copy already exists in-repo
`firmware/useq-celium/shared/protocol.h` ✓ is a C header of `static const` constants + `static inline` helpers,
mirrored constant-for-constant by `manifold/src/backends/useq-protocol.ts` ✓, pinned by
`manifold/src/backends/useq-protocol.test.ts` ✓ (`test('frame sizes match the C header')`), documented as
`kind: spec` in `docs/specs/useq-cv-protocol.md` ✓. The device-side parser is a ~20-line resync state machine ✓
`firmware/useq-celium/main/src/main.cpp:93-110`. **Verified live**: `bun test src tests/*.test.ts` in `manifold/`
`23 pass, 0 fail, 6 files, 65 ms`.
### 1.7 L29 — `InputChain`/`OutputChain` have no firmware consumer
✓ Repo-wide grep: the only non-test, non-self references are `nisps/wasm/bindings.cpp:76-77,194-195,968,1011`
and `tests/cpp/{test_pipeline,parity_check}.cpp`. `nisps/pipeline/output_chain.hpp:13` says "firmware would pick
its mode's NOut" — aspirational, present-tense-adjacent, and currently false. `InputChain` already serialises
(`state_size()`, round-trip test ✓ `tests/cpp/test_pipeline.cpp:131-150`), which matters below.
---
## 2. Invariants
Beyond the repo-wide hard constraints (platform-neutral allocation-free `nisps/`; native↔WASM parity ≤1e-5;
schema changes ship both codegen outputs; RT-safe worklet + SPSC dual-core discipline), this area adds:
- **I1 — The wire format has exactly one source of truth.** A single C header under `firmware/`, mirrored in TS,
pinned by a test that fails when they diverge. No constant is written twice by hand in the same language.
- **I2 — No hand-maintained per-mode table anywhere in the protocol.** Mode identity, net shape, param count and
ordering reach the wire from codegen output on both sides, or they do not reach it at all.
- **I3 — A model blob is applied only against a proven-identical contract.** Shape *and* schema fingerprint must
match. On mismatch the device NAKs; it never partially applies, never truncates, never zero-pads.
- **I4 — Serial servicing is bounded.** The core-0 pump reads at most a fixed byte budget per `loop()` iteration.
The 5 ms `tick_control()` cadence ✓ (`src/main.cpp:152,160`) is not to be perturbed by a host that streams.
- **I5 — Serial handling never runs on core 1.** Core 1 is audio + MIDI drain. All parsing, buffering, filesystem
and ML mutation happen on core 0.
- **I6 — Flash writes are an explicit, announced, user-initiated act.** Because they park core 1 (§1.3), a
persist is never automatic, never periodic, and never a side effect of another command.
- **I7 — Diagnostics and protocol do not share the stream unframed.** Either the diagnostics are silenced for the
session or they are carried inside a frame. Never raw ASCII interleaved with binary payloads.
- **I8 — The device is authoritative about itself.** The browser asks and believes; it never assumes a firmware
build from a mode name, a port name, or a USB descriptor.
- **I9 — No compat shim.** The stubs are deleted, not deprecated. `EditorPanel` is the only consumer (§1.1).
---
## 3. Design
### 3.1 Transport and channel
USB CDC (`Serial`), 8N1; the CDC baud parameter is ignored by the hardware but the host must supply one —
**115200**, matching what the firmware already calls and what `cv-backend.ts:133` uses. No second UART, no
composite USB device, no TinyUSB reconfiguration.
**The diagnostics collision (I7) is resolved by session state.** On `HELLO` the firmware sets an
`editor_session` flag; while set, `loop()`'s `Serial.println(".")` and perf `printf` (✓ `src/main.cpp:166,172`)
are suppressed and the same information is emitted as a `TELEMETRY` frame instead. `BYE`, a host disconnect
(DTR drop), or a 5 s silence timeout clears the flag and restores the prints. Boot-time prints
(`"Serial initialised."`, `"Bound peripherals to mode."`) happen before any session and are harmless — the
host-side parser drops non-sync bytes anyway.
*Rejected:* moving diagnostics to a second UART (costs pins and a second cable) and CDC-composite dual ports
(complicates the Web Serial port picker for one debug convenience).
### 3.2 Framing
Variable-length, because payloads range from 0 bytes to a 6 KB model (§1.4). Fixed-length-per-type (the useq
choice) does not survive that.
```
byte 0 sync 0xA5 host→device, 0x5A device→host
byte 1 type u8
bytes 2..3 length u16 LE, payload bytes, 0..MEMLED_MAX_PAYLOAD
bytes 4.. payload
last 2 crc u16 LE, CRC-16/CCITT-FALSE over bytes [1 .. 3+length]
```
- **CRC-16, not XOR-8.** useq's XOR-8 is right for a 26-byte frame streamed at 100 Hz where a corrupt frame is
discarded and replaced 10 ms later. It is wrong for a 6 KB model transfer, where a 1-in-256 undetected
corruption silently installs wrong weights and the user hears an inexplicable instrument. Bitwise CRC-16 is
~15 lines, table-free, and identical on both sides.
- **`MEMLED_MAX_PAYLOAD = 512`.** Bounds the device RX buffer at 518 bytes and each frame's service time at
~45 ms of wire time worst case — hence I4's byte budget. Larger objects chunk (§3.4).
- Both directions use the same frame shape; only the sync byte differs, so one parser implementation serves
both ends.
Home: **`firmware/shared/memlnaut-editor-protocol.h`** — deliberately *not* under `firmware/MEMLNaut-NISPS/`,
because it is shared with the browser exactly as `firmware/useq-celium/shared/protocol.h` is. Mirror:
**`manifold/src/serial/memlnaut-protocol.ts`**. Parity test: **`manifold/src/serial/memlnaut-protocol.test.ts`**
(picked up by the existing `bun test src tests/*.test.ts` glob — do not touch that script).
### 3.3 Identity: a codegen-emitted schema fingerprint (discharges I2, I3)
The one genuinely new idea in this spec, and the thing that makes "derive from schema codegen, not hand-defined
tables" concrete.
`codegen/generate.ts` gains one emitted constant per mode: a **FNV-1a 64 hash over a canonical serialisation of
the mode's contract-bearing schema fields** — `mode_id`, `engine_id`, `ml` (`input_size`, `hidden_layers`,
`output_size`, `default_spread`), `params[]` as `(name, min, max, default, curve, group)` in order, and
`voice_spaces` (names + curve overrides) in order. Deliberately **excluded**: `label`, `_note`, `ui` — cosmetic,
and a label edit must not invalidate a user's trained model.
Emitted into both generated worlds in the same change (hard constraint):
- `nisps/modes/generated/<mode>_schema.hpp``inline constexpr std::uint64_t k<Mode>SchemaHash = 0x…ull;`
alongside the existing `k<Mode>ModeId` ✓ (`nisps/modes/generated/paf_synth_schema.hpp:10`)
- `manifold/src/modes/generated/<mode>_schema.ts``schema_hash: '0x…'` on the existing `ModeSchema` const
CI already fails on a stale generated tree (`.github/workflows/ci.yml`, step *"Codegen is committed and
idempotent"* ✓ `:129`), and `codegen/tests/golden/paf_synth_schema.{hpp,ts}` ✓ will need regenerating in the
same commit.
The device reports its hash in `DESCRIBE`; the browser compares against **its own** generated hash for that
mode. Equal ⇒ weights, settings and examples are interchangeable. Unequal ⇒ the UI says *"this MEMLNaut is
running a different build of `paf_synth`"* and disables transfer. No table, no version negotiation matrix, no
per-mode special case — and the check is exact rather than heuristic.
### 3.4 Command set (protocol v1)
Types are `static const uint8_t` in the shared header. `ACK`/`NAK` are the universal replies; `NAK` carries a
one-byte reason code from an enum in the same header.
**Host → device**
| Type | Payload | Reply |
|---|---|---|
| `HELLO` | `u16 host_proto_ver` | `DESCRIBE` — opens the session, silences diagnostics |
| `BYE` | — | `ACK` — closes the session, restores diagnostics |
| `GET_MODEL` | — | `MODEL_BEGIN`, then `MODEL_CHUNK`×N |
| `PUT_MODEL_BEGIN` | `u64 schema_hash, u16 dims[5], u32 weight_count, u32 crc32` | `ACK`/`NAK` |
| `PUT_MODEL_CHUNK` | `u16 index, bytes` | `ACK` |
| `PUT_MODEL_END` | — | `ACK` (applied) / `NAK` (CRC or count mismatch — **nothing applied**) |
| `GET_SETTINGS` | — | `SETTINGS` |
| `SET_SETTING` | `u16 id, f32 value` | `ACK`/`NAK` |
| `PERSIST` | `u8 slot` | `ACK`/`NAK` — see I6 |
| `LOAD_PERSISTED` | `u8 slot` | `ACK`/`NAK` |
| `CLEAR_EXAMPLES` | — | `ACK` |
| `SUBSCRIBE` | `u16 mask, u8 rate_hz` | `ACK` — telemetry stream on/off |
**Device → host**
`DESCRIBE`, `MODEL_BEGIN`, `MODEL_CHUNK`, `SETTINGS`, `TELEMETRY`, `LOG` (UTF-8 text, framed — the session
replacement for the raw prints), `ACK`, `NAK`.
`DESCRIBE` payload: `u16 proto_ver`, `u64 schema_hash`, `mode_id` (fixed 24-byte NUL-padded ASCII, from
`generated::k<Mode>ModeId` ✓), `u16 dims[5]`, `u32 weight_count`, `u16 max_examples`, `u16 capability_bits`,
`char fw_build[16]`. The capability bits are what let one protocol serve 16 firmware variants without a
per-variant table: `HAS_PERSIST`, `HAS_SD`, `HAS_EXAMPLE_EXPORT`, `HAS_TELEMETRY`, `IS_SEQUENCER`.
**PUT_MODEL is atomic (I3).** Chunks land in a staging buffer sized `weight_count * 4` — for the largest mode
6 152 bytes of core-0 RAM, allocated as a `static` array sized from `MLPType::weight_count()` at compile time,
so no heap. `set_weights` is called once, from `PUT_MODEL_END`, only after the CRC-32 over the whole blob
matches and `weight_count` and `dims[5]` equal the device's own.
**Settings (I2, honestly).** Not everything can come from codegen, and pretending otherwise would be the
hand-defined table under a different name. The split:
- *Derived from codegen, on both sides:* the training triple `learning_rate` / `max_iterations` / `min_error`
(defaults + identity from `schemas/ml_defaults.json``nisps::ml::generated::kMlTrainDefaults` ✓ and
`manifold/src/modes/generated/ml_defaults.ts` ✓ — both mirrors already exist), and `voice_space_index` whose
legal range is the generated `voice_spaces` list ✓.
- *Genuinely device-side, enumerated in the shared header:* `pin_value`, `joystick_single` (the Dual/Single
toggle ✓ `glue/settings_view.hpp:54-58`), `explore_intensity` (RVX1 ✓ `glue/peripherals.hpp:124-126`),
`master_volume` (RVGain1 ✓ `:115-117` — note it drives `AudioDriver::SetMasterVolume` directly, not the mode). These are `ModeBase`/peripheral concepts with no schema home; there
are four of them; they live in one enum in the shared header and nowhere else.
**Telemetry closes ALIGNMENT defect 5's live half.** The `TELEMETRY` payload carries `PERF_GET_MEAN(MLSTATS)`
and `AUDIOLOOP_MEAN` — numbers the firmware **already computes and prints**`src/main.cpp:172` — plus
`example_count()`, `eval_loss()`, and (paged) the loss history the operator deliberately kept on device
(audit L25). That is the on-device timing report ALIGNMENT defect 5 asks for, at near-zero marginal cost,
displayed in a browser panel that already knows how to draw a loss curve (`console/TrainingHealth.tsx` ✓).
### 3.5 Persistence
**Primary: LittleFS on internal flash**, because it needs no accessory and every unit has it.
Required changes: `board_build.filesystem_size` in `platformio.ini`'s `[env]` block (§1.3 — currently absent,
default `0MB`, `begin()` fails), a `glue/persistence.hpp` that calls `LittleFS` **directly** (not memllib's
`FlashFS.hpp`, whose non-`inline` header functions are a duplicate-symbol trap and which adds nothing — §1.3),
and a fixed on-disk record:
```
/memlnaut/<mode_id>/<slot>.mdl magic, u16 record_ver, u64 schema_hash, u16 dims[5],
u32 weight_count, f32 weights[], settings block, u32 crc32
```
`schema_hash` on disk is checked on load exactly as on the wire (I3), so a firmware reflash that changes the
schema invalidates old saves loudly instead of loading garbage.
**The audio stall is real and must be surfaced, not hidden.** Sequence for `PERSIST`: mute via
`AudioDriver::SetMasterVolume(0)`, emit `LOG "saving…"`, write, restore volume, `ACK`. The browser shows a
determinate "Saving to device — audio will pause" state. The alternative — pretending a flash erase is free —
would be exactly the kind of plausible-looking lie this repo has been deleting all week.
**Not chosen, but noted:** the SD path (`interface/SDCard.hpp` ✓, already compiled ✓, pins ✓) does not park
core 1 and is the right home for bulk artefacts (full example sets, session recordings) if the operator's units
have cards fitted. Left behind the `HAS_SD` capability bit rather than built speculatively.
### 3.6 Browser side
`manifold/src/serial/memlnaut-serial.ts` is **rewritten, not extended**: the three stub methods and their
`TODO`s are deleted (I9 — sole consumer is `EditorPanel.tsx`, §1.1). The class becomes a framed-transport
driver: a read loop over `port.readable`, a resync parser identical in shape to the TS mirror's encoder, a
promise-keyed request/reply map, and the same `subscribe()` store it already has (which `EditorPanel` already
consumes via `useSyncExternalStore` ✓).
`EditorPanel.tsx` grows: a device card (mode, build, dims, compatibility verdict), Save-to-device /
Load-from-device with the compatibility gate and a progress state, a settings section, and a live telemetry
strip. **The "not yet wired" note is deleted in the same commit as the code that wires it** — a note that
outlives its truth is the failure mode this repo keeps finding.
`output-mode.ts:72`'s description ("configure / save / restore") becomes true and needs no edit. Two small
hygiene items ride along: fix `web-serial.d.ts`'s false header comment (§1.1) and move it to a shared location
now that two modules provably depend on it.
---
## 4. Deletions
| Deleted | Named consumers | Why safe |
|---|---|---|
| `MemlnautSerial.saveModel` / `.restoreModel` / `.getSettings` bodies + `TODO`s | `EditorPanel.tsx` only (✓ grep) | Replaced in the same change; no external caller |
| `EditorPanel`'s "not yet wired" `<p>` | none | Becomes false the moment step 6 lands |
| The unconditional `Serial.println(".")` / perf `printf` in `src/main.cpp:166-175` | none (human eyeball on a serial monitor) | Not deleted — made session-conditional. Outside a session behaviour is unchanged. |
| `lib/memllib/src/hardware/FlashFS.hpp`*not* deleted | none (✓ zero consumers) | Vendored upstream code; deleting it diverges the vendor tree for no gain (`VENDORED.md` re-sync). We simply do not use it, and `persistence.hpp` says why. |
Nothing else. In particular the `cvgate` backend's Web Serial code stays untouched — different device, different
protocol, no shared transport worth extracting for two implementations.
---
## 5. Sequenced implementation plan
Each step is independently landable and independently verifiable. Steps 14 are agent-safe with the existing
gates. Steps 58 need hardware and are honestly marked.
**Step 0 — L29 disposition (do first, it is one line either way).**
`nisps/pipeline/output_chain.hpp:13` and `input_chain.hpp`'s header currently imply a firmware consumer that does
not exist (§1.7). If the operator answers **Q4** (below) with "not now", soften both comments to say the chains
are browser-side today, in this step. If "yes", they stay and step 8 fulfils them. Either way L29 stops being an
open lie after step 0.
*Verification:* `bash scripts/lint-cpp.sh` (comment-only change; the lint strips comments before matching ✓).
**Step 1 — the shared header + TS mirror + parity test. No behaviour anywhere.**
`firmware/shared/memlnaut-editor-protocol.h`, `manifold/src/serial/memlnaut-protocol.ts`,
`manifold/src/serial/memlnaut-protocol.test.ts`. Encoder/decoder + CRC-16 in both languages; the test asserts
frame offsets, `MEMLED_MAX_PAYLOAD`, every type byte, and CRC-16 against fixed vectors.
*Verification:* `cd manifold && bun run typecheck && bun run test` (the new test is picked up by the existing
glob — **do not change the test script**). Additionally register a host C++ test that includes the header and
asserts the same vectors: add a source to `nisps_modes_tests`, which already puts the repo root on its include
path for exactly this reason ✓ (`nisps/CMakeLists.txt:134`). Then `bash scripts/build-cpp-tests.sh` proves the C
and TS encoders agree on the same vectors on both sides.
*Note:* `scripts/parity-check.sh` says nothing about any of this — it exercises PAFSynth and ChannelStrip at
all-params-0.5. A green parity run is not evidence for this step.
**Step 2 — codegen emits the schema fingerprint.**
`codegen/generate.ts` + `codegen/lib.ts`; C++ and TS in the same commit; `codegen/tests/golden/*` regenerated.
*Verification:* `cd codegen && bun run generate.ts` twice (idempotence), `bun run test` (golden + curve-drift),
then `bash scripts/build-cpp-tests.sh` (the generated headers are compiled by `nisps_modes_tests` ✓). CI's
"Codegen is committed and idempotent" step ✓ is the backstop.
**Step 3 — firmware command surface, read-only commands.**
`glue/editor_serial.hpp`: the bounded pump (I4), the parser, `HELLO`/`DESCRIBE`/`GET_SETTINGS`/`GET_MODEL`/
`SUBSCRIBE`/`BYE`, the session flag gating the diagnostics. Called from `loop()` on core 0 next to
`tick_control()`. Deliberately no mutation yet.
*Verification (weak, be honest):* `pio run -e slpworkshop -e pafsynth -e selftest` compiles and the flash/RAM
delta is reported (CI's firmware job already does this ✓ `.github/workflows/ci.yml:213,223`). Compilation is
**all** that is verified without hardware. The parser itself should be structured so its byte-level core is
Arduino-free and host-testable — the precedent is `glue/codec_config.hpp`, deliberately Arduino-free and covered
by `tests/cpp/test_mode_driver_config.cpp` ✓. Follow it: put the state machine in the shared header or a pure
sibling, and the `Serial` reads in the glue.
**Step 4 — browser transport + read-only editor UI.**
Rewrite `memlnaut-serial.ts` onto the framed protocol; `EditorPanel` renders `DESCRIBE` + telemetry + the
compatibility verdict. Save/Load still absent.
*Verification:* `bun run typecheck && bun run test && bun run build`. A Playwright e2e can cover the panel's
**disconnected** rendering only — Web Serial cannot be driven headlessly. Do not claim more. The frame codec is
covered by step 1's unit test; the *round trip* is not covered by anything until hardware.
**Step 5 — firmware mutating commands.**
`PUT_MODEL_*` (staging buffer + atomic apply), `SET_SETTING`, `CLEAR_EXAMPLES`. Requires hoisting the
`FeedbackController` out of `bind_peripherals`'s function-local static (§1.5) if any command touches feedback
state; if v1 avoids feedback entirely, say so and leave it.
*Verification:* compile + size. The atomicity logic (CRC-32 accumulate, count check, single `set_weights`) is
host-testable if written as a pure function over spans — do that, and cover it in `nisps_modes_tests`.
**Step 6 — Save-to-device / Load-from-device in the UI**, gated on the schema hash + dims match, with progress
and the explicit incompatibility message. Delete the "not yet wired" note here.
*Verification:* typecheck + unit + build; e2e for the **gate** (a fake `DESCRIBE` with a wrong hash must disable
the buttons) is worth writing because it is pure logic.
**Step 7 — persistence.**
`board_build.filesystem_size` in `platformio.ini` (**changes the flash layout of all 16 envs — rebuild and
re-report sizes**), `glue/persistence.hpp` on `LittleFS` directly, the record format, `PERSIST`/`LOAD_PERSISTED`,
the mute-around-write sequence.
*Verification:* all 16 envs build and the size table is re-baselined (`scripts/build-firmware.sh --all`). The
record encode/decode is pure and host-testable; the flash behaviour, the stall duration, and whether the audio
interruption is acceptable are **operator chokepoints — no automated gate reaches them.**
**Step 8 — (conditional on Q4) `InputChain`/`OutputChain` firmware wiring.**
`InputChain` in `bind_peripherals` between the joystick callbacks and `mode.set_input`; `OutputChain<MLPType::
kOutput>` in `ModeBase::tick_control` before `engine_.set_params`. Both configs become editor settings, which is
the only reason firmware would want them configurable at all. Note the cost honestly: `OutputChain<NMax>` adds an
`NMax`-float state array to every mode instance and a per-tick pass at 200 Hz.
*Verification:* host tests already exist for both chains ✓ (`tests/cpp/test_pipeline.cpp`) and parity stage 7
covers them ✓ (`tests/cpp/parity_check.cpp:341-394`) — but **neither proves the firmware wiring**, and the thing
that actually changes (how the joystick *feels*) can only be judged by ear on hardware.
---
## 6. Open questions — operator only
1. **Persistence medium and the audio stall.** Internal flash (always present, but every save parks core 1 and
interrupts audio for the erase+program — §1.3) or SD (no stall, but needs a card fitted, and it is ✗ unknown
whether your units have one)? If flash: is a brief, announced audio interruption on an explicit save
acceptable, or does that rule flash out?
2. **What "presets" means for the device.** Vision bullet 5 says *settings, presets, training, examples,
visualisation*. This spec covers settings, model transfer, telemetry, and persistence slots. It does **not**
define a preset object — and §6.5c (curated/advanced split) is supposed to define exactly that, and is
scheduled *before* this item. Does the editor wait for 5c's preset model, or ship slots-of-weights now and
absorb presets later?
3. **Do examples need to leave the device?** Exporting the training set requires a new public accessor on
`MLPCore` (§1.5) and probably hoisting the `FeedbackController` (§1.5). "Train on device, curate in browser"
implies yes; "save/restore a model" implies no. This is the difference between a small v1 and a much larger
one.
4. **L29 / step 8: should firmware gain the browser's input pipeline?** Deadzone, circular clamp, momentum zoom
and EMA would change the feel of every hardware mode, and only your ears can judge it. If the answer is "not
now", step 0 softens the comments and the question closes cleanly.
5. **Whose model wins on connect?** When the browser connects and both ends hold a trained net for the same
mode, does the editor do nothing until told (proposed default), or offer/auto-pull the device's?
## 7. Decisions taken here (not questions)
- CRC-16 for frames, CRC-32 for whole-model transfers, not useq's XOR-8 — §3.2.
- Variable-length frames with a 512-byte cap and chunked models, not one large frame — §3.2, I4.
- Diagnostics silenced by session flag rather than moved to another port — §3.1, I7.
- Compatibility by codegen-emitted schema fingerprint + explicit dims, never by `mode_id` alone — §3.3, I3.
- `LittleFS` called directly; memllib's `FlashFS.hpp` not used and not deleted — §3.5, §4.
- The four device-side settings live in an enum in the shared header, and the spec says plainly that they are
not codegen-derived rather than pretending — §3.4.
- Protocol header at `firmware/shared/`, not inside `firmware/MEMLNaut-NISPS/`, mirroring useq-celium.
## 8. Where verification is weak
Say this out loud in any PR description that lands these steps:
- **`scripts/parity-check.sh` proves nothing here.** It exercises PAFSynth and ChannelStrip with all params at
0.5. It touches no serial code, no framing, no persistence.
- **Nothing in CI executes firmware.** The firmware job compiles three envs and reports sizes ✓
(`.github/workflows/ci.yml:213,223`). A protocol that compiles is not a protocol that works.
- **Web Serial cannot be exercised headlessly.** Playwright can cover the panel's disconnected and
incompatible-device states, and the frame codec is unit-tested on both sides — the *round trip over a real
cable* is untested until someone plugs a MEMLNaut in.
- **Mitigation worth building early:** a tiny host-side loopback harness — the TS encoder feeding the C parser
compiled natively into `nisps_modes_tests` — turns "the two ends agree about bytes" from a hope into a gate,
and costs about an hour. It still says nothing about USB CDC, flash timing, or feel.
- **Step 7 has no automated gate at all** beyond "16 envs still build". Flash layout, stall duration and
recovery-after-power-loss are hardware chokepoints.