use std::io::Cursor;
use std::ops::Range;
use nord_format::fields::Field;
use nord_format::{Entity, Settings, Song};
pub fn fields_of(entity: &Entity) -> Option<Vec<Field>> {
entity.registry().map(|body| body.fields())
}
pub fn has_registry(entity: &Entity) -> bool {
entity.registry().is_some()
}
pub fn is_electro5_settings(entity: &Entity) -> bool {
matches!(entity, Entity::Settings(Settings::Electro5(_)))
}
pub fn is_set_list(entity: &Entity) -> bool {
matches!(entity, Entity::Song(Song::Electro5(_)))
}
pub fn apply(bytes: &[u8], sets: &[(String, String)]) -> Result<(Vec<Field>, Vec<u8>), String> {
let mut entity =
nord_format::from_stream(&mut Cursor::new(bytes)).map_err(|e| e.to_string())?;
{
let body = entity
.registry_mut()
.ok_or("this entity has no field registry")?;
for (path, value) in sets {
body.set_field(path, value).map_err(|e| e.to_string())?;
}
}
let fields = entity
.registry()
.ok_or("this entity has no field registry")?
.fields();
let out = nord_format::to_bytes(&entity).map_err(|e| e.to_string())?;
Ok((fields, out))
}
pub fn decoded(bytes: &[u8]) -> Option<Vec<Field>> {
let entity = nord_format::from_stream(&mut Cursor::new(bytes)).ok()?;
fields_of(&entity)
}
pub fn changed(saved: &[u8], current: &[u8]) -> Vec<String> {
let (Some(before), Some(after)) = (decoded(saved), decoded(current)) else {
return Vec::new();
};
before
.iter()
.zip(&after)
.filter(|(before, after)| before.path == after.path && before.value != after.value)
.map(|(_, after)| after.path.clone())
.collect()
}
pub struct DiffRow {
pub at: usize,
pub before: u8,
pub after: u8,
pub note: &'static str,
}
fn checksum_bytes(file: &[u8]) -> Option<(Range<usize>, &'static str)> {
if file.len() < 8 || &file[0..4] != nord_format::cbin::MAGIC {
return None;
}
match u32::from_le_bytes(file[4..8].try_into().ok()?) {
0 => Some((file.len() - 2..file.len(), " (file crc16)")),
1 => Some((0x18..0x1c, " (body crc32)")),
_ => None,
}
}
pub fn byte_diff(before: &[u8], after: &[u8]) -> Vec<DiffRow> {
if before.len() != after.len() {
return Vec::new();
}
let checksum = checksum_bytes(after);
before
.iter()
.zip(after)
.enumerate()
.filter(|(_, (b, a))| b != a)
.map(|(at, (&b, &a))| DiffRow {
at,
before: b,
after: a,
note: match &checksum {
Some((range, label)) if range.contains(&at) => label,
_ => "",
},
})
.collect()
}
#[cfg(test)]
pub mod blank {
use nord_format::cbin::{Cbin, Header, RawBody};
use nord_format::formats::ns3;
use nord_format::{Entity, Song};
pub fn stage3_song() -> Vec<u8> {
let file = Cbin {
header: Header::new(ns3::song::FORMAT, (0, 0), 0),
body: RawBody(vec![0u8; ns3::song::BODY_LEN]),
};
nord_format::to_bytes(&Entity::Song(Song::Stage3(file))).expect("a stub encodes")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::drawbar_widget;
use crate::workspace::Fresh;
use nord_format::formats::ne5;
use nord_format::Program;
fn program() -> Vec<u8> {
let entity = Entity::Program(Program::Electro5(ne5::program::new(
(0, 0).try_into().unwrap(),
)));
nord_format::to_bytes(&entity).unwrap()
}
#[test]
fn a_set_changes_the_field_it_names_and_nothing_else() {
let bytes = program();
let (before, _) = apply(&bytes, &[]).unwrap();
let (after, edited) = apply(&bytes, &[("center_panel.gain".into(), "96".into())]).unwrap();
let moved: Vec<&str> = before
.iter()
.zip(&after)
.filter(|(b, a)| b.display != a.display)
.map(|(_, a)| a.path.as_str())
.collect();
assert_eq!(moved, ["center_panel.gain"]);
assert_eq!(edited.len(), bytes.len());
}
#[test]
fn an_out_of_range_value_is_refused_by_the_library() {
let err = apply(&program(), &[("center_panel.gain".into(), "200".into())])
.err()
.expect("200 is not a gain");
assert!(err.contains("not a value of gain"), "{err}");
assert!(err.contains("0 .. 127"), "{err}");
}
#[test]
fn a_refusal_anywhere_in_a_batch_applies_none_of_it() {
let bytes = program();
let sets = [
("center_panel.transpose_enabled".into(), "true".into()),
("center_panel.transpose".into(), "99".into()),
];
assert!(apply(&bytes, &sets).is_err());
let (fields, _) = apply(&bytes, &[]).unwrap();
let enabled = fields
.iter()
.find(|f| f.path == "center_panel.transpose_enabled")
.unwrap();
assert_eq!(enabled.value, "false");
}
#[test]
fn the_checksum_bytes_are_annotated_as_bookkeeping() {
let bytes = program();
let (_, edited) = apply(&bytes, &[("center_panel.gain".into(), "96".into())]).unwrap();
let diff = byte_diff(&bytes, &edited);
assert!(!diff.is_empty());
let annotated: Vec<usize> = diff
.iter()
.filter(|row| row.note.contains("crc32"))
.map(|row| row.at)
.collect();
assert!(annotated.iter().all(|at| (0x18..0x1c).contains(at)));
assert!(!annotated.is_empty(), "the crc32 must have moved");
assert!(
diff.iter().any(|row| row.note.is_empty()),
"the edit itself must show as an unannotated byte",
);
}
#[test]
fn every_registry_backed_body_reads_and_writes() {
for bytes in [
Fresh::Stage2Program.bytes().unwrap(),
Fresh::Stage3Synth.bytes().unwrap(),
Fresh::Stage4Organ.bytes().unwrap(),
Fresh::Stage4Piano.bytes().unwrap(),
Fresh::Stage4Program.bytes().unwrap(),
Fresh::Stage4Synth.bytes().unwrap(),
] {
let (fields, out) = apply(&bytes, &[]).expect("a blank body round-trips");
assert!(!fields.is_empty());
assert_eq!(out, bytes, "an empty set changes nothing");
}
}
#[test]
fn changed_names_the_edited_paths_and_nothing_else() {
let bytes = program();
let (_, once) = apply(&bytes, &[("center_panel.gain".into(), "96".into())]).unwrap();
assert_eq!(changed(&bytes, &once), ["center_panel.gain"]);
let (_, twice) = apply(&once, &[("center_panel.split".into(), "true".into())]).unwrap();
assert_eq!(
changed(&bytes, &twice),
["center_panel.split", "center_panel.gain"],
"registry order, not the order the edits were made in",
);
assert!(changed(&bytes, &bytes).is_empty());
assert!(changed(b"not a nord file", &bytes).is_empty());
}
#[test]
fn a_register_round_trips_through_the_widgets_spelling() {
let bytes = program();
let (fields, _) = apply(&bytes, &[]).unwrap();
let register = fields
.iter()
.find(|f| f.path == "organ_panel.vox_preset1_drawbars")
.unwrap();
let bits = drawbar_widget::parse(®ister.value).unwrap();
let parked = drawbar_widget::written(bits, drawbar_widget::bars(bits))
.expect("every bar is where it was stored");
assert_eq!(drawbar_widget::spell(parked), register.value);
let (after, _) = apply(&bytes, &[(register.path.clone(), "0x888800000".into())]).unwrap();
let edited = after.iter().find(|f| f.path == register.path).unwrap();
assert_eq!(
drawbar_widget::bars(drawbar_widget::parse(&edited.value).unwrap()),
[8, 8, 8, 8, 0, 0, 0, 0, 0]
);
}
}