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}