use std::sync::mpsc::{Receiver, Sender};
use eframe::egui;
use nord_format::cbin::{Cbin, Generation, Header};
use nord_format::formats::{ne5, ns2, ns3, ns4, nsmpproj};
use nord_format::{Entity, Live, OrganPreset, PianoPreset, Program, Settings, Song, Synth};
use nord_usb::{Location, ObjectClass};
use crate::log::Log;
use crate::newproject::{Draft, Making};
use crate::queue::Queue;
#[derive(Clone)]
pub enum Origin {
File(String),
Device {
class: ObjectClass,
at: Location,
},
Fresh,
Rescued {
at: Location,
},
}
impl Origin {
pub fn label(&self) -> String {
match self {
Origin::File(name) => format!("Opened from {name}"),
Origin::Device { class, at } => {
format!("Copied from {}", crate::strings::place(*class, *at))
}
Origin::Fresh => "New, not saved anywhere yet".into(),
Origin::Rescued { at } => format!("Rescued from {}", crate::strings::shown(*at)),
}
}
pub fn slot(&self) -> Option<(ObjectClass, Location)> {
match self {
Origin::Device { class, at } => Some((*class, *at)),
_ => None,
}
}
}
#[derive(Clone)]
pub enum VerifyState {
Ok,
Differs {
at: usize,
},
Failed(String),
NotApplicable(&'static str),
}
impl VerifyState {
pub fn badge(&self) -> &'static str {
match self {
VerifyState::Ok => "ok",
VerifyState::Differs { .. } => "differs",
VerifyState::Failed(_) => "failed",
VerifyState::NotApplicable(_) => "n/a",
}
}
pub fn detail(&self) -> String {
match self {
VerifyState::Ok => "re-encoded byte-for-byte".into(),
VerifyState::Differs { at } => format!("first difference at byte {at:#06x}"),
VerifyState::Failed(why) => why.clone(),
VerifyState::NotApplicable(why) => (*why).to_string(),
}
}
pub fn color(&self, visuals: &egui::Visuals) -> egui::Color32 {
match self {
VerifyState::Ok => crate::app::good(visuals),
VerifyState::Differs { .. } | VerifyState::Failed(_) => crate::app::bad(visuals),
VerifyState::NotApplicable(_) => visuals.weak_text_color(),
}
}
}
#[derive(Clone)]
pub struct Container {
pub header: Header,
pub body: std::ops::Range<usize>,
pub checksum_ok: bool,
pub checksum_label: &'static str,
pub checksum: String,
pub body_crc32: u32,
}
impl Container {
fn read(bytes: &[u8]) -> Option<Container> {
let info = nord_format::cbin::inspect(&mut std::io::Cursor::new(bytes)).ok()?;
let start = usize::try_from(info.header.generation.body_start()).ok()?;
let end = start.checked_add(usize::try_from(info.body_len).ok()?)?;
let body = start..end;
let body_crc32 = nord_usb::envelope::crc32(bytes.get(body.clone())?);
let (checksum_label, checksum) = match info.header.generation {
Generation::V0 => {
let tail = bytes.get(bytes.len().checked_sub(2)?..)?;
let crc = u16::from_le_bytes(tail.try_into().ok()?);
("crc16:", format!("{crc:#06x}"))
}
Generation::V1 => {
let crc = u32::from_le_bytes(bytes.get(0x18..0x1c)?.try_into().ok()?);
("crc32:", format!("{crc:#010x}"))
}
};
Some(Container {
header: info.header,
body,
checksum_ok: info.checksum_ok,
checksum_label,
checksum,
body_crc32,
})
}
pub fn tag(&self) -> String {
String::from_utf8_lossy(&self.header.tag).into_owned()
}
pub fn body_len(&self) -> u64 {
self.body.len() as u64
}
}
#[derive(Clone, Default)]
pub struct Baseline {
pub bytes: Vec<u8>,
pub crc32: Option<u32>,
pub stamp: u64,
}
impl Baseline {
pub(crate) fn read(bytes: Vec<u8>, stamp: u64) -> Baseline {
let crc32 = Container::read(&bytes).map(|held| held.body_crc32);
Baseline {
bytes,
crc32,
stamp,
}
}
}
#[derive(Clone, Copy)]
pub struct Wrote {
pub class: ObjectClass,
pub at: Location,
pub crc32: u32,
}
pub struct LocalEntity {
pub id: u64,
pub name: String,
pub origin: Origin,
pub bytes: Vec<u8>,
pub entity: Option<Entity>,
pub parse_error: Option<String>,
pub container: Option<Container>,
pub verify: VerifyState,
pub saved: Baseline,
pub kept: bool,
pub stamp: u64,
pub link: Option<(ObjectClass, Location)>,
pub wrote: Option<Wrote>,
}
impl LocalEntity {
fn new(id: u64, name: String, origin: Origin, bytes: Vec<u8>, stamp: u64) -> LocalEntity {
let container = Container::read(&bytes);
let (entity, parse_error) =
match nord_format::from_stream(&mut std::io::Cursor::new(&bytes)) {
Ok(entity) => (Some(entity), None),
Err(e) => (None, Some(e.to_string())),
};
let verify = match &entity {
Some(entity) => verify(entity, &bytes),
None => VerifyState::NotApplicable("the file did not decode"),
};
let mut held = LocalEntity {
id,
name,
origin,
bytes,
entity,
parse_error,
container,
verify,
saved: Baseline::default(),
kept: true,
stamp,
link: None,
wrote: None,
};
held.saved = held.baseline();
held
}
pub fn is_unsaved(&self) -> bool {
self.stamp != self.saved.stamp
}
fn baseline(&self) -> Baseline {
Baseline {
bytes: self.bytes.clone(),
crc32: self.container.as_ref().map(|held| held.body_crc32),
stamp: self.stamp,
}
}
pub fn spot(&self) -> Option<(ObjectClass, Location)> {
self.link.or_else(|| self.origin.slot())
}
pub fn tag(&self) -> String {
match (&self.entity, &self.container) {
(Some(entity), _) => entity.identity().format.to_string(),
(None, Some(container)) => container.tag(),
(None, None) => "?".into(),
}
}
pub fn raw_body(&self) -> Option<Vec<u8>> {
nord_usb::envelope::unwrap(&self.bytes)
.ok()
.map(|read| read.body.0)
}
}
pub fn precious(entity: &LocalEntity, queue: &Queue) -> bool {
entity.is_unsaved() || queue.holds(entity.id)
}
fn export_filename(name: &str, bytes: &[u8]) -> String {
let stem = match filename_stem(name) {
s if s.is_empty() => "unnamed".to_string(),
s => s,
};
match crate::strings::carries_tag(&stem) {
true => stem,
false => format!("{stem}.{}", format_tag(bytes)),
}
}
pub fn zone_wav_name(instrument: &str, zone: usize) -> String {
let stem = match filename_stem(instrument) {
s if s.is_empty() => "unnamed".to_string(),
s => s,
};
format!("{stem}-zone{zone}.wav")
}
pub fn stroke_wav_name(library: &str, root: u8, bank: u8, layer: u8) -> String {
let stem = match filename_stem(library) {
s if s.is_empty() => "unnamed".to_string(),
s => s,
};
format!("{stem}-{root:03}-b{bank}-l{layer:02}.wav")
}
fn filename_stem(label: &str) -> String {
let mut owed = false;
let mut out = String::with_capacity(label.len());
for c in label.chars() {
match c {
'-' => owed = !out.is_empty(),
_ if c.is_whitespace() => owed = !out.is_empty(),
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => owed = !out.is_empty(),
_ if c.is_control() => {}
_ => {
if std::mem::take(&mut owed) {
out.push('-');
}
out.push(c);
}
}
}
out.trim_matches(['.', '-']).to_string()
}
fn format_tag(bytes: &[u8]) -> String {
if bytes.starts_with(nord_format::cbin::MAGIC) {
return bytes
.get(8..12)
.filter(|tag| tag.iter().all(|b| b.is_ascii_alphanumeric()))
.map(|tag| String::from_utf8_lossy(tag).into_owned())
.unwrap_or_else(|| "bin".to_string());
}
if bytes.starts_with(nsmpproj::MAGIC) {
return nsmpproj::FORMAT.to_string();
}
"bin".to_string()
}
fn verify(entity: &Entity, bytes: &[u8]) -> VerifyState {
if matches!(entity, Entity::Bundle(_)) {
return VerifyState::NotApplicable("a bundle is an archive; it does not re-encode");
}
let out = match nord_format::to_bytes(entity) {
Ok(out) => out,
Err(e) => return VerifyState::Failed(e.to_string()),
};
match out.iter().zip(bytes).position(|(a, b)| a != b) {
Some(at) => VerifyState::Differs { at },
None if out.len() == bytes.len() => VerifyState::Ok,
None => VerifyState::Differs {
at: out.len().min(bytes.len()),
},
}
}
pub struct Family {
pub label: &'static str,
pub kinds: &'static [Fresh],
}
macro_rules! zeroed {
($body:ty, $len:expr, $format:expr, $versions:expr, $wrap:expr) => {{
let body = <$body>::try_from([0u8; $len]).map_err(|e| format!("{e}"))?;
let version = *$versions.last().ok_or("the format knows no version")?;
$wrap(Cbin {
header: Header::new($format, (0, 0), version),
body,
})
}};
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Fresh {
Program,
Live,
SetList,
Settings,
Stage2Program,
Stage3Program,
Stage3Synth,
Stage4Program,
Stage4Organ,
Stage4Piano,
Stage4Synth,
}
impl Fresh {
pub const ALL: [Fresh; 11] = [
Fresh::Program,
Fresh::Live,
Fresh::SetList,
Fresh::Settings,
Fresh::Stage2Program,
Fresh::Stage3Program,
Fresh::Stage3Synth,
Fresh::Stage4Program,
Fresh::Stage4Organ,
Fresh::Stage4Piano,
Fresh::Stage4Synth,
];
pub const FAMILIES: [Family; 4] = [
Family {
label: "Electro 5",
kinds: &[Fresh::Program, Fresh::Live, Fresh::SetList, Fresh::Settings],
},
Family {
label: "Stage 2",
kinds: &[Fresh::Stage2Program],
},
Family {
label: "Stage 3",
kinds: &[Fresh::Stage3Program, Fresh::Stage3Synth],
},
Family {
label: "Stage 4",
kinds: &[
Fresh::Stage4Program,
Fresh::Stage4Organ,
Fresh::Stage4Piano,
Fresh::Stage4Synth,
],
},
];
pub fn label(self) -> &'static str {
match self {
Fresh::Program | Fresh::Stage2Program | Fresh::Stage3Program | Fresh::Stage4Program => {
"Program"
}
Fresh::Live => "Live slot",
Fresh::SetList => "Set list",
Fresh::Settings => "Settings",
Fresh::Stage3Synth | Fresh::Stage4Synth => "Synth preset",
Fresh::Stage4Organ => "Organ preset",
Fresh::Stage4Piano => "Piano preset",
}
}
pub fn tag(self) -> &'static str {
match self {
Fresh::Program => ne5::program::FORMAT,
Fresh::Live => ne5::live::FORMAT,
Fresh::SetList => ne5::song::FORMAT,
Fresh::Settings => ne5::settings::FORMAT,
Fresh::Stage2Program => ns2::program::FORMAT,
Fresh::Stage3Program => ns3::program::FORMAT,
Fresh::Stage3Synth => ns3::synth::FORMAT,
Fresh::Stage4Program => ns4::program::FORMAT,
Fresh::Stage4Organ => ns4::organ_preset::FORMAT,
Fresh::Stage4Piano => ns4::piano_preset::FORMAT,
Fresh::Stage4Synth => ns4::synth::FORMAT,
}
}
pub fn zeroed(self) -> bool {
!matches!(
self,
Fresh::Program | Fresh::Live | Fresh::SetList | Fresh::Settings
)
}
pub fn note(self) -> Option<&'static str> {
self.zeroed().then_some(
"Every control at zero. The file decodes and re-saves byte for byte, but it \
is not a factory program — nothing here knows what one would hold.",
)
}
pub(crate) fn bytes(self) -> Result<Vec<u8>, String> {
let at = |slot: u16| -> Result<ne5::program::Location, String> {
(0, slot).try_into().map_err(|e| format!("{e}"))
};
let entity = match self {
Fresh::Program => Entity::Program(Program::Electro5(ne5::program::new(at(0)?))),
Fresh::Live => Entity::Live(Live::Electro5(ne5::live::new(
(0, 0).try_into().map_err(|e| format!("{e}"))?,
))),
Fresh::SetList => Entity::Song(Song::Electro5(
ne5::song::new(
(0, 0).try_into().map_err(|e| format!("{e}"))?,
ne5::song::DEFAULT_VERSION,
[at(0)?, at(1)?, at(2)?, at(3)?],
)
.map_err(|e| format!("{e}"))?,
)),
Fresh::Settings => Entity::Settings(Settings::Electro5(ne5::settings::new())),
Fresh::Stage2Program => zeroed!(
ns2::Program,
ns2::program::BODY_LEN,
ns2::program::FORMAT,
ns2::program::KNOWN_VERSIONS,
|f| Entity::Program(Program::Stage2(f))
),
Fresh::Stage3Program => zeroed!(
ns3::Program,
ns3::program::BODY_LEN,
ns3::program::FORMAT,
ns3::program::KNOWN_VERSIONS,
|f| Entity::Program(Program::Stage3(f))
),
Fresh::Stage3Synth => zeroed!(
ns3::SynthPreset,
ns3::synth::BODY_LEN,
ns3::synth::FORMAT,
ns3::synth::KNOWN_VERSIONS,
|f| Entity::Synth(Synth::Stage3(f))
),
Fresh::Stage4Program => zeroed!(
ns4::Program,
ns4::program::BODY_LEN,
ns4::program::FORMAT,
ns4::program::KNOWN_VERSIONS,
|f| Entity::Program(Program::Stage4(f))
),
Fresh::Stage4Organ => zeroed!(
ns4::organ_preset::OrganPreset,
ns4::organ_preset::BODY_LEN,
ns4::organ_preset::FORMAT,
ns4::organ_preset::KNOWN_VERSIONS,
|f| Entity::OrganPreset(OrganPreset::Stage4(f))
),
Fresh::Stage4Piano => zeroed!(
ns4::piano_preset::PianoPreset,
ns4::piano_preset::BODY_LEN,
ns4::piano_preset::FORMAT,
ns4::piano_preset::KNOWN_VERSIONS,
|f| Entity::PianoPreset(PianoPreset::Stage4(f))
),
Fresh::Stage4Synth => zeroed!(
ns4::synth::SynthPreset,
ns4::synth::BODY_LEN,
ns4::synth::FORMAT,
ns4::synth::KNOWN_VERSIONS,
|f| Entity::Synth(Synth::Stage4(f))
),
};
nord_format::to_bytes(&entity).map_err(|e| e.to_string())
}
}
enum Arrival {
Read,
Unverified,
Unreadable,
}
pub struct Saved {
pub id: u64,
pub name: String,
pub origin: Origin,
pub saved: Vec<u8>,
pub unsaved: Option<Vec<u8>>,
}
enum Incoming {
Opened {
name: String,
bytes: Vec<u8>,
},
Wavs {
making: Making,
files: Vec<(String, Vec<u8>)>,
},
Note(String),
Failed(String),
}
pub struct Workspace {
entities: Vec<LocalEntity>,
next_id: u64,
revision: u64,
ctx: egui::Context,
tx: Sender<Incoming>,
rx: Receiver<Incoming>,
draft: Option<Draft>,
}
impl Workspace {
pub fn new(ctx: egui::Context) -> Workspace {
let (tx, rx) = std::sync::mpsc::channel();
Workspace {
entities: Vec::new(),
next_id: 1,
revision: 0,
ctx,
tx,
rx,
draft: None,
}
}
pub fn ctx(&self) -> &egui::Context {
&self.ctx
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn entities(&self) -> &[LocalEntity] {
&self.entities
}
pub fn listed(&self) -> impl Iterator<Item = &LocalEntity> {
self.entities.iter().filter(|e| e.kept)
}
pub fn get(&self, id: u64) -> Option<&LocalEntity> {
self.entities.iter().find(|e| e.id == id)
}
pub fn is_view(&self, id: u64) -> bool {
self.get(id).is_some_and(|entity| !entity.kept)
}
pub fn view(&mut self, name: String, origin: Origin, bytes: Vec<u8>, log: &mut Log) -> u64 {
let (id, _) = self.add(name, origin, bytes, log);
let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) else {
return id;
};
entity.kept = false;
let where_ = match entity.origin.slot() {
Some((class, at)) => crate::strings::place(class, at),
None => "the instrument".to_string(),
};
log.say(format!(
"Viewing {where_} — “{}” is not kept on this computer.",
entity.name
));
id
}
pub fn view_of(&self, class: ObjectClass, at: Location) -> Option<u64> {
self.entities
.iter()
.find(|e| !e.kept && e.origin.slot() == Some((class, at)))
.map(|e| e.id)
}
pub fn keep(&mut self, id: u64, log: &mut Log) {
let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) else {
return;
};
if std::mem::replace(&mut entity.kept, true) {
return;
}
let name = entity.name.clone();
self.revision += 1;
log.say(format!("“{name}” is on this computer."));
}
pub fn close_views(
&mut self,
open: impl Fn(u64) -> bool,
pending: impl Fn(u64) -> bool,
queue: &Queue,
log: &mut Log,
) {
let mut rescued = Vec::new();
let before = self.entities.len();
self.entities.retain_mut(|entity| {
if entity.kept || open(entity.id) {
return true;
}
if !precious(entity, queue) && !pending(entity.id) {
return false;
}
entity.kept = true;
rescued.push(entity.name.clone());
true
});
for name in &rescued {
log.say(format!(
"“{name}” is kept on this computer — it has changes the instrument does \
not."
));
}
if self.entities.len() == before && rescued.is_empty() {
return;
}
self.revision += 1;
}
pub fn relink(&mut self, held_by: impl Fn(&LocalEntity) -> Option<(ObjectClass, Location)>) {
for entity in &mut self.entities {
entity.link = held_by(entity);
}
}
pub fn rename(&mut self, id: u64, name: String) {
if let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) {
entity.name = name;
self.revision += 1;
}
}
fn add(
&mut self,
name: String,
origin: Origin,
bytes: Vec<u8>,
log: &mut Log,
) -> (u64, Arrival) {
let id = self.next_id;
self.next_id += 1;
let entity = LocalEntity::new(id, name, origin, bytes, self.stamp());
let arrival = match (&entity.parse_error, &entity.verify) {
(Some(e), _) => {
log.error(format!("{}: {e}", entity.name));
Arrival::Unreadable
}
(None, VerifyState::Ok) => {
log.info(format!(
"{}: {} ({} bytes), verified",
entity.name,
entity.tag(),
entity.bytes.len(),
));
Arrival::Read
}
(None, state) => {
log.warn(format!(
"{}: {} — verify {}: {}",
entity.name,
entity.tag(),
state.badge(),
state.detail(),
));
Arrival::Unverified
}
};
self.entities.push(entity);
(id, arrival)
}
pub fn ingest(&mut self, name: String, origin: Origin, bytes: Vec<u8>, log: &mut Log) -> u64 {
let (id, arrival) = self.add(name.clone(), origin, bytes, log);
match arrival {
Arrival::Unreadable => {
log.trouble(format!("“{name}” is not a file this app understands."))
}
Arrival::Read => log.say(format!("“{name}” is on this computer.")),
Arrival::Unverified => log.say(format!(
"“{name}” opened, but it does not re-save byte for byte."
)),
}
id
}
fn stamp(&mut self) -> u64 {
self.revision += 1;
self.revision
}
fn stamp_for(&mut self, id: u64, bytes: &[u8]) -> u64 {
match self
.get(id)
.map(|entity| (entity.stamp, entity.bytes == bytes))
{
Some((stamp, true)) => stamp,
_ => self.stamp(),
}
}
pub fn poll(&mut self, log: &mut Log) {
while let Ok(message) = self.rx.try_recv() {
match message {
Incoming::Opened { name, bytes } => {
self.ingest(name.clone(), Origin::File(name), bytes, log);
}
Incoming::Wavs { making, files } => self.draft = Draft::plan(making, files),
Incoming::Note(text) => log.say(text),
Incoming::Failed(text) => log.trouble(text),
}
}
}
pub fn open_dialog(&self) {
let tx = self.tx.clone();
let ctx = self.ctx.clone();
spawn(async move {
let picked = rfd::AsyncFileDialog::new()
.set_title("Open Nord files")
.pick_files()
.await;
for handle in picked.unwrap_or_default() {
let bytes = handle.read().await;
let _ = tx.send(Incoming::Opened {
name: handle.file_name(),
bytes,
});
}
ctx.request_repaint();
});
}
pub fn pick_wavs(&self, making: Making) {
let tx = self.tx.clone();
let ctx = self.ctx.clone();
spawn(async move {
let picked = rfd::AsyncFileDialog::new()
.set_title(format!("Pick the WAVs for a {}", making.label()))
.add_filter("WAV", &["wav"])
.pick_files()
.await;
let mut files = Vec::new();
for handle in picked.unwrap_or_default() {
let bytes = handle.read().await;
files.push((handle.file_name(), bytes));
}
let _ = tx.send(Incoming::Wavs { making, files });
ctx.request_repaint();
});
}
pub fn draft_mut(&mut self) -> Option<&mut Draft> {
self.draft.as_mut()
}
pub fn take_draft(&mut self) -> Option<Draft> {
self.draft.take()
}
pub fn export_name(&self, id: u64) -> Option<String> {
let entity = self.get(id)?;
Some(export_filename(&entity.name, &entity.bytes))
}
pub fn export(&self, id: u64) {
let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
return;
};
let name = match self.export_name(id) {
Some(name) => name,
None => return,
};
self.save_bytes(name, entity.bytes.clone());
}
pub fn save_bytes(&self, name: String, bytes: Vec<u8>) {
let tx = self.tx.clone();
let ctx = self.ctx.clone();
spawn(async move {
let _ = tx.send(save(name, bytes).await);
ctx.request_repaint();
});
}
pub fn revert(&mut self, id: u64, log: &mut Log) {
let Some(saved) = self.get(id).map(|entity| entity.saved.bytes.clone()) else {
return;
};
if self.respell(id, saved).is_none() {
return;
}
if let Some(entity) = self.get(id) {
log.say(format!("“{}” is back as it was last saved.", entity.name));
}
}
pub fn mark_saved(&mut self, id: u64) {
let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) else {
return;
};
if !entity.is_unsaved() {
return;
}
entity.saved = entity.baseline();
self.revision += 1;
}
pub fn landed(&mut self, id: u64, class: ObjectClass, at: Location, sent: Vec<u8>) {
let stamp = self.stamp_for(id, &sent);
let Some(entity) = self.entities.iter_mut().find(|e| e.id == id) else {
return;
};
entity.saved = Baseline::read(sent, stamp);
entity.link = Some((class, at));
entity.wrote = entity.saved.crc32.map(|crc32| Wrote { class, at, crc32 });
self.revision += 1;
}
pub fn forget_writes(&mut self) {
for entity in &mut self.entities {
entity.wrote = None;
}
}
pub fn replace_bytes(&mut self, id: u64, bytes: Vec<u8>, log: &mut Log) {
let Some(verify) = self.respell(id, bytes) else {
return;
};
if let VerifyState::Ok = verify {
return;
}
log.warn(format!(
"after editing, verify {}: {}",
verify.badge(),
verify.detail()
));
}
fn respell(&mut self, id: u64, bytes: Vec<u8>) -> Option<VerifyState> {
if self.get(id).is_none_or(|entity| entity.bytes == bytes) {
return None;
}
let stamp = self.stamp();
let held = self
.get(id)
.is_some_and(|entity| entity.saved.bytes == bytes);
let entity = self.entities.iter_mut().find(|e| e.id == id)?;
let (kept, link, wrote) = (entity.kept, entity.link, entity.wrote);
let saved = std::mem::take(&mut entity.saved);
let saved = Baseline {
stamp: match held {
true => stamp,
false => saved.stamp,
},
..saved
};
let replaced =
LocalEntity::new(id, entity.name.clone(), entity.origin.clone(), bytes, stamp);
let verify = replaced.verify.clone();
*entity = LocalEntity {
kept,
link,
saved,
wrote,
..replaced
};
Some(verify)
}
pub fn duplicate(&mut self, id: u64, log: &mut Log) -> Option<u64> {
let source = self.entities.iter().find(|e| e.id == id)?;
let name = format!("{} copy", source.name);
let (origin, bytes) = (source.origin.clone(), source.bytes.clone());
Some(self.ingest(name, origin, bytes, log))
}
pub fn remove(&mut self, id: u64, log: &mut Log) {
let Some(at) = self.entities.iter().position(|e| e.id == id) else {
return;
};
let gone = self.entities.remove(at);
self.revision += 1;
log.say(format!("Removed “{}” from this computer.", gone.name));
}
pub fn next_id(&self) -> u64 {
self.next_id
}
pub fn restore(&mut self, saved: Vec<Saved>, next_id: Option<u64>, log: &mut Log) -> usize {
let mut refused = 0;
for Saved {
id,
name,
origin,
saved,
unsaved,
} in saved
{
let Some(next) = id.checked_add(1) else {
refused += 1;
continue;
};
if self.entities.iter().any(|e| e.id == id) {
refused += 1;
continue;
}
let stamp = self.stamp();
let bytes = unsaved.unwrap_or_else(|| saved.clone());
let held = match bytes == saved {
true => stamp,
false => self.stamp(),
};
let entity = LocalEntity {
saved: Baseline::read(saved, held),
..LocalEntity::new(id, name, origin, bytes, stamp)
};
if let Some(e) = &entity.parse_error {
log.warn(format!("{}: {e}", entity.name));
}
self.next_id = self.next_id.max(next);
self.entities.push(entity);
}
if let Some(next) = next_id {
self.next_id = self.next_id.max(next);
}
self.revision += 1;
refused
}
pub fn create(&mut self, kind: Fresh, log: &mut Log) -> Option<u64> {
match kind.bytes() {
Ok(bytes) => {
let name = format!("untitled.{}", kind.tag());
Some(self.ingest(name, Origin::Fresh, bytes, log))
}
Err(e) => {
log.error(format!("new {}: {e}", kind.label()));
log.trouble(format!("Could not make a new {}.", kind.label()));
None
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn save(name: String, bytes: Vec<u8>) -> Incoming {
let Some(handle) = rfd::AsyncFileDialog::new()
.set_file_name(&name)
.save_file()
.await
else {
return Incoming::Note(format!("{name}: save cancelled"));
};
match write_beside(handle.path(), &bytes) {
Ok(()) => Incoming::Note(format!(
"wrote {} ({} bytes)",
handle.file_name(),
bytes.len(),
)),
Err(e) => Incoming::Failed(format!("{name}: {e}")),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn write_beside(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
let mut temp = path.as_os_str().to_owned();
temp.push(".tmp");
let temp = std::path::PathBuf::from(temp);
let wrote = std::fs::write(&temp, bytes).and_then(|()| std::fs::rename(&temp, path));
if wrote.is_err() {
let _ = std::fs::remove_file(&temp);
}
wrote
}
#[cfg(target_arch = "wasm32")]
async fn save(name: String, bytes: Vec<u8>) -> Incoming {
match download(&name, &bytes) {
Ok(()) => Incoming::Note(format!("downloaded {name} ({} bytes)", bytes.len())),
Err(e) => Incoming::Failed(format!("{name}: {e:?}")),
}
}
#[cfg(target_arch = "wasm32")]
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
let document = web_sys::window()
.and_then(|w| w.document())
.ok_or_else(|| JsValue::from_str("no document"))?;
let parts = js_sys::Array::new();
parts.push(&js_sys::Uint8Array::from(bytes).into());
let options = web_sys::BlobPropertyBag::new();
options.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)?;
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
let anchor: web_sys::HtmlAnchorElement = document.create_element("a")?.unchecked_into();
anchor.set_href(&url);
anchor.set_download(name);
anchor.click();
web_sys::Url::revoke_object_url(&url)?;
Ok(())
}
#[cfg(test)]
pub(crate) fn as_type_0(bytes: &[u8]) -> Vec<u8> {
let mut file = nord_usb::envelope::unwrap(bytes).expect("a CBIN file with a body");
file.header.generation = Generation::V0;
let mut out = std::io::Cursor::new(Vec::new());
file.write_to(&mut out).expect("a type-0 container writes");
out.into_inner()
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn<F: std::future::Future<Output = ()> + Send + 'static>(future: F) {
std::thread::spawn(move || nord_usb::block_on(future));
}
#[cfg(target_arch = "wasm32")]
fn spawn<F: std::future::Future<Output = ()> + 'static>(future: F) {
wasm_bindgen_futures::spawn_local(future);
}
#[cfg(test)]
mod tests {
use super::*;
fn ingest(name: &str, bytes: Vec<u8>) -> LocalEntity {
LocalEntity::new(1, name.into(), Origin::Fresh, bytes, 0)
}
#[test]
fn a_fresh_program_decodes_and_verifies() {
let entity = ingest("untitled.ne5p", Fresh::Program.bytes().unwrap());
assert!(entity.parse_error.is_none());
assert_eq!(entity.tag(), "ne5p");
assert!(
matches!(entity.verify, VerifyState::Ok),
"{}",
entity.verify.detail()
);
let container = entity.container.expect("a fresh program is a CBIN file");
assert!(container.checksum_ok);
assert_eq!(container.header.generation, Generation::V1);
assert_eq!(container.body.len(), ne5::program::BODY_LEN);
assert_eq!(container.checksum_label, "crc32:");
}
#[test]
fn the_body_checksum_is_the_word_a_type_1_header_stores() {
let bytes = Fresh::Program.bytes().unwrap();
let entity = ingest("untitled.ne5p", bytes.clone());
let container = entity.container.expect("a fresh program is a CBIN file");
assert_eq!(container.header.generation, Generation::V1);
let body = nord_usb::envelope::unwrap(&bytes).expect("a file the wire takes");
assert_eq!(
container.body_crc32,
nord_usb::envelope::crc32(&body.body.0)
);
assert_eq!(
container.body_crc32,
u32::from_le_bytes(bytes[0x18..0x1c].try_into().unwrap()),
);
}
#[test]
fn a_type_0_file_has_the_body_checksum_its_header_does_not_carry() {
let bytes = as_type_0(&Fresh::Program.bytes().unwrap());
let entity = ingest("Circling Bells.ne5p", bytes.clone());
let container = entity.container.as_ref().expect("still a CBIN file");
assert_eq!(container.header.generation, Generation::V0);
assert!(container.checksum_ok);
assert_eq!(container.checksum_label, "crc16:");
let body = nord_usb::envelope::unwrap(&bytes).expect("a file the wire takes");
let hashed = nord_usb::envelope::crc32(&body.body.0);
assert_eq!(container.body_crc32, hashed);
assert_eq!(entity.saved.crc32, Some(hashed));
}
#[test]
fn every_fresh_default_round_trips_under_its_own_tag() {
for kind in Fresh::ALL {
let entity = ingest("untitled", kind.bytes().unwrap());
assert!(entity.parse_error.is_none(), "{:?}", kind);
assert_eq!(entity.tag(), kind.tag(), "{kind:?}");
assert!(matches!(entity.verify, VerifyState::Ok), "{kind:?}");
assert!(
entity.container.expect("a CBIN file").checksum_ok,
"{kind:?}"
);
}
}
#[test]
fn every_kind_sits_in_exactly_one_family() {
let mut seen: Vec<Fresh> = Fresh::FAMILIES
.iter()
.flat_map(|family| family.kinds.iter().copied())
.collect();
assert_eq!(seen.len(), Fresh::ALL.len());
for kind in Fresh::ALL {
let at = seen.iter().position(|held| *held == kind);
seen.remove(at.unwrap_or_else(|| panic!("{kind:?} is in no family")));
}
assert!(seen.is_empty());
}
#[test]
fn a_zeroed_body_says_that_it_is_one() {
assert!(!Fresh::Program.zeroed() && Fresh::Program.note().is_none());
for kind in Fresh::ALL.iter().filter(|kind| kind.zeroed()) {
let note = kind.note().unwrap_or_else(|| panic!("{kind:?}"));
assert!(note.contains("zero"), "{kind:?}: {note}");
}
}
#[test]
fn no_two_kinds_share_a_tag() {
let mut tags: Vec<&str> = Fresh::ALL.iter().map(|kind| kind.tag()).collect();
tags.sort_unstable();
let held = tags.len();
tags.dedup();
assert_eq!(tags.len(), held);
}
#[test]
fn a_view_is_not_on_this_computer_until_it_is_kept() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let device = || Origin::Device {
class: ObjectClass::Program,
at,
};
let viewed = workspace.view(
"Africa-Split.ne5p".into(),
device(),
Fresh::Program.bytes().unwrap(),
&mut log,
);
let copied = workspace.ingest(
"Squabble-B.ne5p".into(),
device(),
Fresh::Program.bytes().unwrap(),
&mut log,
);
assert!(workspace.is_view(viewed) && !workspace.is_view(copied));
let listed: Vec<u64> = workspace.listed().map(|e| e.id).collect();
assert_eq!(listed, vec![copied], "a view is not in the local list");
assert!(workspace.get(viewed).is_some());
assert_eq!(workspace.entities().len(), 2);
let edited = workspace.get(viewed).unwrap().bytes.clone();
workspace.replace_bytes(viewed, [edited, vec![]].concat(), &mut log);
assert!(workspace.is_view(viewed));
workspace.keep(viewed, &mut log);
assert!(!workspace.is_view(viewed));
assert_eq!(workspace.listed().count(), 2);
}
#[test]
fn viewing_a_slot_says_that_and_not_that_it_was_kept() {
let mut workspace = Workspace::new(egui::Context::default());
let mut log = Log::default();
let id = workspace.view(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at: Location { bank: 6, slot: 3 },
},
Fresh::Program.bytes().unwrap(),
&mut log,
);
assert!(workspace.is_view(id));
assert!(log.status().1.starts_with("Viewing "), "{}", log.status().1);
assert!(
!log.iter()
.any(|entry| entry.text.contains("is on this computer.")),
"a view was never taken onto this computer"
);
assert!(log.iter().any(|entry| entry.text.contains("verified")));
}
#[test]
fn an_edit_and_a_revert_leave_the_write_this_app_made() {
let mut workspace = Workspace::new(egui::Context::default());
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let sent = workspace.get(id).unwrap().bytes.clone();
workspace.landed(id, ObjectClass::Program, at, sent.clone());
let wrote = |workspace: &Workspace| {
workspace
.get(id)
.unwrap()
.wrote
.map(|held| (held.class, held.at, held.crc32))
};
let landed = wrote(&workspace).expect("a write this app made");
let (_, edited) =
crate::fields::apply(&sent, &[("center_panel.gain".into(), "96".into())]).unwrap();
workspace.replace_bytes(id, edited, &mut log);
assert_eq!(wrote(&workspace), Some(landed), "an edit is not a write");
workspace.revert(id, &mut log);
assert_eq!(wrote(&workspace), Some(landed), "and neither is a revert");
}
#[test]
fn a_view_goes_when_the_last_tab_on_it_closes() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let viewed = workspace.view(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at,
},
Fresh::Program.bytes().unwrap(),
&mut log,
);
let local = workspace.create(Fresh::Program, &mut log).unwrap();
let queue = Queue::default();
workspace.close_views(|id| id == viewed, |_| false, &queue, &mut log);
assert!(workspace.get(viewed).is_some(), "its tab is still open");
workspace.close_views(|_| false, |_| false, &queue, &mut log);
assert!(workspace.get(viewed).is_none());
assert!(workspace.get(local).is_some(), "kept is kept");
}
#[test]
fn a_view_with_changes_in_it_is_kept_rather_than_dropped() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = |slot| Location { bank: 6, slot };
let view = |workspace: &mut Workspace, slot, log: &mut Log| {
workspace.view(
format!("view-{slot}.ne5p"),
Origin::Device {
class: ObjectClass::Program,
at: at(slot),
},
Fresh::Program.bytes().unwrap(),
log,
)
};
let edited = view(&mut workspace, 0, &mut log);
let owed = view(&mut workspace, 1, &mut log);
let untouched = view(&mut workspace, 2, &mut log);
let bytes = workspace.get(edited).unwrap().bytes.clone();
workspace.replace_bytes(edited, [bytes, vec![0]].concat(), &mut log);
let mut queue = Queue::default();
crate::queue::enqueue(
&workspace,
&mut crate::device::Device::new(workspace.ctx().clone()),
&mut queue,
&mut log,
owed,
ObjectClass::Program,
at(1),
);
assert!(precious(workspace.get(edited).unwrap(), &queue));
assert!(precious(workspace.get(owed).unwrap(), &queue));
assert!(!precious(workspace.get(untouched).unwrap(), &queue));
workspace.close_views(|_| false, |_| false, &queue, &mut log);
assert!(workspace.get(untouched).is_none(), "the slot still has it");
let listed: Vec<u64> = workspace.listed().map(|e| e.id).collect();
assert_eq!(listed, vec![edited, owed], "and the changes survive");
assert!(!workspace.is_view(edited) && !workspace.is_view(owed));
assert!(queue.holds(owed));
assert!(log.status().1.contains("kept on this computer"));
}
#[test]
fn a_view_whose_edit_is_still_a_plan_is_kept_rather_than_dropped() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let planning = workspace.view(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at: Location { bank: 6, slot: 3 },
},
Fresh::Program.bytes().unwrap(),
&mut log,
);
let queue = Queue::default();
assert!(!precious(workspace.get(planning).unwrap(), &queue));
workspace.close_views(|_| false, |id| id == planning, &queue, &mut log);
assert!(workspace.get(planning).is_some(), "the plan survives");
assert!(!workspace.is_view(planning), "and is listed to survive in");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn a_save_lands_whole_and_leaves_no_temporary_beside_it() {
let dir = std::env::temp_dir().join("drawbar-save-beside");
std::fs::create_dir_all(&dir).expect("a directory to save into");
let path = dir.join("Royal Grand.npno");
std::fs::write(&path, b"what was there before").expect("a file to replace");
write_beside(&path, b"the bytes the editor made").expect("it writes");
assert_eq!(
std::fs::read(&path).expect("it is there"),
b"the bytes the editor made"
);
let left: Vec<std::ffi::OsString> = std::fs::read_dir(&dir)
.expect("it is still a directory")
.map(|entry| entry.expect("an entry").file_name())
.collect();
assert_eq!(left, [path.file_name().expect("a name")], "{left:?}");
let nowhere = dir.join("no-such-folder").join("Royal Grand.npno");
assert!(write_beside(&nowhere, b"anything").is_err());
assert!(!nowhere.with_extension("npno.tmp").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_slot_has_at_most_one_view() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let elsewhere = Location { bank: 6, slot: 4 };
let class = ObjectClass::Program;
assert_eq!(workspace.view_of(class, at), None);
let id = workspace.view(
"Africa-Split.ne5p".into(),
Origin::Device { class, at },
Fresh::Program.bytes().unwrap(),
&mut log,
);
assert_eq!(workspace.view_of(class, at), Some(id));
assert_eq!(workspace.view_of(class, elsewhere), None);
assert_eq!(workspace.view_of(ObjectClass::SetList, at), None);
workspace.ingest(
"Africa-Split.ne5p".into(),
Origin::Device { class, at },
Fresh::Program.bytes().unwrap(),
&mut log,
);
assert_eq!(workspace.view_of(class, at), Some(id));
workspace.keep(id, &mut log);
assert_eq!(workspace.view_of(class, at), None);
}
#[test]
fn the_raw_body_export_drops_the_container_header() {
let entity = ingest("untitled.ne5p", Fresh::Program.bytes().unwrap());
let body = entity.raw_body().expect("a CBIN file has a body");
assert_eq!(body.len(), ne5::program::BODY_LEN);
assert_eq!(
body.as_slice(),
&entity.bytes[Generation::V1.body_start() as usize..],
);
}
#[test]
fn an_export_sanitises_the_name_and_supplies_the_extension() {
let bytes = Fresh::Program.bytes().unwrap();
let file = |name: &str| export_filename(name, &bytes);
assert_eq!(file("Big strings"), "Big-strings.ne5p");
assert_eq!(file("patch.ne5p"), "patch.ne5p", "a carried tag is kept");
assert_eq!(file("Bass 2.0"), "Bass-2.0.ne5p", "a dot is not a tag");
assert_eq!(file("../../etc/passwd"), "etc-passwd.ne5p");
assert_eq!(file(" "), "unnamed.ne5p");
assert_eq!(
export_filename("Big strings", b"no header"),
"Big-strings.bin",
);
}
#[test]
fn a_zone_wav_is_named_after_its_instrument_and_number() {
assert_eq!(zone_wav_name("Bass Clarinet", 2), "Bass-Clarinet-zone2.wav");
assert_eq!(zone_wav_name("../../etc/passwd", 1), "etc-passwd-zone1.wav");
assert_eq!(zone_wav_name(" ", 1), "unnamed-zone1.wav");
}
#[test]
fn bytes_carry_a_stamp_that_changes_only_when_they_do() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let opened = workspace.get(id).unwrap().bytes.clone();
let stamp = |workspace: &Workspace| workspace.get(id).unwrap().stamp;
let first = stamp(&workspace);
workspace.rename(id, "Africa-Split".into());
assert_eq!(stamp(&workspace), first, "the bytes did not move");
let (_, edited) =
crate::fields::apply(&opened, &[("center_panel.gain".into(), "96".into())]).unwrap();
workspace.replace_bytes(id, edited, &mut log);
let second = stamp(&workspace);
assert_ne!(second, first);
workspace.revert(id, &mut log);
let third = stamp(&workspace);
assert_ne!(third, second);
assert_ne!(third, first, "back to the same bytes is still a new decode");
workspace.revert(id, &mut log);
assert_eq!(stamp(&workspace), third);
}
#[test]
fn an_asset_is_unsaved_while_it_holds_something_its_baseline_does_not() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let opened = workspace.get(id).unwrap().bytes.clone();
let unsaved = |workspace: &Workspace| workspace.get(id).unwrap().is_unsaved();
assert!(!unsaved(&workspace), "a fresh asset starts saved");
let (_, edited) =
crate::fields::apply(&opened, &[("center_panel.gain".into(), "96".into())]).unwrap();
workspace.replace_bytes(id, edited.clone(), &mut log);
assert!(unsaved(&workspace));
assert_eq!(workspace.get(id).unwrap().saved.bytes, opened);
workspace.mark_saved(id);
assert!(!unsaved(&workspace));
assert_eq!(workspace.get(id).unwrap().saved.bytes, edited);
assert_eq!(workspace.get(id).unwrap().bytes, edited);
let (_, again) =
crate::fields::apply(&edited, &[("center_panel.gain".into(), "12".into())]).unwrap();
workspace.replace_bytes(id, again, &mut log);
assert!(unsaved(&workspace));
workspace.revert(id, &mut log);
assert!(!unsaved(&workspace));
assert_eq!(workspace.get(id).unwrap().bytes, edited);
let (_, away) =
crate::fields::apply(&edited, &[("center_panel.gain".into(), "12".into())]).unwrap();
workspace.replace_bytes(id, away, &mut log);
assert!(unsaved(&workspace));
workspace.replace_bytes(id, edited, &mut log);
assert!(!unsaved(&workspace), "it holds what it was saved as again");
}
#[test]
fn a_write_that_landed_saves_what_it_carried_rather_than_a_later_edit() {
let mut workspace = Workspace::new(egui::Context::default());
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let sent = workspace.get(id).unwrap().bytes.clone();
let (_, edited) =
crate::fields::apply(&sent, &[("center_panel.gain".into(), "96".into())]).unwrap();
workspace.replace_bytes(id, edited.clone(), &mut log);
workspace.landed(id, ObjectClass::Program, at, sent.clone());
assert!(
workspace.get(id).unwrap().is_unsaved(),
"the edit made in flight is still owed"
);
assert_eq!(workspace.get(id).unwrap().saved.bytes, sent);
workspace.landed(id, ObjectClass::Program, at, edited);
assert!(
!workspace.get(id).unwrap().is_unsaved(),
"a write of what it holds leaves nothing owed"
);
}
#[test]
fn the_baseline_carries_the_checksum_a_slot_holding_it_reports() {
let entity = ingest("untitled.ne5p", Fresh::Program.bytes().unwrap());
let body = nord_usb::envelope::unwrap(&entity.bytes).expect("a file the wire takes");
assert_eq!(
entity.saved.crc32,
Some(nord_usb::envelope::crc32(&body.body.0))
);
}
#[test]
fn a_project_export_keeps_its_own_extension() {
let project = nord_format::formats::nsmpproj::Project::new(
"One",
&[nord_format::formats::nsmpproj::NewZone {
path: "one.wav".into(),
sample_rate: 44100,
frames: 44100,
root_key: 60,
}],
0,
)
.unwrap();
let bytes = nord_format::to_bytes(&Entity::SampleProject(project)).unwrap();
assert_eq!(
export_filename("proj.nsmpproj", &bytes),
"proj.nsmpproj",
"a carried project extension is kept"
);
assert_eq!(export_filename("My Kit", &bytes), "My-Kit.nsmpproj");
}
#[test]
fn bytes_that_do_not_decode_are_kept_with_their_error() {
let entity = ingest("junk.bin", b"not a nord file at all".to_vec());
assert!(entity.entity.is_none());
assert!(entity.parse_error.is_some());
assert!(entity.container.is_none());
assert!(matches!(entity.verify, VerifyState::NotApplicable(_)));
assert_eq!(entity.tag(), "?");
}
#[test]
fn a_name_survives_being_fetched_opened_edited_and_exported() {
use nord_usb::{Location, ObjectClass};
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
let at = Location { bank: 6, slot: 3 };
let id = workspace.ingest(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at,
},
Fresh::Program.bytes().unwrap(),
&mut log,
);
assert_eq!(workspace.get(id).unwrap().name, "Africa-Split.ne5p");
let bytes = workspace.get(id).unwrap().bytes.clone();
let (_, edited) =
crate::fields::apply(&bytes, &[("center_panel.gain".into(), "96".into())]).unwrap();
workspace.replace_bytes(id, edited, &mut log);
assert_eq!(workspace.get(id).unwrap().name, "Africa-Split.ne5p");
assert!(workspace.get(id).unwrap().is_unsaved());
workspace.revert(id, &mut log);
assert_eq!(workspace.get(id).unwrap().bytes, bytes);
assert_eq!(workspace.get(id).unwrap().name, "Africa-Split.ne5p");
assert_eq!(
workspace.export_name(id).as_deref(),
Some("Africa-Split.ne5p")
);
}
#[test]
fn a_restored_asset_keeps_what_it_was() {
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx);
let mut log = Log::default();
workspace.restore(
vec![Saved {
id: 9,
name: "Africa-Split.ne5p".into(),
origin: Origin::Fresh,
saved: Fresh::Program.bytes().unwrap(),
unsaved: None,
}],
Some(10),
&mut log,
);
let entity = workspace.get(9).expect("restored under its own id");
assert_eq!(entity.name, "Africa-Split.ne5p");
assert!(matches!(entity.verify, VerifyState::Ok));
assert!(!entity.is_unsaved());
let fresh = workspace.create(Fresh::Live, &mut log).unwrap();
assert!(fresh >= 10);
}
#[test]
fn a_tampered_body_byte_is_reported() {
let mut bytes = Fresh::Program.bytes().unwrap();
let at = Generation::V1.body_start() as usize + 0x30;
bytes[at] ^= 0xff;
let entity = ingest("tampered.ne5p", bytes);
assert!(entity.parse_error.is_some());
assert!(!entity.container.expect("still a CBIN file").checksum_ok);
}
}