Skip to main content

nord_usb/
wire.rs

1//! The vendor wire protocol.
2//!
3//! Every message on the vendor bulk endpoints is a length-prefixed, CRC-trailered
4//! frame of **big-endian** `u32`s. Note big-endian — the *file* formats
5//! ([`nord_format`]) are little-endian.
6//!
7//! ```text
8//! ┌────────┬─────────┬───────────┬─────────┬───────────────┬───────┐
9//! │ length │ service │ subsystem │ command │ args…         │ crc16 │
10//! │  u32   │   u32   │    u32    │   u32   │               │  u16  │
11//! └────────┴─────────┴───────────┴─────────┴───────────────┴───────┘
12//!   total inc. crc                          responses lead   over all
13//!                                           with u32 status  preceding bytes
14//! ```
15//!
16//! Derived from captured traffic. Confirmed on hardware. This framing carries every
17//! operation the crate performs, and two platforms emit byte-identical request frames
18//! for the same verb. What an individual command *means* is a separate question, and
19//! several below are still open.
20//!
21//! A response to a request is `command + 1` and inserts a `u32` status (0 = success)
22//! ahead of the echoed arguments. The unsolicited [`cmd::CHANGED`] notification is
23//! status-less.
24//!
25//! Requests are *usually* even, but that is a pattern and not a rule — [`cmd::SELECT`]
26//! is `0x2f`, an odd request whose response is `0x30`. **Direction is the only reliable
27//! discriminator**, which is why this module records it at decode time rather than
28//! deriving it (see [`Message::decode_response`]).
29
30use std::num::NonZeroU32;
31
32use crate::error::{Error, Result};
33use nord_format::accept::Slot;
34use nord_format::fields::Library;
35
36/// Bytes ahead of the argument region: length, service, subsystem, command.
37pub const HEADER_LEN: usize = 16;
38/// Trailing CRC-16.
39pub const CRC_LEN: usize = 2;
40
41/// Functional area the message is addressed to.
42///
43/// Only two are observed so far. `Ui` carries the human-readable progress strings
44/// NSM displays (`"Deleting..."`, `"Uploading..."`); `Program` is where the actual
45/// work happens.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Service {
48    /// Session control and UI progress strings. Pairs with subsystem `1`.
49    Ui,
50    /// Program/slot operations. Pairs with subsystem `10`.
51    Program,
52    Unknown(u32),
53}
54
55impl Service {
56    pub fn from_raw(v: u32) -> Self {
57        match v {
58            6 => Service::Ui,
59            12 => Service::Program,
60            other => Service::Unknown(other),
61        }
62    }
63
64    pub fn to_raw(self) -> u32 {
65        match self {
66            Service::Ui => 6,
67            Service::Program => 12,
68            Service::Unknown(v) => v,
69        }
70    }
71}
72
73/// Command codes observed on [`Service::Program`] (subsystem 10).
74///
75/// The response is always the request `+1`, so only the request code is named. Codes are
76/// what the device actually sent — not guesses. Most requests happen to be even;
77/// [`cmd::SELECT`] is the counter-example, so do not treat parity as meaning anything.
78pub mod cmd {
79    /// Open the transaction (the `O22 I26` that starts every operation).
80    pub const SESSION_OPEN: u32 = 0x04;
81    /// Close the transaction.
82    pub const SESSION_CLOSE: u32 = 0x06;
83    /// Device/memory status; the response carries several counters.
84    pub const STATUS: u32 = 0x08;
85    /// Delete a program.
86    pub const DELETE: u32 = 0x14;
87    /// Read a program's data. Response body is a reframed entity.
88    pub const READ: u32 = 0x12;
89    /// Copy/duplicate an object: `src_bank, src_slot, dst_bank, dst_slot`. The device
90    /// copies internally — no body crosses the wire.
91    pub const COPY: u32 = 0x16;
92    /// Move a program between slots.
93    pub const MOVE: u32 = 0x18;
94    /// Read a program's metadata (name, format tag).
95    pub const INFO: u32 = 0x1e;
96    /// Rename a program; args carry a length-prefixed string.
97    pub const RENAME: u32 = 0x1c;
98    /// Select an object live on the instrument ("open on device" / double-click).
99    /// Non-destructive: nothing stored changes, the device just loads it. This is the
100    /// one request with inverted parity — odd code, even response (`0x30`) — so its
101    /// direction cannot be inferred from the command number.
102    pub const SELECT: u32 = 0x2f;
103    /// Re-link an object's dependency table ("set slot table"). Rewrites which library
104    /// pianos/samples a program points at, or which programs a set list holds. Its
105    /// payload semantics (notably the per-entry flag byte) are not fully pinned down,
106    /// so no typed operation is built on it yet — the code is named for completeness.
107    pub const RELINK: u32 = 0x35;
108
109    /// Begin writing an entity. Args: bank, slot, body length, format tag, timestamp,
110    /// `0xFFFFFFFF`, then the slot's **name**, length-prefixed — a placeholder name
111    /// becomes the slot's name.
112    pub const BEGIN_WRITE: u32 = 0x0a;
113
114    /// Reclaim library storage; the argument is a block count (256KiB blocks, what
115    /// `STATUS` counts). A library `BEGIN_WRITE` short on free blocks is refused
116    /// `0x16` until this has run in the same session. Destroys nothing.
117    pub const WRITE_PREPARE: u32 = 0x22;
118    /// Query the cleaning pass: three reply words `[requested, done, running]`, ready
119    /// when `running` is 0. Only meaningful after `0x22` in the same session.
120    pub const WRITE_PREPARE_2: u32 = 0x26;
121    /// Begin reading an entity. Args: bank, slot.
122    pub const BEGIN_READ: u32 = 0x0c;
123    /// Finish a transfer, either direction. Args: bank, slot.
124    pub const END_TRANSFER: u32 = 0x0e;
125    /// Push entity bytes. Args: bank, slot, offset, length, then the body.
126    pub const WRITE_DATA: u32 = 0x10;
127    /// List an entity's piano/sample dependencies.
128    pub const DEPENDENCIES: u32 = 0x28;
129
130    /// List the device's storage partitions. No arguments.
131    ///
132    /// **The partition index is the object class code.** The classes this crate names
133    /// are positions in this table, which is why the numbering has gaps: 0 and 2 are
134    /// `(Native)` variants of the piano and sample libraries, holding the same objects in
135    /// a different order.
136    pub const PARTITIONS: u32 = 0x00;
137
138    /// List one partition's banks and their slot capacity. Args: partition index.
139    ///
140    /// The only source of a class's geometry. Piano "banks" are the panel's categories
141    /// (`Grand`, `Upright`, …), so a piano address is category:position.
142    pub const BANKS: u32 = 0x02;
143
144    /// The object the panel currently has loaded, for the session's class. No arguments;
145    /// the reply is a bank/slot pair. The read half of [`SELECT`].
146    ///
147    /// Class-dependent: status `0x1` when nothing of the session's class is loaded, and
148    /// status `0x15` from the library classes, which have no focus at all.
149    pub const FOCUS: u32 = 0x31;
150
151    /// Adjacent occupied slot: `bank, slot, direction` (`0` forward, `1` backward);
152    /// slot `0xffff_ffff` walks from the bank's boundary. Status `1` is the
153    /// end-of-walk signal, not a fault; an empty bank and a missing bank answer
154    /// identically, so bank existence needs [`INFO`]. ⚠️ Omitting the direction word
155    /// is refused `0x11` after any write since power-up.
156    pub const NEXT_SLOT: u32 = 0x20;
157
158    /// Erases an entire partition.
159    ///
160    /// Reported by public documentation; not confirmed on hardware. Deliberately left
161    /// unconfirmed: a session is class-scoped, so the session is what aims this —
162    /// opened on a library class it takes the whole piano or sample store, which is
163    /// hundreds of megabytes and a long restore from a backup. Named here so it can be
164    /// recognised and refused, not so it can be sent.
165    pub const ERASE_ALL: u32 = 0x24;
166
167    /// Highest command the instrument has ever been seen to answer.
168    ///
169    /// Above this is unexplored space, and it is not empty: at least one code up there
170    /// reaches a destructive path, paints its own progress label, never replies, and
171    /// needs a power cycle. Distance from the known range is not evidence that a code is
172    /// unimplemented.
173    pub const HIGHEST_ANSWERING: u32 = 0x3d;
174
175    /// Wedges the instrument: no reply, the session's close goes unanswered, and the
176    /// bulk endpoints stall until a power cycle. Reported elsewhere as the read half of
177    /// [`NOTIFY_ENABLE`], which is not what it does here.
178    pub const NOTIFY_READ_WEDGE: u32 = 0x2a;
179
180    /// Unsolicited device → host notification — no request pairs with it, so it
181    /// arrives in place of whatever reply the host reads for next. Queued by a
182    /// front-panel STORE while a cable session was possible.
183    ///
184    /// Confirmed on hardware.
185    ///
186    /// Unexplained: what it announces. It is absent from the capture corpus, so nothing
187    /// pins the meaning down beyond the store that produced it.
188    pub const CHANGED: u32 = 0x2c;
189
190    /// Enable/disable change notifications for a class: `class, on`. The reported
191    /// read half is [`NOTIFY_READ_WEDGE`].
192    pub const NOTIFY_ENABLE: u32 = 0x2d;
193}
194
195/// The UI/session service (service 6, subsystem 1): the transaction's outer handshake
196/// and the progress strings NSM paints on the **instrument's own display** during a
197/// transfer.
198///
199/// The progress messages ([`ui::label`], [`ui::percent`]) are **fire-and-forget** — the
200/// device never replies. They must be sent with `Session::notify`, never `request`,
201/// which would block forever waiting for a response that never comes.
202pub mod ui {
203    use super::{Message, Service};
204    use crate::error::{Error, Result};
205
206    /// Subsystem paired with [`Service::Ui`].
207    pub const SUBSYSTEM: u32 = 1;
208    /// Open the UI side of a transaction (the `O18 I22` that starts every operation).
209    pub const HELLO: u32 = 0x00;
210    /// Close the UI side of a transaction.
211    pub const GOODBYE: u32 = 0x02;
212    /// A text progress label, e.g. `"Downloading..."`.
213    pub const LABEL: u32 = 0x06;
214    /// A progress percentage, 0..=100.
215    pub const PERCENT: u32 = 0x07;
216
217    /// The longest label the one-byte length field can describe.
218    pub const MAX_LABEL_LEN: usize = u8::MAX as usize;
219
220    /// A progress label. Layout is six zero bytes, a one-byte length, then unpadded
221    /// ASCII — read straight off the wire and byte-for-byte reproducible.
222    ///
223    /// Fails for a label longer than [`MAX_LABEL_LEN`] **bytes** rather than truncating
224    /// the length into a `u8`: a 256-byte label would silently encode a length of `0`
225    /// and put a malformed frame on the wire. Note the bound is on UTF-8 bytes, not
226    /// characters.
227    pub fn label(text: &str) -> Result<Message> {
228        if text.len() > MAX_LABEL_LEN {
229            return Err(Error::InvalidArgument(format!(
230                "progress label is {} bytes; the length field holds at most {MAX_LABEL_LEN}",
231                text.len(),
232            )));
233        }
234        let mut args = vec![0u8; 6];
235        args.push(text.len() as u8);
236        args.extend_from_slice(text.as_bytes());
237        Ok(Message::new(Service::Ui, SUBSYSTEM, LABEL, args))
238    }
239
240    /// A progress percentage. Layout is a constant `u16` 1 then the value as a `u16`.
241    ///
242    /// Clamped to 100. Unlike [`label`] an out-of-range value cannot produce a
243    /// malformed frame — every `u16` encodes fine — so this is a cosmetic nonsense
244    /// value on the instrument's display, not a protocol error.
245    pub fn percent(pct: u16) -> Message {
246        let mut args = 1u16.to_be_bytes().to_vec();
247        args.extend_from_slice(&pct.min(100).to_be_bytes());
248        Message::new(Service::Ui, SUBSYSTEM, PERCENT, args)
249    }
250}
251
252/// What [`cmd::INFO`] reports about one slot.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct ProgramInfo {
255    pub location: Location,
256    /// Length of the entity body on the wire — 121 for an Electro 5 program.
257    pub body_len: u32,
258    /// Four-character CBIN format tag, e.g. `ne5p`.
259    pub format: String,
260    /// Schema/content version, the same field the CBIN header carries at `0x14` and
261    /// the one NSM prints in its "Version" column.
262    ///
263    /// Per format tag, not a per-item counter: `ne5p` reports 4 and `ne5t` reports 0
264    /// or 1. For library content it is the version in the object's own *name*, ×100 —
265    /// `Royal Grand 3D YaS6 XL 5.4` reports `540`.
266    pub version: u32,
267    /// CRC-32 of the body, as the device reports it. Lets a read be verified
268    /// against the device's own checksum rather than trusting the transfer.
269    ///
270    /// `None` for classes the device does not checksum — pianos and samples report
271    /// `0xffffffff` rather than a real value, which is normalized away here so callers
272    /// cannot mistake it for a checksum to verify against.
273    pub crc32: Option<u32>,
274    /// Slot name as shown on the instrument. Stored nowhere in the file itself.
275    pub name: String,
276}
277
278impl ProgramInfo {
279    /// Fixed offsets ahead of the name: bank, slot, body_len, format, version, and the
280    /// two `0xffffffff` words, then the name's own length.
281    const NAME_LEN_AT: usize = 28;
282
283    pub fn decode(msg: &Message) -> Result<Self> {
284        // A request-decoded message retains the status position and shifts every field.
285        if !msg.is_response() {
286            return Err(Error::InvalidArgument(
287                "object info must be decoded from a response (use Message::decode_response)".into(),
288            ));
289        }
290        let p = msg.payload();
291        if p.len() < Self::NAME_LEN_AT + 4 {
292            return Err(Error::Truncated {
293                got: p.len(),
294                need: Self::NAME_LEN_AT + 4,
295            });
296        }
297        let word = |i: usize| u32::from_be_bytes(p[i..i + 4].try_into().unwrap());
298
299        // Words 20 and 24 vary for libraries, so preserve their position without asserting them.
300        let name_len = word(Self::NAME_LEN_AT) as usize;
301        let name_start = Self::NAME_LEN_AT + 4;
302        let name_end = checked_end(p, name_start, name_len)?;
303        let name = String::from_utf8_lossy(&p[name_start..name_end])
304            .trim_end()
305            .to_owned();
306
307        // Trailing word, past the padding. Absent if the reply stops at the name.
308        let crc32 = match p.len().saturating_sub(name_end) >= 4 {
309            true => match word(p.len() - 4) {
310                u32::MAX => None,
311                crc => Some(crc),
312            },
313            false => None,
314        };
315
316        Ok(Self {
317            location: Location {
318                bank: word(0),
319                slot: word(4),
320            },
321            body_len: word(8),
322            format: String::from_utf8_lossy(&p[12..16]).into_owned(),
323            version: word(16),
324            crc32,
325            name,
326        })
327    }
328}
329
330/// Fixed-size field block trailing each partition record.
331const PARTITION_FIELDS: usize = 29;
332
333pub(crate) fn read_u32(buf: &[u8], at: usize) -> Result<u32> {
334    let end = at.checked_add(4).ok_or(Error::Truncated {
335        got: buf.len(),
336        need: usize::MAX,
337    })?;
338    buf.get(at..end)
339        .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
340        .ok_or(Error::Truncated {
341            got: buf.len(),
342            need: end,
343        })
344}
345
346fn checked_end(buf: &[u8], start: usize, len: usize) -> Result<usize> {
347    let end = start.checked_add(len).ok_or(Error::Truncated {
348        got: buf.len(),
349        need: usize::MAX,
350    })?;
351    if end > buf.len() {
352        return Err(Error::Truncated {
353            got: buf.len(),
354            need: end,
355        });
356    }
357    Ok(end)
358}
359
360/// One of the device's storage partitions, from [`cmd::PARTITIONS`].
361///
362/// **The index in the reply is the object class code** — `ObjectClass::from_raw` numbers
363/// positions in this table, gaps included.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct Partition {
366    /// Position in the table, and therefore the class code.
367    pub index: u32,
368    /// The device's own name: `Piano`, `Samp Lib`, `Program`, `Set List`, …
369    pub name: String,
370    /// Whether this is the `(Native)` view of a library. Native and user partitions
371    /// describe **one** pool — identical capacity fields — ordered differently.
372    pub native: bool,
373    /// The 29 trailing bytes, verbatim: four big-endian words and then 13 one-byte
374    /// flags. Only the words this type exposes an accessor for are decoded; the rest are
375    /// carried so a caller can look at them without another read.
376    ///
377    /// Static configuration, not state — every value is unchanged by storing or deleting
378    /// content.
379    ///
380    /// Confirmed on hardware.
381    pub fields: Vec<u8>,
382}
383
384/// One bank within a partition, from [`cmd::BANKS`].
385#[derive(Debug, Clone, PartialEq, Eq)]
386pub struct Bank {
387    /// Zero-based position, as addresses use it. The panel shows this plus one.
388    pub index: u32,
389    /// The device's name for it. For pianos these are the panel's **categories**
390    /// (`Grand`, `Upright`, `EPiano1`, …), not numbers.
391    pub name: String,
392    /// How many slots the bank holds. `0xfffe` appears for the `(Native)` partitions and
393    /// is a sentinel, not a capacity.
394    pub slots: u32,
395}
396
397impl Partition {
398    /// Decode a [`cmd::PARTITIONS`] reply: `[u8 count]` then that many
399    /// `[u32 name_len][name][29 bytes]` records.
400    ///
401    /// ⚠️ The length prefix is a **`u32`**. Read as a `u16` the first record still parses
402    /// and every one after it lands mid-field, which looks like corruption rather than a
403    /// framing mistake.
404    pub fn decode_all(msg: &Message) -> Result<Vec<Self>> {
405        if !msg.is_response() {
406            return Err(Error::InvalidArgument(
407                "partitions must be decoded from a response".into(),
408            ));
409        }
410        let p = msg.payload();
411        let count = *p.first().ok_or(Error::Truncated { got: 0, need: 1 })? as usize;
412        let mut out = Vec::with_capacity(count);
413        let mut at = 1;
414        for index in 0..count {
415            let len = read_u32(p, at)? as usize;
416            let name_start = checked_end(p, at, 4)?;
417            let end = checked_end(p, name_start, len)?;
418            let fields_end = checked_end(p, end, PARTITION_FIELDS)?;
419            let name = String::from_utf8_lossy(&p[name_start..end])
420                .trim_end()
421                .to_string();
422            out.push(Partition {
423                index: index as u32,
424                native: name.contains("(Native)"),
425                name,
426                fields: p[end..fields_end].to_vec(),
427            });
428            at = fields_end;
429        }
430        Ok(out)
431    }
432
433    /// The partition's allocation granularity, in **net** bytes — the payload one unit of
434    /// whatever [`Status`] counts here holds.
435    ///
436    /// Library partitions report a storage block minus its own overhead; an Electro 5
437    /// reports `261632` (256 KiB − 512) for pianos and `131064` (128 KiB − 8) for
438    /// samples. Slot-addressed partitions report `1`, which is what says their counters
439    /// are byte-granular.
440    ///
441    /// ⚠️ Net, not gross. Sizing a write off the enclosing power-of-two block instead
442    /// differs only for a body within the overhead of an exact block boundary, but it
443    /// differs by a whole block when it does.
444    ///
445    /// Confirmed on hardware.
446    pub fn allocation_unit(&self) -> Result<AllocationUnit> {
447        let word = read_u32(&self.fields, 0)?;
448        let bytes = NonZeroU32::new(word).ok_or_else(|| {
449            Error::InvalidArgument(format!(
450                "partition {} reports an allocation unit of 0, which sizes nothing",
451                self.index
452            ))
453        })?;
454        Ok(AllocationUnit {
455            partition: self.index,
456            bytes,
457        })
458    }
459}
460
461/// Net bytes per unit of whatever [`Status`] counts for a partition: `1` where the
462/// counters are byte-granular, the net storage block in a library.
463///
464/// See [`Partition::allocation_unit`], which is the only source of one.
465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
466pub struct AllocationUnit {
467    partition: u32,
468    bytes: NonZeroU32,
469}
470
471impl AllocationUnit {
472    pub fn get(self) -> u32 {
473        self.bytes.get()
474    }
475
476    /// Whether this partition's counters are byte-granular rather than block-granular.
477    pub fn is_bytes(self) -> bool {
478        self.bytes.get() == 1
479    }
480
481    pub(crate) fn belongs_to(self, partition: u32) -> bool {
482        self.partition == partition
483    }
484
485    /// How many units a body of `bytes` occupies.
486    ///
487    /// ⚠️ Rounds up. Undercounting makes [`cmd::BEGIN_WRITE`] refuse `0x16` even
488    /// straight after a cleaning pass that reclaimed what the undercount asked for.
489    pub fn blocks_for(self, bytes: usize) -> Result<u32> {
490        let bytes = u64::try_from(bytes)
491            .map_err(|_| Error::InvalidArgument("the body is larger than u64".into()))?;
492        u32::try_from(bytes.div_ceil(u64::from(self.bytes.get()))).map_err(|_| {
493            Error::InvalidArgument(format!(
494                "a body of {bytes} bytes is more units of {} than the wire's u32 holds",
495                self.bytes.get()
496            ))
497        })
498    }
499}
500
501impl Bank {
502    /// Decode a [`cmd::BANKS`] reply: the echoed partition, a count, then
503    /// `[u32 name_len][name][u32 slots]` records.
504    pub fn decode_all(msg: &Message) -> Result<Vec<Self>> {
505        if !msg.is_response() {
506            return Err(Error::InvalidArgument(
507                "banks must be decoded from a response".into(),
508            ));
509        }
510        let p = msg.payload();
511        let count = *p.get(4).ok_or(Error::Truncated {
512            got: p.len(),
513            need: 5,
514        })? as usize;
515        let mut out = Vec::with_capacity(count);
516        let mut at = 5;
517        for index in 0..count {
518            let len = read_u32(p, at)? as usize;
519            let name_start = checked_end(p, at, 4)?;
520            let end = checked_end(p, name_start, len)?;
521            let name = String::from_utf8_lossy(&p[name_start..end])
522                .trim_end()
523                .to_string();
524            out.push(Bank {
525                index: index as u32,
526                name,
527                slots: read_u32(p, end)?,
528            });
529            at = end + 4;
530        }
531        Ok(out)
532    }
533
534    /// The sentinel the `(Native)` partitions report instead of a real capacity.
535    pub const UNBOUNDED: u32 = 0xfffe;
536
537    /// Whether [`Self::slots`] is a real capacity rather than the sentinel.
538    pub fn is_bounded(&self) -> bool {
539        self.slots != Self::UNBOUNDED
540    }
541}
542
543/// One entry from a [`cmd::DEPENDENCIES`] response: a piano or sample that a program
544/// (or a program that a set list) references.
545///
546/// The library `id` is the same id the object carries in its own file — a
547/// `PianoPanel`'s piano id, a sample's sample id — so this is the bridge between the
548/// content on the wire and the bytes on disk.
549pub struct Dependency {
550    /// Whether this reference is **live**: `1` when the section owning it (piano or
551    /// sample) is routed to a keyboard part in that program, `0` otherwise.
552    ///
553    /// ⚠️ Not a presence flag. The device resolves an unrouted section's model index to
554    /// a library object anyway, so a `0` row can name a piano the program's own body
555    /// records as `none` — and the same object reads `1` from one program and `0` from
556    /// another. **Filter on this before treating a row as a dependency**, or a bundle
557    /// walk collects objects nothing plays.
558    pub flag: u8,
559    /// What kind of object this dependency is (piano, sample, program).
560    pub class: ObjectClass,
561    /// Content id, matching the id in the object's own file header.
562    pub id: u32,
563    /// Human-readable name — which the `.ne5p`/`.ne5t` files do not themselves store.
564    pub name: String,
565    /// Slot address, for slot-addressed dependencies (programs). Library content
566    /// (pianos, samples) is addressed by `id` and reports no location.
567    pub location: Option<Location>,
568}
569
570impl Dependency {
571    /// Whether this row is a dependency the object actually has.
572    ///
573    /// A row addresses its object one of two ways: library content (pianos, samples)
574    /// by [`Self::id`], slot-addressed content (a set list's programs) by
575    /// [`Self::location`] — with `id` always `0`. Confirmed on hardware. Requiredness
576    /// therefore asks whether the row addresses *anything*, by either field; an
577    /// id-only filter silently classifies every set-list dependency as unassigned and
578    /// a set-list bundle walk collects nothing.
579    ///
580    /// Two kinds of row are reported but are **not** dependencies, and both look like one
581    /// at a glance:
582    ///
583    /// - The section owning it is not routed to a keyboard part ([`Self::flag`] `0`). The
584    ///   device resolves the section's model index to a library object regardless, so the
585    ///   row can name a piano the object's own body records as `none`.
586    /// - The section *is* routed but nothing is assigned to it, giving a live flag with a
587    ///   null [`Self::id`] and no location.
588    ///
589    /// Anything collecting an object's real requirements — a bundle walk above all —
590    /// wants this rather than the raw list, or it goes looking for objects that either
591    /// are not played or do not exist.
592    pub fn is_required(&self) -> bool {
593        self.flag == 1 && (self.id != 0 || self.location.is_some())
594    }
595
596    /// Decode a whole [`cmd::DEPENDENCIES`] response into the list it carries.
597    ///
598    /// Layout after the leading `bank, slot, count`, each entry is
599    /// `[u8 flag][u32 reserved][u32 class][u32 id][u32 name_len][name][u32 has_location][u32 bank][u32 slot]`
600    /// with no alignment padding, so an entry is `29 + name_len` bytes.
601    pub fn decode_all(msg: &Message) -> Result<Vec<Self>> {
602        // Request decoding leaves the status position in place and shifts every entry.
603        if !msg.is_response() {
604            return Err(Error::InvalidArgument(
605                "dependency list must be decoded from a response (use Message::decode_response)"
606                    .into(),
607            ));
608        }
609        let p = msg.payload();
610        if p.len() < 12 {
611            return Err(Error::Truncated {
612                got: p.len(),
613                need: 12,
614            });
615        }
616        let word = |i: usize| u32::from_be_bytes(p[i..i + 4].try_into().unwrap());
617        let count = word(8) as usize;
618
619        // Bound allocation by the payload's minimum possible entry count.
620        let mut out = Vec::with_capacity(count.min((p.len() - 12) / 29));
621        let mut i = 12;
622        for _ in 0..count {
623            // flag(1) + reserved(4) + class(4) + id(4) + name_len(4) = 17 bytes.
624            let name_start = checked_end(p, i, 17)?;
625            let flag = p[i];
626            let class = ObjectClass::from_raw(word(i + 5));
627            let id = word(i + 9);
628            let name_len = word(i + 13) as usize;
629            let name_end = checked_end(p, name_start, name_len)?;
630            let record_end = checked_end(p, name_end, 12)?;
631            let name = String::from_utf8_lossy(&p[name_start..name_end]).into_owned();
632            let has_location = word(name_end) != 0;
633            let location = has_location.then(|| Location {
634                bank: word(name_end + 4),
635                slot: word(name_end + 8),
636            });
637            out.push(Self {
638                flag,
639                class,
640                id,
641                name,
642                location,
643            });
644            i = record_end;
645        }
646        Ok(out)
647    }
648}
649
650/// CRC-16/CCITT-FALSE — poly `0x1021`, init `0xFFFF`, no reflection, no xorout.
651///
652/// Identified from known message/trailer pairs and checked across the capture corpus.
653pub fn crc16(data: &[u8]) -> u16 {
654    let mut crc: u16 = 0xFFFF;
655    for &byte in data {
656        crc ^= (byte as u16) << 8;
657        for _ in 0..8 {
658            crc = if crc & 0x8000 != 0 {
659                (crc << 1) ^ 0x1021
660            } else {
661                crc << 1
662            };
663        }
664    }
665    crc
666}
667
668/// One protocol message, decoded.
669#[derive(Debug, Clone, PartialEq, Eq)]
670pub struct Message {
671    pub service: Service,
672    pub subsystem: u32,
673    pub command: u32,
674    /// Everything between the command word and the CRC. Ordinary responses include
675    /// their leading status word; [`cmd::CHANGED`] does not.
676    pub args: Vec<u8>,
677    /// Set by the decoder from the direction the bytes traveled. Not inferable from
678    /// the command code — see [`Message::is_response`].
679    is_response: bool,
680}
681
682/// The protocol version to put on [`Service::Program`] frames, when something other than
683/// the caller's is wanted.
684///
685/// The device treats 8, 9 and 10 as synonyms and drops anything newer; **values below 8
686/// stall the bulk endpoints and need a power cycle**, so this exists to compare the
687/// accepted window, not to sweep.
688#[cfg(feature = "fault-injection")]
689fn protocol_version_override() -> Option<u32> {
690    std::env::var("NORD_PROTOCOL_VERSION")
691        .ok()
692        .and_then(|v| v.parse().ok())
693}
694
695#[cfg(not(feature = "fault-injection"))]
696fn protocol_version_override() -> Option<u32> {
697    None
698}
699
700impl Message {
701    /// A request, to send to the device.
702    pub fn new(service: Service, subsystem: u32, command: u32, args: Vec<u8>) -> Self {
703        // Only the program service carries a version here; the UI service's `1` is a real
704        // subsystem selector and overriding it would be a different frame entirely.
705        let subsystem = match service {
706            Service::Program => protocol_version_override().unwrap_or(subsystem),
707            _ => subsystem,
708        };
709        Self {
710            service,
711            subsystem,
712            command,
713            args,
714            is_response: false,
715        }
716    }
717
718    /// Whether this message was decoded as a device response.
719    ///
720    /// **Direction, not parity.** Parity invites the guess and does not support it: the
721    /// "select in instrument" command is `0x2f` (odd) with response `0x30` (even),
722    /// exactly inverting it. The `response == request + 1` rule does hold — only the
723    /// parity of the request does not. Getting this backwards silently misaligns
724    /// [`Self::payload`] by four bytes and hides device errors, so it is recorded at
725    /// decode time by the side that knows.
726    pub fn is_response(&self) -> bool {
727        self.is_response
728    }
729
730    /// The status word an ordinary response leads with. `Some(0)` is success.
731    pub fn status(&self) -> Option<u32> {
732        if !self.is_response || self.command == cmd::CHANGED || self.args.len() < 4 {
733            return None;
734        }
735        Some(u32::from_be_bytes(self.args[..4].try_into().ok()?))
736    }
737
738    /// Arguments with an ordinary response's status stripped. Notifications are unchanged.
739    pub fn payload(&self) -> &[u8] {
740        if self.is_response && self.command != cmd::CHANGED && self.args.len() >= 4 {
741            &self.args[4..]
742        } else {
743            &self.args
744        }
745    }
746
747    pub fn encode(&self) -> Vec<u8> {
748        let len = (HEADER_LEN + self.args.len() + CRC_LEN) as u32;
749        let mut out = Vec::with_capacity(len as usize);
750        out.extend_from_slice(&len.to_be_bytes());
751        out.extend_from_slice(&self.service.to_raw().to_be_bytes());
752        out.extend_from_slice(&self.subsystem.to_be_bytes());
753        out.extend_from_slice(&self.command.to_be_bytes());
754        out.extend_from_slice(&self.args);
755        out.extend_from_slice(&crc16(&out).to_be_bytes());
756        out
757    }
758
759    /// Decode bytes received *from* the device.
760    pub fn decode_response(buf: &[u8]) -> Result<Self> {
761        let mut m = Self::decode(buf)?;
762        // CHANGED is an unsolicited notification, not a command response, and carries no status.
763        if m.command != cmd::CHANGED && buf.len() < HEADER_LEN + 4 + CRC_LEN {
764            return Err(Error::Truncated {
765                got: buf.len(),
766                need: HEADER_LEN + 4 + CRC_LEN,
767            });
768        }
769        m.is_response = true;
770        Ok(m)
771    }
772
773    /// Decode an exploratory reply without requiring the ordinary status word.
774    /// A short frame is an observation, not a typed operation failure.
775    pub fn decode_probe(buf: &[u8]) -> Result<Self> {
776        let mut m = Self::decode(buf)?;
777        m.is_response = true;
778        Ok(m)
779    }
780
781    /// Decode bytes without asserting a direction; treated as a request.
782    /// Prefer [`Self::decode_response`] for anything read off the wire.
783    pub fn decode(buf: &[u8]) -> Result<Self> {
784        if buf.len() < HEADER_LEN + CRC_LEN {
785            return Err(Error::Truncated {
786                got: buf.len(),
787                need: HEADER_LEN + CRC_LEN,
788            });
789        }
790        let declared = u32::from_be_bytes(buf[0..4].try_into().unwrap()) as usize;
791        if declared != buf.len() {
792            return Err(Error::LengthMismatch {
793                declared,
794                actual: buf.len(),
795            });
796        }
797
798        let split = buf.len() - CRC_LEN;
799        let expected = u16::from_be_bytes(buf[split..].try_into().unwrap());
800        let actual = crc16(&buf[..split]);
801        if expected != actual {
802            return Err(Error::BadCrc { expected, actual });
803        }
804
805        Ok(Self {
806            service: Service::from_raw(u32::from_be_bytes(buf[4..8].try_into().unwrap())),
807            subsystem: u32::from_be_bytes(buf[8..12].try_into().unwrap()),
808            command: u32::from_be_bytes(buf[12..16].try_into().unwrap()),
809            args: buf[HEADER_LEN..split].to_vec(),
810            is_response: false,
811        })
812    }
813}
814
815/// What kind of object a session is about.
816///
817/// `SESSION_OPEN` carries one of these, and [`cmd::STATUS`] then reports on that class
818/// alone. Confirmed on hardware. **The class code is the device's partition index**,
819/// and the partition table names each one. An unrecognized numeric class is preserved.
820///
821/// The gaps at `0` and `2` are the `Piano (Native)` and `Samp Lib (Native)` partitions
822/// — a second view of the same objects in storage order rather than by category. Both
823/// are readable and neither is modelled here.
824#[derive(Debug, Clone, Copy, PartialEq, Eq)]
825pub enum ObjectClass {
826    Piano,
827    Sample,
828    Program,
829    SetList,
830    /// The three Live slots. Wire-addressed `0:0..0:2`; bodies are `ne5p`-shaped.
831    Live,
832    /// The global settings singleton, wire-addressed `0:0`. Reports no body checksum
833    /// (`0xffffffff`), like the library classes.
834    Settings,
835    Unknown(u32),
836}
837
838/// The four library classes are the libraries a decoded body can refer into, and their
839/// codes are [`Library::code`]'s — one table for a caller holding both.
840impl From<Library> for ObjectClass {
841    fn from(library: Library) -> Self {
842        match library {
843            Library::Piano => ObjectClass::Piano,
844            Library::Sample => ObjectClass::Sample,
845            Library::Program => ObjectClass::Program,
846            Library::SetList => ObjectClass::SetList,
847        }
848    }
849}
850
851impl ObjectClass {
852    pub fn from_raw(v: u32) -> Self {
853        if let Some(library) = u8::try_from(v).ok().and_then(Library::from_code) {
854            return library.into();
855        }
856        match v {
857            6 => ObjectClass::Live,
858            7 => ObjectClass::Settings,
859            other => ObjectClass::Unknown(other),
860        }
861    }
862
863    pub fn to_raw(self) -> u32 {
864        match self {
865            ObjectClass::Piano => Library::Piano.code().into(),
866            ObjectClass::Sample => Library::Sample.code().into(),
867            ObjectClass::Program => Library::Program.code().into(),
868            ObjectClass::SetList => Library::SetList.code().into(),
869            ObjectClass::Live => 6,
870            ObjectClass::Settings => 7,
871            ObjectClass::Unknown(v) => v,
872        }
873    }
874
875    /// The classes worth querying for an inventory. Live and Settings also answer, but
876    /// report zero items — they are singletons, not slot-counted storage.
877    pub const INVENTORY: [ObjectClass; 4] = [
878        ObjectClass::Piano,
879        ObjectClass::Sample,
880        ObjectClass::Program,
881        ObjectClass::SetList,
882    ];
883
884    pub fn label(self) -> String {
885        match self {
886            ObjectClass::Piano => "pianos".into(),
887            ObjectClass::Sample => "samples".into(),
888            ObjectClass::Program => "programs".into(),
889            ObjectClass::SetList => "set lists".into(),
890            ObjectClass::Live => "live slots".into(),
891            ObjectClass::Settings => "settings".into(),
892            ObjectClass::Unknown(v) => format!("class {v}"),
893        }
894    }
895
896    /// The storage class `nord_format`'s acceptance table names this one by — the four
897    /// libraries and the two singletons, without the wire. `None` for a code this crate
898    /// does not name, which no table row can be about.
899    pub fn storage(self) -> Option<Slot> {
900        match self {
901            ObjectClass::Piano => Some(Slot::Piano),
902            ObjectClass::Sample => Some(Slot::Sample),
903            ObjectClass::Program => Some(Slot::Program),
904            ObjectClass::SetList => Some(Slot::SetList),
905            ObjectClass::Live => Some(Slot::Live),
906            ObjectClass::Settings => Some(Slot::Settings),
907            ObjectClass::Unknown(_) => None,
908        }
909    }
910
911    /// Whether this class is one of the content libraries, whose objects vary in size
912    /// and whose [`Status`] counters are storage blocks rather than bytes.
913    pub fn is_library(self) -> bool {
914        matches!(self, ObjectClass::Piano | ObjectClass::Sample)
915    }
916
917    /// Whether a write into an *occupied* slot of this class lands without deleting it
918    /// first.
919    ///
920    /// Confirmed on hardware.
921    ///
922    /// Live and Settings accept the ordinary `BEGIN_WRITE` → `WRITE_DATA` →
923    /// `END_TRANSFER` sequence at their occupied slots and the body
924    /// reads back as what was sent, where every other class answers status `0x4` until
925    /// the slot is empty. Their delete has never been attempted, so composing a write
926    /// out of delete-then-write there is both unnecessary and untested.
927    pub fn overwrites_in_place(self) -> bool {
928        matches!(self, ObjectClass::Live | ObjectClass::Settings)
929    }
930
931    /// Whether the device stores a name for the objects of this class.
932    ///
933    /// Confirmed on hardware.
934    ///
935    /// Live and Settings hold fixed names (`Live 1`, `Settings`) — they answer
936    /// `0x1c` rename with success and change nothing, and they carry `BEGIN_WRITE`'s
937    /// name argument and discard it. Partition record word 3, the slot-family name
938    /// length, is `0` for both.
939    pub fn names_its_slots(self) -> bool {
940        !matches!(self, ObjectClass::Live | ObjectClass::Settings)
941    }
942}
943
944/// What [`cmd::STATUS`] reports, for whichever [`ObjectClass`] the session opened.
945///
946/// **The unit differs by class family.** The slot-addressed classes (program, set list,
947/// live, settings) count **bytes**: a program costs 141 = 121 body + 16 name + 4 CRC, a
948/// set list 38 = 18 + 16 + 4. The library classes (piano, sample) count **storage
949/// blocks** of [`Partition::allocation_unit`] net payload bytes each — just under 256
950/// KiB for pianos and 128 KiB for samples on an Electro 5.
951///
952/// ⚠️ **`free + used` is not the capacity.** A delete moves its space into
953/// [`Self::dirty`], not into `free`, so a report built from those two words shrinks
954/// every time something is deleted. [`Self::total`] sums all four storage words, which
955/// *is* constant, and [`Self::available`] is the space a write can actually reach —
956/// `free` now, plus whatever the cleaning pass can reclaim out of `dirty`.
957///
958/// `dirty` and `spare` read `0` outside the library partitions.
959///
960/// Confirmed on hardware.
961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
962pub struct Status {
963    pub class: ObjectClass,
964    pub count: u32,
965    /// Space a write may use immediately, without a cleaning pass.
966    pub free: u32,
967    /// Space live objects occupy. Deleting lowers this and raises `dirty`.
968    pub used: u32,
969    /// Space held by deleted objects, reclaimable by [`cmd::WRITE_PREPARE`]. Survives a
970    /// power cycle, where `free`'s prepared state does not reliably.
971    pub dirty: u32,
972    /// The fifth word: a small per-partition constant (1 and 2 observed), never seen to
973    /// move. Meaning unknown, and counted in [`Self::total`] only because the four
974    /// storage words together sum to a value that does not change.
975    pub spare: u32,
976}
977
978impl Status {
979    /// The partition's capacity — constant per class, in the class's own unit.
980    pub fn total(&self) -> u64 {
981        u64::from(self.free) + u64::from(self.used) + u64::from(self.dirty) + u64::from(self.spare)
982    }
983
984    /// Space a write can reach: what is free now plus what cleaning can reclaim.
985    ///
986    /// A partition reporting `free` 0 with a large `dirty` pool is entirely writable —
987    /// [`crate::op::write`] reclaims the shortfall before it begins.
988    pub fn available(&self) -> u64 {
989        u64::from(self.free) + u64::from(self.dirty)
990    }
991
992    /// Bytes per item, when every item of this class costs the same.
993    ///
994    /// Only the slot-addressed classes resolve: their `STATUS` unit is bytes and every
995    /// item is one fixed record. The library classes count blocks of genuinely
996    /// variable-size content, and a class this crate cannot name has no known unit, so
997    /// both yield `None` whatever their counters happen to divide into.
998    pub fn bytes_per_item(&self) -> Option<u32> {
999        if self.class.is_library() || matches!(self.class, ObjectClass::Unknown(_)) {
1000            return None;
1001        }
1002        if self.count == 0 || self.used == 0 || !self.used.is_multiple_of(self.count) {
1003            return None;
1004        }
1005        let per = self.used / self.count;
1006        // Only trust it if the class capacity is also a whole number of items;
1007        // otherwise the division is a coincidence.
1008        (per != 0 && self.total().is_multiple_of(u64::from(per))).then_some(per)
1009    }
1010
1011    /// Total item slots, for classes where items are fixed-size.
1012    ///
1013    /// Far more meaningful than a byte count: programs report 400, which is exactly the
1014    /// 8 banks × 50 slots of an Electro 5.
1015    pub fn slots(&self) -> Option<u32> {
1016        self.bytes_per_item()
1017            .and_then(|per| u32::try_from(self.total() / u64::from(per)).ok())
1018    }
1019
1020    pub fn used_percent(&self) -> f32 {
1021        let total = self.total();
1022        if total == 0 {
1023            0.0
1024        } else {
1025            100.0 * self.used as f32 / total as f32
1026        }
1027    }
1028
1029    /// Decode a [`cmd::STATUS`] response: `count, free, used, dirty, spare`.
1030    ///
1031    /// The five-word shape is what an Electro 5 answers. Confirmed on hardware. Only the
1032    /// first three words are required; a shorter reply decodes with the missing words as
1033    /// zero.
1034    pub fn decode(class: ObjectClass, msg: &Message) -> Result<Self> {
1035        // Request decoding would leave the status position and shift every counter.
1036        if !msg.is_response() {
1037            return Err(Error::InvalidArgument(
1038                "status must be decoded from a response (use Message::decode_response)".into(),
1039            ));
1040        }
1041        let p = msg.payload();
1042        if p.len() < 12 {
1043            return Err(Error::Truncated {
1044                got: p.len(),
1045                need: 12,
1046            });
1047        }
1048        if !p.len().is_multiple_of(4) {
1049            return Err(Error::Truncated {
1050                got: p.len(),
1051                need: (p.len() / 4 + 1) * 4,
1052            });
1053        }
1054        let word = |i: usize| u32::from_be_bytes(p[i * 4..i * 4 + 4].try_into().unwrap());
1055        Ok(Self {
1056            class,
1057            count: word(0),
1058            free: word(1),
1059            used: word(2),
1060            dirty: if p.len() >= 16 { word(3) } else { 0 },
1061            spare: if p.len() >= 20 { word(4) } else { 0 },
1062        })
1063    }
1064}
1065
1066/// A bank/slot address. **Zero-indexed on the wire**, one-indexed in the UI and in
1067/// every capture directory name — `move_prog_8-13_to_7-16` puts `7, 12, 6, 15` on
1068/// the wire.
1069#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1070pub struct Location {
1071    pub bank: u32,
1072    pub slot: u32,
1073}
1074
1075impl Location {
1076    /// From the one-indexed numbering used by the UI and capture names.
1077    ///
1078    /// Panics if either number is zero.
1079    pub fn from_user(bank: u32, slot: u32) -> Self {
1080        assert!(
1081            bank >= 1 && slot >= 1,
1082            "from_user takes the panel's one-indexed numbering; got {bank}:{slot}"
1083        );
1084        Self {
1085            bank: bank - 1,
1086            slot: slot - 1,
1087        }
1088    }
1089
1090    /// The one-indexed bank number shown by the instrument.
1091    pub fn user_bank(self) -> u64 {
1092        u64::from(self.bank) + 1
1093    }
1094
1095    /// The one-indexed slot number shown by the instrument.
1096    pub fn user_slot(self) -> u64 {
1097        u64::from(self.slot) + 1
1098    }
1099
1100    pub fn write_to(&self, out: &mut Vec<u8>) {
1101        out.extend_from_slice(&self.bank.to_be_bytes());
1102        out.extend_from_slice(&self.slot.to_be_bytes());
1103    }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    /// A library reference in a decoded body and a session on the wire name the same
1109    /// catalogue by the same code, in both directions — and the acceptance table names
1110    /// it by the same storage class.
1111    #[test]
1112    fn a_library_class_code_is_the_librarys_own() {
1113        use super::ObjectClass;
1114        use nord_format::accept::Slot;
1115        use nord_format::fields::Library;
1116        for library in [
1117            Library::Piano,
1118            Library::Sample,
1119            Library::Program,
1120            Library::SetList,
1121        ] {
1122            let class = ObjectClass::from(library);
1123            assert_eq!(class.to_raw(), u32::from(library.code()), "{library:?}");
1124            assert_eq!(ObjectClass::from_raw(library.code().into()), class);
1125            assert_eq!(class.storage(), Some(Slot::from(library)), "{library:?}");
1126        }
1127        assert_eq!(ObjectClass::from_raw(6), ObjectClass::Live);
1128        assert_eq!(ObjectClass::from_raw(0), ObjectClass::Unknown(0));
1129        assert_eq!(ObjectClass::Live.storage(), Some(Slot::Live));
1130        assert_eq!(ObjectClass::Settings.storage(), Some(Slot::Settings));
1131        assert_eq!(
1132            ObjectClass::Unknown(9).storage(),
1133            None,
1134            "no table row can be about a class this crate does not name"
1135        );
1136    }
1137
1138    use super::*;
1139
1140    /// The middle exchange of `move_prog_8-13_to_7-16`, byte-for-byte off the wire.
1141    const MOVE: &str = "000000220000000c0000000a00000018000000070000000c000000060000000f4a55";
1142    /// Its response: command +1, status word inserted, arguments echoed.
1143    const MOVE_RESP: &str =
1144        "000000260000000c0000000a0000001900000000000000070000000c000000060000000f7197";
1145
1146    fn hex(s: &str) -> Vec<u8> {
1147        (0..s.len())
1148            .step_by(2)
1149            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
1150            .collect()
1151    }
1152
1153    /// A response frame carrying `payload` behind a success status, so a decoder can be
1154    /// given bytes no device would send.
1155    fn response(command: u32, payload: &[u8]) -> Message {
1156        let mut args = 0u32.to_be_bytes().to_vec();
1157        args.extend_from_slice(payload);
1158        let bytes = Message::new(Service::Program, 10, command + 1, args).encode();
1159        Message::decode_response(&bytes).expect("a well-formed response frame")
1160    }
1161
1162    /// A `[u32 length][bytes]` record, as every name on the wire is written.
1163    fn length_prefixed(len: u32, bytes: &[u8]) -> Vec<u8> {
1164        let mut out = len.to_be_bytes().to_vec();
1165        out.extend_from_slice(bytes);
1166        out
1167    }
1168
1169    #[test]
1170    fn only_the_buffer_classes_overwrite_in_place_and_hold_no_name() {
1171        for class in [ObjectClass::Live, ObjectClass::Settings] {
1172            assert!(class.overwrites_in_place(), "{}", class.label());
1173            assert!(!class.names_its_slots(), "{}", class.label());
1174        }
1175        let storage = [
1176            ObjectClass::Piano,
1177            ObjectClass::Sample,
1178            ObjectClass::Program,
1179            ObjectClass::SetList,
1180            ObjectClass::Unknown(9),
1181        ];
1182        for class in storage {
1183            assert!(!class.overwrites_in_place(), "{}", class.label());
1184            assert!(class.names_its_slots(), "{}", class.label());
1185        }
1186    }
1187
1188    #[test]
1189    fn decodes_a_real_move() {
1190        let m = Message::decode(&hex(MOVE)).unwrap();
1191        assert_eq!(m.service, Service::Program);
1192        assert_eq!(m.subsystem, 10);
1193        assert_eq!(m.command, cmd::MOVE);
1194        assert!(!m.is_response());
1195
1196        // 8-13 -> 7-16, zero-indexed on the wire.
1197        let mut want = Vec::new();
1198        Location::from_user(8, 13).write_to(&mut want);
1199        Location::from_user(7, 16).write_to(&mut want);
1200        assert_eq!(m.payload(), want.as_slice());
1201    }
1202
1203    #[test]
1204    fn response_is_request_plus_one_plus_status() {
1205        let req = Message::decode(&hex(MOVE)).unwrap();
1206        let resp = Message::decode_response(&hex(MOVE_RESP)).unwrap();
1207
1208        assert_eq!(resp.command, req.command + 1);
1209        assert!(resp.is_response());
1210        assert_eq!(resp.status(), Some(0));
1211        // Once the status word is stripped, the arguments are identical...
1212        assert_eq!(resp.payload(), req.payload());
1213        // ...which is exactly why responses run 4 bytes longer.
1214        assert_eq!(hex(MOVE_RESP).len() - hex(MOVE).len(), 4);
1215    }
1216
1217    /// Direction cannot be inferred from the command code.
1218    ///
1219    /// "Select in instrument" is `0x2f` -> `0x30`: an **odd** request with an **even**
1220    /// response, inverting the parity guess that held for every other decoded op. Both
1221    /// messages are real, from `select_setlist_1-2` (set lists) and
1222    /// `open_on_device_2-12` (programs) -- the same command at two object classes.
1223    #[test]
1224    fn direction_is_not_inferable_from_command_parity() {
1225        // Request: cmd 0x2f, args (0, 1) -- displayed set list 1:2.
1226        let req =
1227            Message::decode(&hex("0000001a0000000c0000000a0000002f00000000000000017f71")).unwrap();
1228        assert_eq!(req.command, 0x2f);
1229        assert!(req.command & 1 == 1, "this request really is odd-numbered");
1230        assert!(
1231            !req.is_response(),
1232            "an odd command must still decode as a request"
1233        );
1234        assert_eq!(req.status(), None);
1235        // A request's payload must not have four bytes eaten as a status word.
1236        assert_eq!(req.payload().len(), 8);
1237
1238        // Response: cmd 0x30 (even), status 0, then the echoed args.
1239        let resp = Message::decode_response(&hex(
1240            "0000001e0000000c0000000a0000003000000000000000000000000112c4",
1241        ))
1242        .unwrap();
1243        assert_eq!(resp.command, req.command + 1);
1244        assert!(
1245            resp.command & 1 == 0,
1246            "this response really is even-numbered"
1247        );
1248        assert!(resp.is_response());
1249        assert_eq!(
1250            resp.status(),
1251            Some(0),
1252            "status must be readable despite even command"
1253        );
1254        assert_eq!(
1255            resp.payload(),
1256            req.payload(),
1257            "args line up once status is stripped"
1258        );
1259    }
1260
1261    #[test]
1262    fn round_trips_byte_exact() {
1263        for raw in [MOVE, MOVE_RESP] {
1264            let bytes = hex(raw);
1265            assert_eq!(Message::decode(&bytes).unwrap().encode(), bytes);
1266        }
1267    }
1268
1269    #[test]
1270    fn rejects_a_corrupted_crc() {
1271        let mut bytes = hex(MOVE);
1272        *bytes.last_mut().unwrap() ^= 0xFF;
1273        assert!(matches!(Message::decode(&bytes), Err(Error::BadCrc { .. })));
1274    }
1275
1276    #[test]
1277    fn a_response_without_a_status_word_is_truncated() {
1278        let bytes = Message::new(Service::Program, 10, cmd::STATUS + 1, Vec::new()).encode();
1279        assert!(matches!(
1280            Message::decode_response(&bytes),
1281            Err(Error::Truncated { need: 22, .. })
1282        ));
1283    }
1284
1285    #[test]
1286    fn changed_is_a_statusless_notification() {
1287        let bytes = Message::new(Service::Program, 10, cmd::CHANGED, vec![1, 2, 3, 4]).encode();
1288        let message = Message::decode_response(&bytes).unwrap();
1289        assert_eq!(message.status(), None);
1290        assert_eq!(message.payload(), [1, 2, 3, 4]);
1291    }
1292
1293    #[test]
1294    fn crc_matches_known_messages() {
1295        // Session open/close and the UI hello, straight from the corpus.
1296        for raw in [
1297            "0000001200000006000000010000000006a1",
1298            "000000160000000c0000000a0000000400000004a218",
1299            "000000120000000c0000000a000000066500",
1300        ] {
1301            assert!(Message::decode(&hex(raw)).is_ok(), "{raw}");
1302        }
1303    }
1304
1305    /// The progress strings encode byte-for-byte to what NSM put on the wire — the
1306    /// "Deleting..." label from `delete_prog_bank7_loc50` and the 100% bar from the
1307    /// program read.
1308    #[test]
1309    fn ui_label_and_percent_match_the_wire() {
1310        assert_eq!(
1311            super::ui::label("Deleting...").unwrap().encode(),
1312            hex("000000240000000600000001000000060000000000000b44656c6574696e672e2e2e7394"),
1313        );
1314        assert_eq!(
1315            super::ui::percent(100).encode(),
1316            hex("0000001600000006000000010000000700010064927b"),
1317        );
1318    }
1319
1320    /// Object info uses its declared name length and treats `0xffffffff` as no checksum.
1321    #[test]
1322    fn object_info_decodes_every_format() {
1323        let cases: &[(&str, &str, u32, Option<u32>, &str)] = &[
1324            ("000000450000000c0000000a0000001f00000000000000050000000c000000796e65357000000004ffffffffffffffff00000003666f6f000000000000000021ab3d01a1ee",
1325             "ne5p", 4, Some(0x21ab_3d01), "foo"),
1326            ("000000460000000c0000000a0000001f000000000000000000000007000000126e65357400000001ffffffffffffffff00000004746573740000000000000000dce9a145bf84",
1327             "ne5t", 1, Some(0xdce9_a145), "test"),
1328            ("0000005c0000000c0000000a0000001f0000000000000000000000000c7db5446e706e6f0000021c5e98c95affffffff0000001a526f79616c204772616e64203344205961533620584c20352e340000000500000000ffffffffc30b",
1329             "npno", 540, None, "Royal Grand 3D YaS6 XL 5.4"),
1330            ("000000610000000c0000000a0000001f0000000000000000000000000011da986e736d70000000c8554100ec000800000000001f41636f7573746963205069616e6f20335f5f4b6f7267206d6f6e6f20322e300000000000000000ffffffff366f",
1331             "nsmp", 200, None, "Acoustic Piano 3__Korg mono 2.0"),
1332        ];
1333        for (raw, format, version, crc32, name) in cases {
1334            let info = ProgramInfo::decode(&Message::decode_response(&hex(raw)).unwrap()).unwrap();
1335            assert_eq!(&info.format, format);
1336            assert_eq!(info.version, *version, "{format}");
1337            assert_eq!(info.crc32, *crc32, "{format}");
1338            assert_eq!(&info.name, name);
1339        }
1340    }
1341
1342    /// A 54-character sample name, straight off the wire.
1343    #[test]
1344    fn object_info_reads_a_54_character_name() {
1345        let info = ProgramInfo::decode(
1346            &Message::decode_response(&hex(
1347                "000000780000000c0000000a0000001f00000000000000000000004b002700f66e736d70000000c8554777330009000200000036332056696f6c696e7320534d5f4368616d6265726c696e5f4d4d6173746572206d6f6e6f20736d616c6c2076657273696f6e20322e300000000000000000ffffffff062d",
1348            ))
1349            .unwrap(),
1350        )
1351        .unwrap();
1352        assert_eq!(
1353            info.name,
1354            "3 Violins SM_Chamberlin_MMaster mono small version 2.0"
1355        );
1356        assert_eq!(info.name.len(), 54);
1357    }
1358
1359    /// A label too long for the one-byte length field is refused, not truncated. The
1360    /// failure it prevents is silent: `256 as u8` is 0, so the frame would claim an
1361    /// empty string and carry 256 bytes of payload.
1362    #[test]
1363    fn over_long_labels_are_refused_not_truncated() {
1364        assert!(super::ui::label(&"x".repeat(super::ui::MAX_LABEL_LEN)).is_ok());
1365        assert!(super::ui::label(&"x".repeat(super::ui::MAX_LABEL_LEN + 1)).is_err());
1366    }
1367
1368    /// Percent clamps rather than erroring — no `u16` can produce a malformed frame.
1369    #[test]
1370    fn percent_clamps_to_100() {
1371        assert_eq!(
1372            super::ui::percent(101).encode(),
1373            super::ui::percent(100).encode()
1374        );
1375        assert_eq!(
1376            super::ui::percent(u16::MAX).encode(),
1377            super::ui::percent(100).encode()
1378        );
1379    }
1380
1381    /// Decoding a dependency list from a *request*-decoded message would shift every
1382    /// offset by the four-byte status word. That must be an error, not a misparse.
1383    #[test]
1384    fn dependencies_require_a_response() {
1385        let raw = hex(
1386            "000000820000000c0000000a0000002900000000000000060000000200000002000000000000000001d303b5f20000001a526f79616c204772616e64203344205961533620584c20352e3400000000ffffffffffffffff010000000000000003f2f5cadc0000000c6166726963615f73706c697400000000ffffffffffffffffc791",
1387        );
1388        assert!(Dependency::decode_all(&Message::decode(&raw).unwrap()).is_err());
1389        assert!(Dependency::decode_all(&Message::decode_response(&raw).unwrap()).is_ok());
1390    }
1391
1392    /// Decode the dependency list a real duplicate read back: a piano and a sample,
1393    /// each with the content id that also appears in the file header.
1394    #[test]
1395    fn decodes_real_dependencies() {
1396        let resp = Message::decode_response(&hex(
1397            "000000820000000c0000000a0000002900000000000000060000000200000002000000000000000001d303b5f20000001a526f79616c204772616e64203344205961533620584c20352e3400000000ffffffffffffffff010000000000000003f2f5cadc0000000c6166726963615f73706c697400000000ffffffffffffffffc791",
1398        ))
1399        .unwrap();
1400        let deps = Dependency::decode_all(&resp).unwrap();
1401        assert_eq!(deps.len(), 2);
1402
1403        assert_eq!(deps[0].class, ObjectClass::Piano);
1404        assert_eq!(deps[0].id, 0xd303_b5f2);
1405        assert_eq!(deps[0].name, "Royal Grand 3D YaS6 XL 5.4");
1406        assert_eq!(deps[0].location, None);
1407
1408        assert_eq!(deps[1].class, ObjectClass::Sample);
1409        assert_eq!(deps[1].id, 0xf2f5_cadc);
1410        assert_eq!(deps[1].name, "africa_split");
1411        assert_eq!(deps[1].location, None);
1412
1413        // The piano row reads flag 0 — reported, but its section is not routed.
1414        assert!(!deps[0].is_required());
1415        assert!(deps[1].is_required());
1416    }
1417
1418    /// A live flag addressing nothing — routed section, nothing assigned — is the one
1419    /// row shape [`Dependency::is_required`] must reject that liveness alone accepts.
1420    #[test]
1421    fn a_live_row_addressing_nothing_is_not_required() {
1422        let d = Dependency {
1423            flag: 1,
1424            class: ObjectClass::Piano,
1425            id: 0,
1426            name: String::new(),
1427            location: None,
1428        };
1429        assert!(!d.is_required());
1430    }
1431
1432    /// A set list's dependencies are programs: slot-addressed, [`Dependency::id`]
1433    /// always `0`, the address in the location words. Confirmed on hardware. A real
1434    /// set list read back four such rows, all live. A required-filter keyed on id
1435    /// alone classifies every one as "routed but nothing assigned".
1436    ///
1437    /// The frame is constructed to the confirmed shape — echoed bank/slot, count,
1438    /// then four 29-byte id-0 rows (empty name) with locations — not a byte capture.
1439    #[test]
1440    fn set_list_dependencies_are_required_by_location_not_id() {
1441        let mut args = Vec::new();
1442        // Status, then the echoed set-list address (panel 1:43, 0-indexed on the
1443        // wire) and row count.
1444        for w in [0u32, 0, 42, 4] {
1445            args.extend_from_slice(&w.to_be_bytes());
1446        }
1447        // Slots A–D held panel 1:7, 1:3, 1:39, 1:41.
1448        for slot in [6u32, 2, 38, 40] {
1449            args.push(1); // flag: the slot is live
1450                          // missing, class (program), id, name_len, has_location, bank, slot.
1451            for w in [0u32, 4, 0, 0, 1, 0, slot] {
1452                args.extend_from_slice(&w.to_be_bytes());
1453            }
1454        }
1455        assert_eq!(args.len() - 4, 128);
1456
1457        let raw = Message::new(Service::Program, 10, cmd::DEPENDENCIES + 1, args).encode();
1458        let deps = Dependency::decode_all(&Message::decode_response(&raw).unwrap()).unwrap();
1459
1460        assert_eq!(deps.len(), 4);
1461        let slots: Vec<u32> = deps.iter().map(|d| d.location.unwrap().slot).collect();
1462        assert_eq!(slots, [6, 2, 38, 40]);
1463        for d in &deps {
1464            assert_eq!(d.class, ObjectClass::Program);
1465            assert_eq!(d.id, 0);
1466            assert!(d.name.is_empty());
1467            assert!(d.is_required(), "a slot-addressed dependency is required");
1468        }
1469    }
1470
1471    /// Every count, length and record in a partition table is the device's word, and a
1472    /// reply that outruns its own payload must name how far past the end it reached.
1473    #[test]
1474    fn a_partition_table_overrunning_its_payload_is_truncated() {
1475        let record = [length_prefixed(3, b"abc"), vec![0; PARTITION_FIELDS]].concat();
1476
1477        let mut claims_two = vec![2u8];
1478        claims_two.extend_from_slice(&record);
1479        assert!(
1480            matches!(
1481                Partition::decode_all(&response(cmd::PARTITIONS, &claims_two)),
1482                Err(Error::Truncated { got: 37, need: 41 })
1483            ),
1484            "a count larger than the payload holds"
1485        );
1486
1487        let mut long_name = vec![1u8];
1488        long_name.extend_from_slice(&length_prefixed(16, b"ab"));
1489        assert!(
1490            matches!(
1491                Partition::decode_all(&response(cmd::PARTITIONS, &long_name)),
1492                Err(Error::Truncated { got: 7, need: 21 })
1493            ),
1494            "a name length past the end of the payload"
1495        );
1496
1497        let mut short_fields = vec![1u8];
1498        short_fields.extend_from_slice(&length_prefixed(3, b"abc"));
1499        short_fields.extend_from_slice(&[0; 10]);
1500        assert!(
1501            matches!(
1502                Partition::decode_all(&response(cmd::PARTITIONS, &short_fields)),
1503                Err(Error::Truncated { got: 18, need: 37 })
1504            ),
1505            "a record cut inside its trailing fields"
1506        );
1507    }
1508
1509    /// The bank table is what bounds every walk, so a short one must fail rather than
1510    /// report fewer banks than the device has.
1511    #[test]
1512    fn a_bank_table_overrunning_its_payload_is_truncated() {
1513        let echo = 4u32.to_be_bytes();
1514        let record = [length_prefixed(3, b"abc"), 50u32.to_be_bytes().to_vec()].concat();
1515
1516        let claims_two = [&echo[..], &[2u8][..], &record[..]].concat();
1517        assert!(
1518            matches!(
1519                Bank::decode_all(&response(cmd::BANKS, &claims_two)),
1520                Err(Error::Truncated { got: 16, need: 20 })
1521            ),
1522            "a count larger than the payload holds"
1523        );
1524
1525        let long_name = [&echo[..], &[1u8][..], &length_prefixed(32, b"ab")[..]].concat();
1526        assert!(
1527            matches!(
1528                Bank::decode_all(&response(cmd::BANKS, &long_name)),
1529                Err(Error::Truncated { got: 11, need: 41 })
1530            ),
1531            "a name length past the end of the payload"
1532        );
1533
1534        let short_capacity = [
1535            &echo[..],
1536            &[1u8][..],
1537            &length_prefixed(3, b"abc")[..],
1538            &[0, 0][..],
1539        ]
1540        .concat();
1541        assert!(
1542            matches!(
1543                Bank::decode_all(&response(cmd::BANKS, &short_capacity)),
1544                Err(Error::Truncated { got: 14, need: 16 })
1545            ),
1546            "a record cut inside its slot count"
1547        );
1548    }
1549
1550    /// A dependency list decides what a bundle walk collects, so a row that does not fit
1551    /// its payload must be an error rather than a shorter list.
1552    #[test]
1553    fn a_dependency_list_overrunning_its_payload_is_truncated() {
1554        // Echoed bank and slot, then the row count.
1555        let header = |count: u32| [0u32.to_be_bytes(), 0u32.to_be_bytes(), count.to_be_bytes()];
1556        // flag, reserved, class, id, name_len, has_location, bank, slot — no padding.
1557        let row = |name_len: u32| {
1558            let mut row = vec![1u8];
1559            for word in [0u32, 4, 0, name_len, 0, 0, 0] {
1560                row.extend_from_slice(&word.to_be_bytes());
1561            }
1562            row
1563        };
1564
1565        assert!(
1566            matches!(
1567                Dependency::decode_all(&response(cmd::DEPENDENCIES, &[0; 11])),
1568                Err(Error::Truncated { got: 11, need: 12 })
1569            ),
1570            "a reply too short to carry its own count"
1571        );
1572
1573        let claims_two = [&header(2).concat()[..], &row(0)[..]].concat();
1574        assert!(
1575            matches!(
1576                Dependency::decode_all(&response(cmd::DEPENDENCIES, &claims_two)),
1577                Err(Error::Truncated { got: 41, need: 58 })
1578            ),
1579            "a count larger than the payload holds"
1580        );
1581
1582        let long_name = [&header(1).concat()[..], &row(256)[..17]].concat();
1583        assert!(
1584            matches!(
1585                Dependency::decode_all(&response(cmd::DEPENDENCIES, &long_name)),
1586                Err(Error::Truncated { got: 29, need: 285 })
1587            ),
1588            "a name length past the end of the payload"
1589        );
1590
1591        let cut_location = [&header(1).concat()[..], &row(0)[..21]].concat();
1592        assert!(
1593            matches!(
1594                Dependency::decode_all(&response(cmd::DEPENDENCIES, &cut_location)),
1595                Err(Error::Truncated { got: 33, need: 41 })
1596            ),
1597            "a row cut inside its trailing location words"
1598        );
1599    }
1600
1601    /// Object info is read before every transfer, so a reply that does not reach its own
1602    /// name must fail rather than decode a shorter one.
1603    #[test]
1604    fn object_info_shorter_than_its_fields_is_truncated() {
1605        assert!(
1606            matches!(
1607                ProgramInfo::decode(&response(cmd::INFO, &[0; 31])),
1608                Err(Error::Truncated { got: 31, need: 32 })
1609            ),
1610            "a reply stopping inside the fixed fields"
1611        );
1612
1613        let mut claims_a_name = vec![0u8; ProgramInfo::NAME_LEN_AT];
1614        claims_a_name.extend_from_slice(&5u32.to_be_bytes());
1615        assert!(
1616            matches!(
1617                ProgramInfo::decode(&response(cmd::INFO, &claims_a_name)),
1618                Err(Error::Truncated { got: 32, need: 37 })
1619            ),
1620            "a name length past the end of the payload"
1621        );
1622    }
1623
1624    /// The frame's own length word and the bytes that arrived must agree, or the reader
1625    /// is looking at part of one message and the start of another.
1626    #[test]
1627    fn a_frame_that_contradicts_its_length_word_is_refused() {
1628        let mut bytes = hex(MOVE);
1629        let declared = bytes.len() + 4;
1630        bytes[..4].copy_from_slice(&(declared as u32).to_be_bytes());
1631        assert!(
1632            matches!(
1633                Message::decode(&bytes),
1634                Err(Error::LengthMismatch {
1635                    declared: 38,
1636                    actual: 34
1637                })
1638            ),
1639            "a length word longer than the frame"
1640        );
1641
1642        assert!(
1643            matches!(
1644                Message::decode(&[0; 17]),
1645                Err(Error::Truncated { got: 17, need: 18 })
1646            ),
1647            "a frame with no room for a header and a CRC"
1648        );
1649    }
1650}