From 1a8e3462cfe54bd2204126381b25540897bdc86c Mon Sep 17 00:00:00 2001 From: pak Date: Tue, 18 Aug 2026 11:09:16 +0200 Subject: [PATCH] Add the Helix LT, measured against a physical unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LT enumerates as 0x0E41/0x424A, which no rule and no table entry covered, so it was invisible to `Transport::open` and its raw USB node stayed root-owned. Everything in the new entry was read off the device, not inferred from the Floor: the handshake reports "P21", a pulled preset populates key 1 and carries blocks in slots 21-28 (both DSPs) with SNAPSHOT 1..8, and banks 0..7 each list 128 presets while bank 8 is refused with code -3. `preset_device_id` stays None because the wire carries no 0x0021xxxx id and there is no LT backup to read one from. The setlist *names* are the Floor's, which is the one place the evidence is thinner than the Floor's own — docs/helix-lt.md says so explicitly. No edit has ever been sent to this unit, so it is Support::Untested despite every read path reconciling cleanly. The existing untested-device invariant is renamed to name the Stomp XL, since "the untested device" is no longer singular. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +- crates/fretwire-protocol/src/lib.rs | 43 +++++++++ crates/fretwire-protocol/tests/devices.rs | 33 ++++++- docs/helix-lt.md | 103 ++++++++++++++++++++++ packaging/70-hxstomp.rules | 2 + 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 docs/helix-lt.md diff --git a/README.md b/README.md index 9b6f267..4d625fc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # fretwire -An independent, from-scratch **Linux editor for the Line 6 HX Stomp and Helix Floor**, written in Rust. +An independent, from-scratch **Linux editor for the Line 6 HX Stomp, Helix Floor and Helix LT**, written in Rust. fretwire talks to the pedal over its `MI_00` USB control interface (VID `0x0E41` / PID `0x4246`). The wire protocol was recovered by **observing USB traffic to and from the device**; the model, @@ -155,7 +155,7 @@ cargo run -p fretwire-cli -- detect # HX Stomp: present cargo run -p fretwire-cli -- pull # read the loaded preset (non-destructive) ``` -The rule covers both the HX Stomp (`0x4246`) and HX Stomp XL (`0x4253`). +The rule covers the HX Stomp (`0x4246`), the Helix Floor (`0x4248`), the Helix LT (`0x424a`) and the HX Stomp XL (`0x4253`). ## The reference data diff --git a/crates/fretwire-protocol/src/lib.rs b/crates/fretwire-protocol/src/lib.rs index 1ca3037..3517a26 100644 --- a/crates/fretwire-protocol/src/lib.rs +++ b/crates/fretwire-protocol/src/lib.rs @@ -28,6 +28,13 @@ pub const PID_HX_STOMP_XL: u16 = 0x4253; /// blocks on the second DSP (addressed by the same bare slot integer: `slot = dsp * 20 + index`). /// See `docs/helix-floor.md` and [`DEVICES`]. pub const PID_HELIX_FLOOR: u16 = 0x4248; +/// USB Product ID for the Helix LT, read off a physical unit on Linux (2026-08-18): +/// USB product string `HELIX`, `bcdDevice 0x0200`, and the same six-interface layout the +/// Floor has, interface 0 being the vendor control channel. +/// +/// The unit identifies itself as `P21` — the Floor's model code — and every read path +/// reconciles against it unchanged. See `docs/helix-lt.md`. +pub const PID_HELIX_LT: u16 = 0x424A; /// Interface number of the vendor-specific control channel. pub const CONTROL_INTERFACE: u8 = 0x00; /// Bulk OUT endpoint (host → device). @@ -128,6 +135,42 @@ pub const DEVICES: &[Device] = &[ setlist_size: Some(128), support: Support::Verified, }, + Device { + pid: PID_HELIX_LT, + name: "Helix LT", + // The LT stamps the Floor's code: the handshake identity reply reports "P21" and a + // pulled preset carries key `7 → 36` = "P21\0". `by_model_code("P21")` therefore + // resolves to the Floor, which is listed first — they are one data class. + model_code: Some("P21"), + // Unknown, not copied across: the handshake carries no `0x0021xxxx` device id and + // the wire preset stream has no such field. The Floor's value came from a `.hxb`, + // and we have no backup from an LT. + preset_device_id: None, + // Both DSPs — a pulled preset populates key `1` and holds blocks in slots 21..28 + // (the unit reported DSP1 71.0% / DSP2 43.0%). + dsps: Some(2), + // The pulled preset carries SNAPSHOT 1..SNAPSHOT 8. + snapshots: Some(8), + // Banks 0..7 each list 128 presets and bank 8 is refused (code -3). Bank 0 holds the + // factory amp presets and bank 7 the templates ("Quick Start", "Parallel Spans", + // "SNP:4-Amp Spill") — the Floor's layout, so the Floor's names are used. Unlike the + // Floor's, these names are not corroborated by a backup; only the arity and the two + // end banks were observed. + setlists: Some(&[ + "FACTORY 1", + "FACTORY 2", + "USER 1", + "USER 2", + "USER 3", + "USER 4", + "USER 5", + "TEMPLATES", + ]), + setlist_size: Some(128), + // Handshake, preset read, setlist and preset-list browse are all reconciled against a + // physical LT, but no edit has ever been sent to one. + support: Support::Untested, + }, Device { pid: PID_HX_STOMP_XL, name: "HX Stomp XL", diff --git a/crates/fretwire-protocol/tests/devices.rs b/crates/fretwire-protocol/tests/devices.rs index 7140532..691f08f 100644 --- a/crates/fretwire-protocol/tests/devices.rs +++ b/crates/fretwire-protocol/tests/devices.rs @@ -3,7 +3,9 @@ //! Everything asserted here is either a USB ID from a real descriptor or a value read out of a real //! preset — nothing is inferred from another device in the family. See `docs/helix-floor.md`. -use fretwire_protocol::{DEVICES, Device, PID_HELIX_FLOOR, PID_HX_STOMP, PID_HX_STOMP_XL, Support}; +use fretwire_protocol::{ + DEVICES, Device, PID_HELIX_FLOOR, PID_HELIX_LT, PID_HX_STOMP, PID_HX_STOMP_XL, Support, +}; #[test] fn every_device_has_a_distinct_pid() { @@ -28,6 +30,10 @@ fn lookup_by_pid() { Device::by_pid(PID_HX_STOMP_XL).map(|d| d.name), Some("HX Stomp XL") ); + assert_eq!( + Device::by_pid(PID_HELIX_LT).map(|d| d.name), + Some("Helix LT") + ); assert!(Device::by_pid(0xFFFF).is_none()); } @@ -74,7 +80,7 @@ fn verified_devices_are_fully_described() { } #[test] -fn the_untested_device_claims_nothing_it_hasnt_shown_us() { +fn the_stomp_xl_claims_nothing_it_hasnt_shown_us() { let xl = Device::by_pid(PID_HX_STOMP_XL).unwrap(); assert_eq!(xl.support, Support::Untested); // We have no capture, preset or backup from an XL — so none of this may be assumed to match @@ -136,3 +142,26 @@ fn the_two_verified_devices_differ_where_we_measured_them() { assert_eq!(floor.preset_device_id, Some(0x0021_0001)); assert_ne!(stomp.model_code, floor.model_code); } + +/// The LT reports the Floor's model code and the same setlist geometry, but its own preset +/// device id was never observed — it must stay unknown rather than inherit the Floor's. +#[test] +fn the_lt_shares_the_floors_data_class_without_inheriting_its_device_id() { + let floor = Device::by_pid(PID_HELIX_FLOOR).unwrap(); + let lt = Device::by_pid(PID_HELIX_LT).unwrap(); + + // Measured on a physical LT: see docs/helix-lt.md. + assert_eq!(lt.model_code, Some("P21")); + assert_eq!((lt.dsps, lt.snapshots), (Some(2), Some(8))); + assert_eq!(lt.setlist_size, floor.setlist_size); + assert_eq!(lt.setlists, floor.setlists); + + // Never seen on the wire, so never guessed. + assert_eq!(lt.preset_device_id, None); + + // Both stamp "P21"; the lookup keeps resolving it to the Floor, which is listed first. + assert_eq!( + Device::by_model_code("P21").map(|d| d.pid), + Some(PID_HELIX_FLOOR) + ); +} diff --git a/docs/helix-lt.md b/docs/helix-lt.md new file mode 100644 index 0000000..7ca4d59 --- /dev/null +++ b/docs/helix-lt.md @@ -0,0 +1,103 @@ +# Helix LT — device survey + +What we know about the **Helix LT**, from a user's unit on Linux (2026-08-18, firmware string +`a7e2585`). Unlike the Floor survey, this one rests on **no USB captures and no device backup**: +everything below was read by fretwire itself over `MI_00`, with this PR's device entry applied. + +**Bottom line: the LT needs no protocol change.** The handshake, the preset read, the snapshot +decode, and the setlist and preset-list browses all work unmodified once the PID is in the table. +The catalogue resolved every block and parameter of the loaded preset by name. + +**Nothing has been written to this device** — no edit, no save, no backup, no restore. That is why +the entry is `Support::Untested` despite the reads reconciling cleanly. + +## USB identity [solid] + +| | Helix Floor | Helix LT | +|---|---|---| +| VID / PID | `0x0E41` / `0x4248` | `0x0E41` / **`0x424A`** | +| `bcdDevice` | `0x0200` | `0x0200` | +| product string | — | `HELIX` | + +Interface layout, read from Linux sysfs — the same six-interface shape the Floor has, with the +vendor control channel at interface 0: + +| iface | class | kernel driver | +|---|---|---| +| 0 | **Vendor (`0xFF`)** | none — this is `MI_00` | +| 1 | Audio control | `snd-usb-audio` | +| 2 | Audio streaming | `snd-usb-audio` | +| 3 | Audio streaming | `snd-usb-audio` | +| 4 | Audio / MIDI | `snd-usb-audio` | +| 5 | HID | `usbhid` | + +`CONTROL_INTERFACE`, `EP_IN` and `EP_OUT` needed no change, and `claim_interface(0)` succeeded on +the first try. + +## Identity and firmware [solid] + +The handshake identity reply reports **`P21`** — the Floor's model code, not a code of its own. The +pulled preset agrees, at key `7`: + +| key | value | +|---|---| +| `36` | `"P21\0"` | +| `37` | `"a7e2585\0"` | +| `35` | `57737248` = `0x03710020` | + +Key `35` is recorded raw on purpose. `docs/helix-floor.md` notes `device_version = 0x03800000` for +firmware 3.82, which would make `0x03710020` read as ~3.71 — but that encoding has never been +pinned down against two known firmware versions, so this is data, not a conclusion. + +## Preset model [solid] + +Reading the loaded preset (8326 bytes, declared length matched): + +- **Both DSPs.** Preset key `1` is populated and blocks came back in slots 21–28 as well as 3–6, + i.e. the global `slot = dsp * 20 + index` numbering the Floor established. The unit reported + DSP1 71.0% used and DSP2 43.0%. +- **8 snapshots**, `SNAPSHOT 1`…`SNAPSHOT 8`, and the stored active index agreed with the live + scene. +- Every one of the 8 blocks resolved to a `.models` definition by name, with device-ordered named + parameters — no unmatched model, no unmatched parameter key. + +## Setlists [arity solid; names not corroborated] + +Browsing each bank in turn: + +| bank | result | +|---|---| +| 0 | 128 presets — `US Double Nrm`, `Essex A30`, `Brit Plexi Brt` (factory) | +| 1 | 128 presets | +| 2 | 128 presets — the user's own presets; `read-info` on the loaded preset reported `bank: 2, index: 0, name: "WIP"` | +| 6 | 128 presets | +| 7 | 128 presets — `Quick Start`, `Parallel Spans`, `SNP:4-Amp Spill` (templates) | +| 8 | refused, code `-3` | + +So: **eight banks of 128**, bank 0 factory and bank 7 templates — the Floor's layout, and +`setlist_stride()`'s 128 fallback was already correct for this device. + +The names in `DEVICES` are therefore the Floor's. Worth being explicit about the difference in +evidence: the Floor's names came from the eight `L6Setlist` streams of a real `.hxb`, whereas here +only the *arity* and the *character of the two end banks* were observed. If an LT backup ever turns +up and disagrees, this is the field to fix. + +## Not observed + +- **`preset_device_id`.** The handshake identity reply carries no `0x0021xxxx` device id, and the + wire preset stream has no such field — the Floor's `0x210001` came from a `.hxb`. Left `None` + rather than copied across. +- **Every write path.** Nothing was sent to this unit beyond reads and the handshake. + +## Reproducing + +With the LT connected and the udev rule installed: + +``` +fretwire detect # Helix LT: present (untested device) +fretwire connect # handshake OK — device reports "P21" +fretwire pull # the preset above, blocks and params resolved +fretwire setlists # 8 setlists +fretwire presets 7 # the templates bank +fretwire dump-raw lt.raw # then: fretwire tree lt.raw +``` diff --git a/packaging/70-hxstomp.rules b/packaging/70-hxstomp.rules index 2156116..5a88bbe 100644 --- a/packaging/70-hxstomp.rules +++ b/packaging/70-hxstomp.rules @@ -14,6 +14,8 @@ SUBSYSTEM=="usb", ATTR{idVendor}=="0e41", ATTR{idProduct}=="4246", MODE="0660", TAG+="uaccess" # Helix Floor — verified: byte-identical handshake and edit path (see docs/helix-floor.md). SUBSYSTEM=="usb", ATTR{idVendor}=="0e41", ATTR{idProduct}=="4248", MODE="0660", TAG+="uaccess" +# Helix LT — reads verified against a physical unit; no edit has been sent to one. +SUBSYSTEM=="usb", ATTR{idVendor}=="0e41", ATTR{idProduct}=="424a", MODE="0660", TAG+="uaccess" # HX Stomp XL — untested; we have no capture from one. Same family, so the rule is here, but # fretwire will warn when it opens it. SUBSYSTEM=="usb", ATTR{idVendor}=="0e41", ATTR{idProduct}=="4253", MODE="0660", TAG+="uaccess"