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:
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(())
+}