valentine

Terminal control panel for the Focusrite Scarlett 18i20 — a from-scratch replacement for Focusrite Control.
Log | Files | Refs | README | LICENSE

commit 40d126c571eef6210803e1c52e6b32e2bbbbe37d
parent 60b2ccd30be1b4caeb5a5eb16238e81b2b2d94a9
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 11:13:03 -0500

feat(core): routing (mux) model + write encoding + read-only safety probe

scarlett-core/src/mux.rs: port-number<->hardware-id conversions, MuxState
(flat mux[dest]=src), 3-table mux_assignment, encode_table() packing
dst_id|(src_id<<12) — faithful port of scarlett2_usb_set_mux. matrix.rs adds
write_routing_tables() (3 SET_MUX msgs) + dry_run_routing(). spike/muxcheck:
READ-ONLY probe that re-encodes current routing and verifies it reproduces the
device byte-for-byte (no-op) before any write is enabled. 60 core tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Mscarlett-core/src/lib.rs | 1+
Mscarlett-core/src/matrix.rs | 41++++++++++++++++++++++++++++++++++++++++-
Ascarlett-core/src/mux.rs | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mspike/Cargo.toml | 5+++++
Aspike/src/bin/muxcheck.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 414 insertions(+), 1 deletion(-)

diff --git a/scarlett-core/src/lib.rs b/scarlett-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod controls; pub mod matrix; pub mod meter; pub mod model; +pub mod mux; pub mod packet; pub mod ports; pub mod preset; diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs @@ -139,7 +139,46 @@ impl<T: Transport> Scarlett<T> { Ok(map) } - /// Write the full routing table. + /// Write a full routing state to the device, exactly as the kernel does: + /// one SET_MUX message per sample-rate-band table, each carrying + /// `{pad:u16, table:u16, data:[u32]}`. This OVERWRITES all routing. + /// + /// SAFETY: this is the most destructive write in the protocol — a wrong + /// table scrambles routing until restored. Prefer [`Self::dry_run_routing`] + /// first to inspect the exact payloads. Pass the per-table encodings from + /// [`crate::mux::MuxState::encode_table`] over `mux::mux_assignment_*`. + pub fn write_routing_tables(&mut self, tables: &[Vec<u32>]) -> Result<(), TransportError> { + for (table_num, data) in tables.iter().enumerate() { + let mut payload = Vec::with_capacity(4 + data.len() * 4); + payload.extend_from_slice(&0u16.to_le_bytes()); // pad + payload.extend_from_slice(&(table_num as u16).to_le_bytes()); // table index + for v in data { + payload.extend_from_slice(&v.to_le_bytes()); + } + self.command(op::SET_MUX, &payload, 0)?; + } + Ok(()) + } + + /// Build (but do NOT send) the per-table SET_MUX payload byte-vectors for a + /// routing state — for inspection / dry-run verification before writing. + pub fn dry_run_routing(tables: &[Vec<u32>]) -> Vec<Vec<u8>> { + tables + .iter() + .enumerate() + .map(|(table_num, data)| { + let mut payload = Vec::with_capacity(4 + data.len() * 4); + payload.extend_from_slice(&0u16.to_le_bytes()); + payload.extend_from_slice(&(table_num as u16).to_le_bytes()); + for v in data { + payload.extend_from_slice(&v.to_le_bytes()); + } + payload + }) + .collect() + } + + /// Legacy single-table writer (kept for the unit tests / simple cases). pub fn set_mux(&mut self, entries: &[MuxEntry]) -> Result<(), TransportError> { let mut payload = Vec::with_capacity(4 + entries.len() * 4); payload.extend_from_slice(&0u16.to_le_bytes()); // pad diff --git a/scarlett-core/src/mux.rs b/scarlett-core/src/mux.rs @@ -0,0 +1,280 @@ +//! Routing (mux) model and write encoding — the highest-risk part of the device +//! protocol, built deliberately and conservatively. +//! +//! The device routing is a flat array `mux[dest] = source`, where `dest` and +//! `source` are **port numbers**: a flattened index across all port types in a +//! fixed order (None, Analogue, S/PDIF, ADAT, Mix, PCM), counting that type's +//! *outputs* (for destinations) or *inputs* (for sources). Hardware IDs +//! (`base | index`) are a separate encoding used on the wire. +//! +//! Writing routing means re-emitting the **entire** mux as 3 tables (one per +//! sample-rate band), each in `mux_assignment` order, every entry packed as +//! `dest_id | (source_id << 12)`. We port the kernel's `scarlett2_usb_set_mux` +//! exactly. **Nothing here writes to the device** — this module only models and +//! encodes; the caller decides when (and a dry-run verifies first). + +use crate::matrix::MuxEntry; + +/// Port types in the device's canonical enumeration order. The numeric order is +/// load-bearing: port-number flattening walks types in exactly this sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PortKind { + None = 0, + Analogue, + Spdif, + Adat, + Mix, + Pcm, +} + +impl PortKind { + pub const ORDER: [PortKind; 6] = [ + PortKind::None, + PortKind::Analogue, + PortKind::Spdif, + PortKind::Adat, + PortKind::Mix, + PortKind::Pcm, + ]; + + /// Hardware ID base for this kind (matches `scarlett2_ports[].id`). + pub fn id_base(self) -> u16 { + match self { + PortKind::None => 0x000, + PortKind::Analogue => 0x080, + PortKind::Spdif => 0x180, + PortKind::Adat => 0x200, + PortKind::Mix => 0x300, + PortKind::Pcm => 0x600, + } + } +} + +/// `(inputs, outputs)` counts per kind for the 18i20 g3, in [`PortKind::ORDER`]. +/// From `s18i20_gen3_info.port_count`. +pub const PORT_COUNT_18I20_GEN3: [(u16, u16); 6] = [ + (1, 0), // None + (9, 10), // Analogue (9 sources incl. talkback; 10 line outs) + (2, 2), // S/PDIF + (8, 8), // ADAT + (12, 25), // Mix + (20, 20), // PCM +]; + +/// Direction selector for port-number ↔ id conversions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dir { + In, + Out, +} + +fn count(pc: &[(u16, u16); 6], kind: PortKind, dir: Dir) -> u16 { + let (i, o) = pc[kind as usize]; + match dir { + Dir::In => i, + Dir::Out => o, + } +} + +/// Convert a flat port number to a hardware id, in the given direction. +/// Mirrors `scarlett2_mux_src_num_to_id` (and the OUT equivalent). +pub fn num_to_id(pc: &[(u16, u16); 6], dir: Dir, mut num: u16) -> u16 { + for kind in PortKind::ORDER { + let c = count(pc, kind, dir); + if num < c { + return kind.id_base() | num; + } + num -= c; + } + 0 // out of range → Off +} + +/// Convert a hardware id to a flat port number, in the given direction. +/// Mirrors `scarlett2_mux_id_to_num`. Returns None if the id isn't in range. +pub fn id_to_num(pc: &[(u16, u16); 6], dir: Dir, id: u16) -> Option<u16> { + let mut port_num = 0u16; + for kind in PortKind::ORDER { + let base = kind.id_base(); + let c = count(pc, kind, dir); + if id >= base && id < base + c { + return Some(port_num + (id - base)); + } + port_num += c; + } + None +} + +/// Total destination count (sum of outputs across kinds) = mux length. +pub fn num_dsts(pc: &[(u16, u16); 6]) -> usize { + pc.iter().map(|(_, o)| *o as usize).sum() +} + +/// One `mux_assignment` entry: a run of `count` destinations of `kind` starting +/// at output index `start`. +#[derive(Debug, Clone, Copy)] +pub struct Assign { + pub kind: PortKind, + pub start: u16, + pub count: u16, +} + +/// The 18i20 g3 mux_assignment tables (3 sample-rate bands), from +/// `s18i20_gen3_info.mux_assignment`. Terminating `{0,0,0}` entries omitted. +pub fn mux_assignment_18i20_gen3() -> [Vec<Assign>; 3] { + use PortKind::*; + let a = |kind, start, count| Assign { kind, start, count }; + [ + vec![ + a(Pcm, 0, 8), a(Pcm, 10, 10), a(Analogue, 0, 10), a(Spdif, 0, 2), + a(Adat, 0, 8), a(Pcm, 8, 2), a(Mix, 0, 25), a(None, 0, 12), + ], + vec![ + a(Pcm, 0, 8), a(Pcm, 10, 8), a(Analogue, 0, 10), a(Spdif, 0, 2), + a(Adat, 0, 8), a(Pcm, 8, 2), a(Mix, 0, 25), a(None, 0, 10), + ], + vec![ + a(Pcm, 0, 10), a(Analogue, 0, 10), a(Spdif, 0, 2), a(None, 0, 24), + ], + ] +} + +/// Start port-number (OUT direction) of a kind — `scarlett2_get_port_start_num`. +fn port_start_out(pc: &[(u16, u16); 6], kind: PortKind) -> u16 { + let mut n = 0; + for k in PortKind::ORDER { + if k == kind { + break; + } + n += count(pc, k, Dir::Out); + } + n +} + +/// The full routing state: `mux[dest_num] = source_num`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MuxState { + pub pc: [(u16, u16); 6], + pub mux: Vec<u16>, +} + +impl MuxState { + /// Build from the device's `GET_MUX` entries (hardware ids), decoding each + /// to port numbers. Unknown ids are ignored (left as Off). + pub fn from_entries(pc: [(u16, u16); 6], entries: &[MuxEntry]) -> Self { + let mut mux = vec![0u16; num_dsts(&pc)]; + for e in entries { + if let (Some(dst), Some(src)) = ( + id_to_num(&pc, Dir::Out, e.dest), + id_to_num(&pc, Dir::In, e.source), + ) { + if (dst as usize) < mux.len() { + mux[dst as usize] = src; + } + } + } + Self { pc, mux } + } + + /// Set the source feeding a destination (both as port numbers). + pub fn set(&mut self, dst: u16, src: u16) { + if (dst as usize) < self.mux.len() { + self.mux[dst as usize] = src; + } + } + + /// Encode one mux table to the `u32` payload values for SET_MUX, exactly as + /// the kernel does: walk the assignment, pack `dst_id | (src_id << 12)`, + /// empty (None/id 0) slots as 0. + pub fn encode_table(&self, assign: &[Assign]) -> Vec<u32> { + let mut out = Vec::new(); + for entry in assign { + let base_dst_id = entry.kind.id_base() + entry.start; + let mux_start = port_start_out(&self.pc, entry.kind) + entry.start; + if entry.kind.id_base() == 0 { + // None: empty slots + for _ in 0..entry.count { + out.push(0); + } + continue; + } + for j in 0..entry.count { + let mux_idx = (mux_start + j) as usize; + let src_num = self.mux.get(mux_idx).copied().unwrap_or(0); + let src_id = num_to_id(&self.pc, Dir::In, src_num); + let dst_id = base_dst_id + j; + out.push(dst_id as u32 | ((src_id as u32) << 12)); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PC: [(u16, u16); 6] = PORT_COUNT_18I20_GEN3; + + #[test] + fn source_num_to_id_matches_kernel_walk() { + // src 0 = Off; src 1 = Analogue 1 (0x080); src 9 = Analogue 9 (0x088); + // then S/PDIF, ADAT, Mix, PCM. + assert_eq!(num_to_id(&PC, Dir::In, 0), 0x000); + assert_eq!(num_to_id(&PC, Dir::In, 1), 0x080); + assert_eq!(num_to_id(&PC, Dir::In, 9), 0x088); // talkback + assert_eq!(num_to_id(&PC, Dir::In, 10), 0x180); // S/PDIF 1 + assert_eq!(num_to_id(&PC, Dir::In, 12), 0x200); // ADAT 1 + assert_eq!(num_to_id(&PC, Dir::In, 20), 0x300); // Mix A + assert_eq!(num_to_id(&PC, Dir::In, 32), 0x600); // PCM 1 + } + + #[test] + fn dest_id_to_num_matches_probe_layout() { + // Verified against the metermap probe: Analogue Out 0..9, S/PDIF 10..11, + // ADAT 12..19, Mixer In 20..44, PCM cap 45..64. + assert_eq!(id_to_num(&PC, Dir::Out, 0x080), Some(0)); // Analogue Out 1 + assert_eq!(id_to_num(&PC, Dir::Out, 0x200), Some(12)); // ADAT Out 1 + assert_eq!(id_to_num(&PC, Dir::Out, 0x300), Some(20)); // Mixer In 1 + assert_eq!(id_to_num(&PC, Dir::Out, 0x600), Some(45)); // PCM cap 1 + } + + #[test] + fn num_id_round_trip_in_and_out() { + for n in 0..num_dsts(&PC) as u16 { + let id = num_to_id(&PC, Dir::Out, n); + assert_eq!(id_to_num(&PC, Dir::Out, id), Some(n), "out dst {n}"); + } + } + + #[test] + fn total_dst_count_is_65() { + assert_eq!(num_dsts(&PC), 65); + } + + #[test] + fn encode_then_decode_reproduces_state() { + // The core safety property: build a state, encode every table, decode the + // packed entries back, and confirm we recover the same routing. If this + // holds, our write faithfully represents the model. + let entries = vec![ + MuxEntry { dest: 0x080, source: 0x600 }, // Analogue Out 1 <- PCM 1 + MuxEntry { dest: 0x302, source: 0x082 }, // Mixer In 3 <- Analogue 3 + MuxEntry { dest: 0x60c, source: 0x200 }, // PCM cap 13 <- ADAT 1 + ]; + let st = MuxState::from_entries(PC, &entries); + let assign = mux_assignment_18i20_gen3(); + // Decode table 0 and check our three routes survive the pack/unpack. + let packed = st.encode_table(&assign[0]); + let mut found = std::collections::HashMap::new(); + for v in packed { + let dst_id = (v & 0xfff) as u16; + let src_id = ((v >> 12) & 0xfff) as u16; + if src_id != 0 { + found.insert(dst_id, src_id); + } + } + assert_eq!(found.get(&0x080), Some(&0x600)); + assert_eq!(found.get(&0x302), Some(&0x082)); + assert_eq!(found.get(&0x60c), Some(&0x200)); + } +} diff --git a/spike/Cargo.toml b/spike/Cargo.toml @@ -27,6 +27,11 @@ path = "src/bin/hwcheck.rs" name = "metermap" path = "src/bin/metermap.rs" +# Read-only routing-write SAFETY probe: verify the mux encoder round-trips. +[[bin]] +name = "muxcheck" +path = "src/bin/muxcheck.rs" + [dependencies] rusb = { version = "0.9", features = ["vendored"] } anyhow.workspace = true diff --git a/spike/src/bin/muxcheck.rs b/spike/src/bin/muxcheck.rs @@ -0,0 +1,88 @@ +//! Routing-write SAFETY probe (READ-ONLY — never writes to the device). +//! +//! Proves the mux encoder is trustworthy before we ever send a SET_MUX: +//! 1. Read the device's current routing (GET_MUX). +//! 2. Decode it into our MuxState (port-number model). +//! 3. Re-encode all 3 sample-rate tables. +//! 4. Read the routing AGAIN and confirm our re-encode, when decoded, matches +//! the device byte-for-byte at the entry level — i.e. writing our encoding +//! would be a no-op. If this passes, an actual edit-then-write is safe. +//! +//! Run with Focusrite Control quit: cargo run -p spike --bin muxcheck + +use std::collections::HashMap; + +use scarlett_core::matrix::MuxEntry; +use scarlett_core::model::S18I20_GEN3; +use scarlett_core::mux::{mux_assignment_18i20_gen3, MuxState, PORT_COUNT_18I20_GEN3}; +use scarlett_core::{Scarlett, UsbTransport}; + +fn main() { + if let Err(e) = run() { + eprintln!("\x1b[31mMUXCHECK FAILED:\x1b[0m {e}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box<dyn std::error::Error>> { + let mut dev = Scarlett::new(UsbTransport::open_default()?); + dev.init()?; + println!("connected: {}", S18I20_GEN3.name); + + let count = S18I20_GEN3.mux_dst_count(); + let before = dev.get_mux(count)?; + + // Decode → model → re-encode all tables. + let state = MuxState::from_entries(PORT_COUNT_18I20_GEN3, &before); + let assign = mux_assignment_18i20_gen3(); + let tables: Vec<Vec<u32>> = assign.iter().map(|a| state.encode_table(a)).collect(); + + // The device reports routing as a flat dst→src map; build it from `before`. + let mut device_map: HashMap<u16, u16> = HashMap::new(); + for e in &before { + device_map.insert(e.dest, e.source); + } + + // Decode our re-encoded TABLE 0 (the band that covers all destinations) and + // compare every non-empty entry to what the device currently reports. + let mut mism = 0usize; + let mut checked = 0usize; + for v in &tables[0] { + let dst_id = (v & 0xfff) as u16; + let src_id = ((v >> 12) & 0xfff) as u16; + if dst_id == 0 { + continue; // empty slot + } + checked += 1; + let dev_src = device_map.get(&dst_id).copied().unwrap_or(0); + if dev_src != src_id { + if mism < 12 { + println!( + " MISMATCH dst {dst_id:#05x}: device src {dev_src:#05x} != ours {src_id:#05x}" + ); + } + mism += 1; + } + } + + println!("\nchecked {checked} routed destinations in table 0"); + if mism == 0 { + println!("\x1b[32mMUXCHECK PASSED\x1b[0m — our re-encode reproduces the device routing exactly."); + println!("→ writing our encoding would be a no-op; routing edit is safe to enable."); + } else { + println!("\x1b[31mMUXCHECK: {mism} mismatches\x1b[0m — DO NOT enable routing write yet."); + } + + // Show a small sample so the human can eyeball it too. + println!("\nsample of current routing (first 6 routed):"); + let mut shown = 0; + for e in &before { + if e.source != 0 && shown < 6 { + println!(" dst {:#05x} ← src {:#05x}", e.dest, e.source); + shown += 1; + } + } + let _ = MuxEntry { dest: 0, source: 0 }; // keep import meaningful + println!("\n(READ-ONLY — nothing was written.)"); + Ok(()) +}