From 4388c75bb8e99f2b341a1059d2d92771fa4b5367 Mon Sep 17 00:00:00 2001 From: Max Bradbury Date: Mon, 9 Sep 2024 22:46:51 +0100 Subject: [PATCH] WIP version of 8.12... --- Cargo.toml | 6 +- LICENSE | 2 +- TODO.md | 9 ++ src/blip.rs | 4 +- src/error.rs | 8 +- src/game.rs | 175 ++++++++++++++++++++++++------- src/image.rs | 37 +++++-- src/lib.rs | 7 ++ src/mock.rs | 17 ++- src/room.rs | 19 +++- src/sprite.rs | 13 ++- src/test-resources/default.bitsy | 4 +- src/test_omnibus.rs | 2 + src/tile.rs | 11 +- src/tune.rs | 41 ++++++++ 15 files changed, 286 insertions(+), 69 deletions(-) create mode 100644 TODO.md create mode 100644 src/tune.rs diff --git a/Cargo.toml b/Cargo.toml index 7aa0639..c9f4639 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitsy-parser" -version = "0.710.1" +version = "0.812.0" authors = ["Max Bradbury "] edition = "2021" description = "A parser and utilities for working with Bitsy game data" @@ -12,6 +12,6 @@ keywords = ["gamedev"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -data-encoding = "^2.3.1" +data-encoding = "^2.6.0" radix_fmt = "^1.0.0" -loe = "^0.2.0" +loe = "0.3.0" diff --git a/LICENSE b/LICENSE index 3361e03..04962f5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright © 2021 Max Bradbury +Copyright © 2024 Max Bradbury Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..be125ea --- /dev/null +++ b/TODO.md @@ -0,0 +1,9 @@ +# 8.0 changes + +* NAME fields have moved underneath the main body of data +* "BGC *" / "BGC 1" property (transparent / palette colour index) +* flags near top of game data ("! VER_MAJ 8" etc.) +* blips + * implement from_str, display +* tunes + * implement from_str, display diff --git a/src/blip.rs b/src/blip.rs index 4c7587f..5ae76ec 100644 --- a/src/blip.rs +++ b/src/blip.rs @@ -2,6 +2,7 @@ use core::fmt; use std::fmt::Formatter; use crate::note::Note; +#[derive(Clone, Debug, PartialEq)] pub enum PulseWidth { /// 50% duty cycle Half, @@ -43,9 +44,8 @@ pub struct Blip { /// first value is milliseconds per note; /// second value is a modifier to the first note (add or subtract milliseconds) beat: [i16; 2], - /// potential values are P2, P4 and P8. pulse_width: PulseWidth, - /// Notes can sound repeatedly, or just once as the blip fades out. + /// Notes can cycle repeatedly, or just play once repeat: bool, } diff --git a/src/error.rs b/src/error.rs index 4637b08..fbed0e6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,6 @@ use std::fmt; -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub enum NotFound { Anything, Avatar, @@ -21,13 +21,13 @@ impl fmt::Display for NotFound { } } -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum ImageError { MalformedPixel, WrongSize, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum Error { Colour, Dialogue, @@ -43,6 +43,8 @@ pub enum Error { Item, Palette, Position, + PulseWidth, + RelativeNote, Room, Sprite, Text, diff --git a/src/game.rs b/src/game.rs index 9d1a8b5..0cce120 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,11 +1,15 @@ -use crate::{Dialogue, Ending, Font, Image, Item, Palette, Room, Sprite, TextDirection, Tile, Variable, transform_line_endings, new_unique_id, try_id, Instance, Error}; +use crate::{ + Blip, Dialogue, Ending, Font, Image, Item, Palette, + Room, Sprite, TextDirection, Tile, Tune, Variable, Instance, Error, + transform_line_endings, new_unique_id, try_id +}; +use crate::error::NotFound; use loe::TransformMode; - use std::collections::HashMap; use std::borrow::BorrowMut; use std::fmt; -use crate::error::NotFound; +use std::fmt::Display; /// in very early versions of Bitsy, room tiles were defined as single alphanumeric characters - /// so there was a maximum of 36 unique tiles. later versions are comma-separated. @@ -26,12 +30,12 @@ impl RoomFormat { } } -impl fmt::Display for RoomFormat { +impl Display for RoomFormat { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", match &self { RoomFormat::Contiguous => 0, RoomFormat::CommaSeparated => 1, - }) + }.to_string()) } } @@ -39,12 +43,13 @@ impl fmt::Display for RoomFormat { #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum RoomType {Room, Set} -impl ToString for RoomType { - fn to_string(&self) -> String { - match &self { - RoomType::Set => "SET", +impl Display for RoomType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let str = match &self { + RoomType::Set => "SET", RoomType::Room => "ROOM", - }.to_string() + }.to_string(); + write!(f, "{}", str) } } @@ -61,7 +66,7 @@ pub enum VersionError { MalformedInteger, } -impl fmt::Display for VersionError { +impl Display for VersionError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", match self { VersionError::MissingParts => "Not enough parts supplied for version", @@ -93,7 +98,14 @@ impl Version { pub struct Game { pub name: String, pub version: Option, + /// it's a bit weird that we have the version twice now, but whatever. only in 8.0+ + pub version_major: Option, + pub version_minor: Option, pub room_format: Option, + /// not sure what this does, could be either a boolean or an int + pub dialogue_compatibility: Option, + /// not sure what this does, could be either a boolean or an int + pub text_mode: Option, pub(crate) room_type: RoomType, pub font: Font, /// used if font is `Font::Custom` @@ -107,15 +119,18 @@ pub struct Game { pub dialogues: Vec, pub endings: Vec, pub variables: Vec, + pub tunes: Vec, + pub blips: Vec, pub font_data: Option, // todo make this an actual struct for parsing /// true if CRLF (Windows), otherwise LF (unix/mac) + /// todo use the enum? pub(crate) line_endings_crlf: bool, } impl Game { - pub fn from(string: String) -> Result<(Game, Vec), crate::error::NotFound> { + pub fn from(string: String) -> Result<(Game, Vec), NotFound> { if string.trim() == "" { - return Err(crate::error::NotFound::Anything); + return Err(NotFound::Anything); } let mut warnings = Vec::new(); @@ -123,7 +138,7 @@ impl Game { let line_endings_crlf = string.contains("\r\n"); let mut string = string; if line_endings_crlf { - string = transform_line_endings(string, TransformMode::LF) + string = transform_line_endings(string, TransformMode::Lf) } let string = string.trim_start_matches('\n').to_string(); @@ -163,7 +178,11 @@ impl Game { let mut font_data: Option = None; let mut version = None; + let mut version_major = None; + let mut version_minor = None; let mut room_format = None; + let mut dialogue_compatibility = None; + let mut text_mode = None; let mut room_type = RoomType::Room; let mut font = Font::AsciiSmall; let mut custom_font = None; @@ -186,11 +205,44 @@ impl Game { } else { warnings.push(Error::Version); } - } else if segment.starts_with("! ROOM_FORMAT") { - let segment = segment.replace("! ROOM_FORMAT ", ""); - room_format = Some( - RoomFormat::from(&segment).unwrap_or(RoomFormat::CommaSeparated) - ); + } else if segment.starts_with("! ") { + // this is (potentially?) an entire block, + // so we need to split it into lines and deal with the lines individually + for line in segment.lines() { + if line.starts_with("! VER_MAJ") { + let line = line.replace("! VER_MAJ ", ""); + + version_major = Some( + line.parse().expect("Couldn't parse major version") + ); + } else if line.starts_with("! VER_MIN") { + let line = line.replace("! VER_MIN ", ""); + + version_minor = Some( + line.parse().expect("Couldn't parse minor version") + ); + } else if line.starts_with("! ROOM_FORMAT") { + let line = line.replace("! ROOM_FORMAT ", ""); + + room_format = Some( + RoomFormat::from(&line).unwrap_or(RoomFormat::CommaSeparated) + ); + } else if line.starts_with("! DLG_COMPAT") { + let line = line.replace("! DLG_COMPAT ", ""); + + // not sure if this is supposed to be a boolean or a version number + dialogue_compatibility = Some( + line.parse().expect("Couldn't parse dialogue compatibility") + ); + } else if line.starts_with("! TXT_MODE") { + let line = line.replace("! TXT_MODE ", ""); + + // not sure if this is supposed to be a boolean or a version number + text_mode = Some( + line.parse().expect("Couldn't parse text mode") + ); + } + } } else if segment.starts_with("DEFAULT_FONT") { let segment = segment.replace("DEFAULT_FONT ", ""); @@ -229,8 +281,9 @@ impl Game { } else if segment.starts_with("ITM ") { let result = Item::from_str(&segment); - if let Ok(item) = result { + if let Ok((item, mut item_warnings)) = result { items.push(item); + warnings.append(&mut item_warnings); } else { warnings.push(result.unwrap_err()); } @@ -258,7 +311,7 @@ impl Game { } if ! avatar_exists { - warnings.push(crate::Error::Game { missing: NotFound::Avatar }); + warnings.push(Error::Game { missing: NotFound::Avatar }); } Ok( @@ -266,7 +319,11 @@ impl Game { Game { name, version, + version_major, + version_minor, room_format, + dialogue_compatibility, + text_mode, room_type, font, custom_font, @@ -279,6 +336,8 @@ impl Game { dialogues, endings, variables, + tunes: vec![], + blips: vec![], font_data, line_endings_crlf, }, @@ -288,40 +347,40 @@ impl Game { } /// todo refactor this into "get T by ID", taking a Vec and an ID name? - pub fn get_sprite_by_id(&self, id: String) -> Result<&Sprite, crate::error::NotFound> { + pub fn get_sprite_by_id(&self, id: String) -> Result<&Sprite, NotFound> { let index = self.sprites.iter().position( |sprite| sprite.id == id ); match index { Some(index) => Ok(&self.sprites[index]), - None => Err(crate::error::NotFound::Sprite), + None => Err(NotFound::Sprite), } } - pub fn get_tile_by_id(&self, id: String) -> Result<&Tile, crate::error::NotFound> { + pub fn get_tile_by_id(&self, id: String) -> Result<&Tile, NotFound> { let index = self.tiles.iter().position( |tile| tile.id == id ); match index { Some(index) => Ok(&self.tiles[index]), - None => Err(crate::error::NotFound::Tile), + None => Err(NotFound::Tile), } } - pub fn get_room_by_id(&self, id: String) -> Result<&Room, crate::error::NotFound> { + pub fn get_room_by_id(&self, id: String) -> Result<&Room, NotFound> { let index = self.rooms.iter().position( |room| room.id == id ); match index { Some(index) => Ok(&self.rooms[index]), - None => Err(crate::error::NotFound::Room), + None => Err(NotFound::Room), } } - pub fn get_avatar(&self) -> Result<&Sprite, crate::error::NotFound> { + pub fn get_avatar(&self) -> Result<&Sprite, NotFound> { self.get_sprite_by_id("A".to_string()) } @@ -338,7 +397,7 @@ impl Game { tiles } - pub fn get_tiles_for_room(&self, id: String) -> Result, crate::error::NotFound> { + pub fn get_tiles_for_room(&self, id: String) -> Result, NotFound> { let room = self.get_room_by_id(id)?; let mut tile_ids = room.tiles.clone(); tile_ids.sort(); @@ -552,8 +611,8 @@ impl Game { } } -impl ToString for Game { - fn to_string(&self) -> String { +impl Display for Game { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut segments: Vec = Vec::new(); // todo refactor @@ -597,18 +656,23 @@ impl ToString for Game { segments.push(self.font_data.to_owned().unwrap()) } - transform_line_endings( + let str = transform_line_endings( format!( - "{}{}{}{}{}\n\n{}\n\n", + "{}{}{}{}{}{}{}{}{}\n\n{}\n\n", &self.name, &self.version_line(), - &self.room_format_line(), + &self.version_major_line(), + &self.version_minor_line(), + &self.dialogue_compatibility_line(), + &self.text_mode_line(), + &self.version_minor_line(), &self.font_line(), &self.text_direction_line(), segments.join("\n\n"), ), - if self.line_endings_crlf {TransformMode::CRLF} else {TransformMode::LF} - ) + if self.line_endings_crlf { TransformMode::Crlf } else { TransformMode::Lf } + ); + write!(f, "{}", str) } } @@ -825,16 +889,49 @@ impl Game { if self.version.is_some() { format!( "\n\n# BITSY VERSION {}.{}", - self.version.as_ref().unwrap().major, self.version.as_ref().unwrap().minor + self.version.as_ref().unwrap().major.to_string(), + self.version.as_ref().unwrap().minor.to_string() ) } else { "".to_string() } } + fn version_major_line(&self) -> String { + if self.version_major.is_some() { + format!("\n! VER_MAJ {}", self.version_major.unwrap().to_string()) + } else { + "".to_string() + } + } + + fn version_minor_line(&self) -> String { + if self.version_minor.is_some() { + format!("\n! VER_MIN {}", self.version_minor.unwrap().to_string()) + } else { + "".to_string() + } + } + fn room_format_line(&self) -> String { if self.room_format.is_some() { - format!("\n\n! ROOM_FORMAT {}", self.room_format.unwrap().to_string()) + format!("\n! ROOM_FORMAT {}", self.room_format.unwrap().to_string()) + } else { + "".to_string() + } + } + + fn dialogue_compatibility_line(&self) -> String { + if self.dialogue_compatibility.is_some() { + format!("\n! DLG_COMPAT {}", self.dialogue_compatibility.unwrap().to_string()) + } else { + "".to_string() + } + } + + fn text_mode_line(&self) -> String { + if self.text_mode.is_some() { + format!("\n! TXT_MODE {}", self.text_mode.unwrap().to_string()) } else { "".to_string() } @@ -907,7 +1004,7 @@ mod test { for n in 1..10 { if n != 4 { let mut new_tile = crate::mock::tile_default(); - new_tile.id = format!("{}", n).to_string(); + new_tile.id = format!("{}", n.to_string()).to_string(); tiles.push(new_tile); } } diff --git a/src/image.rs b/src/image.rs index 4585842..8e00cb4 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,4 +1,5 @@ use std::fmt; +use crate::Error; use crate::error::ImageError; #[derive(Clone, Debug, Eq, PartialEq)] @@ -86,7 +87,7 @@ impl Image { if [64, 256].contains(&pixels.len()) { Ok((Image { pixels }, warnings)) } else { - Err(crate::Error::Image { err: ImageError::WrongSize }) + Err(Error::Image { err: ImageError::WrongSize }) } } } @@ -110,13 +111,37 @@ impl fmt::Display for Image { } /// todo return Result<(Vec, Vec), crate::Error> -pub fn animation_frames_from_str(str: &str) -> Vec { - str +pub fn animation_frames_from_str(str: &str) -> Result<(Vec, Vec), crate::Error> { + let mut warnings: Vec = Vec::new(); + + let results: Vec> = str .split('>') .collect::>() .iter() - .map(|&frame| Image::from_str(frame).unwrap().0) - .collect() + .map(|&frame| { + match Image::from_str(frame) { + Ok((frame, mut frame_warnings)) => { + warnings.append(&mut frame_warnings); + Ok(frame) + }, + Err(e) => { + warnings.push(e.clone()); + Err(e) + } + } + }) + .collect(); + + // this is a pretty stupid way of filtering the results + let mut ok_results = Vec::new(); + + for result in results { + if let Ok(image) = result { + ok_results.push(image); + } + } + + Ok((ok_results, warnings)) } #[cfg(test)] @@ -155,7 +180,7 @@ mod test { fn test_animation_frames_from_string() { let output = animation_frames_from_str( include_str!("test-resources/animation_frames") - ); + ).unwrap().0; let expected = mock::image::animation_frames(); diff --git a/src/lib.rs b/src/lib.rs index 0ce2677..ae0e380 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,12 @@ +extern crate core; + use std::fmt::Display; use std::io::Cursor; use radix_fmt::radix_36; use loe::{process, Config, TransformMode}; +pub mod blip; pub mod colour; pub mod dialogue; pub mod ending; @@ -20,8 +23,11 @@ pub mod sprite; pub mod text; pub mod tile; pub mod variable; +pub mod note; +pub mod tune; pub mod test_omnibus; +pub use blip::Blip; pub use colour::Colour; pub use dialogue::Dialogue; pub use ending::Ending; @@ -36,6 +42,7 @@ pub use room::Room; pub use sprite::Sprite; pub use text::*; pub use tile::Tile; +pub use tune::Tune; pub use variable::Variable; #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/src/mock.rs b/src/mock.rs index 67bc057..3102199 100644 --- a/src/mock.rs +++ b/src/mock.rs @@ -204,7 +204,8 @@ pub mod item { ], name: Some("key".to_string()), dialogue_id: Some("2".to_string()), - colour_id: None + colour_id: None, + blip: None, } } } @@ -227,6 +228,7 @@ pub fn item() -> Item { name: Some("door".to_string()), dialogue_id: Some("2".to_string()), colour_id: None, + blip: None, } } @@ -530,14 +532,19 @@ pub fn room() -> Room { id: "undefined".to_string(), }], walls: None, + tune: None, } } pub fn game_default() -> Game { Game { name: "Write your game's title here".to_string(), - version: Some(Version { major: 7, minor: 10 }), + version: Some(Version { major: 8, minor: 4 }), + version_major: Some(8), + version_minor: Some(12), room_format: Some(RoomFormat::CommaSeparated), + dialogue_compatibility: Some(0), + text_mode: Some(0), room_type: RoomType::Room, font: Font::AsciiSmall, custom_font: None, @@ -829,8 +836,9 @@ pub fn game_default() -> Game { exits: vec![], endings: vec![], walls: None, + tune: None, }], - tiles: vec![self::tile_default()], + tiles: vec![tile_default()], sprites: vec![ Sprite { id: "A".to_string(), @@ -878,6 +886,7 @@ pub fn game_default() -> Game { name: Some("tea".to_string()), dialogue_id: Some("1".to_string()), colour_id: None, + blip: None, }, item::key() ], @@ -903,6 +912,8 @@ pub fn game_default() -> Game { id: "a".to_string(), initial_value: "42".to_string(), }], + tunes: vec![], + blips: vec![], font_data: None, line_endings_crlf: false } diff --git a/src/room.rs b/src/room.rs index a2e1943..2f5f13f 100644 --- a/src/room.rs +++ b/src/room.rs @@ -24,6 +24,7 @@ pub struct Room { pub endings: Vec, /// old method of handling walls - a comma-separated list of tile IDs pub walls: Option>, + pub tune: Option, } impl Room { @@ -45,6 +46,13 @@ impl Room { None => "".to_string(), } } + + fn tune_line(&self) -> String { + match &self.tune { + Some(id) => optional_data_line("TUNE", Some(id.clone())), + None => "".to_string(), + } + } } impl From for Room { @@ -59,11 +67,14 @@ impl From for Room { let mut exits: Vec = Vec::new(); let mut endings: Vec = Vec::new(); let mut walls = None; + let mut tune = None; loop { let last_line = lines.pop().unwrap(); - if last_line.starts_with("WAL") { + if last_line.starts_with("TUNE") { + tune = Some(last_line.replace("TUNE ", "")); + } else if last_line.starts_with("WAL") { let last_line = last_line.replace("WAL ", ""); let ids: Vec<&str> = last_line.split(',').collect(); walls = Some(ids.iter().map(|&id| id.to_string()).collect()); @@ -154,6 +165,7 @@ impl From for Room { exits, endings, walls, + tune, } } } @@ -213,7 +225,7 @@ impl Room { } format!( - "{} {}\n{}{}{}{}{}{}{}", + "{} {}\n{}{}{}{}{}{}{}{}", room_type.to_string(), self.id, tiles, @@ -222,7 +234,8 @@ impl Room { items, exits, endings, - self.palette_line() + self.palette_line(), + self.tune_line(), ) } diff --git a/src/sprite.rs b/src/sprite.rs index 2a7adda..f5b307a 100644 --- a/src/sprite.rs +++ b/src/sprite.rs @@ -51,7 +51,7 @@ impl Sprite { format!("\n{}", lines.join("\n")) } } - + pub fn from_str(str: &str) -> Result { let mut lines: Vec<&str> = str.lines().collect(); @@ -100,9 +100,14 @@ impl Sprite { items.reverse(); - let animation_frames = animation_frames_from_str( - &lines[1..].join("\n") - ); + let animation_frames = match animation_frames_from_str(&lines[1..].join("\n")) { + Ok((frames, _warnings)) => { + frames + }, + Err(_e) => { + Vec::new() + }, + }; Ok(Sprite { id, diff --git a/src/test-resources/default.bitsy b/src/test-resources/default.bitsy index 4c46327..13f36ce 100644 --- a/src/test-resources/default.bitsy +++ b/src/test-resources/default.bitsy @@ -1,9 +1,9 @@ Write your game's title here -# BITSY VERSION 8.4 +# BITSY VERSION 8.12 ! VER_MAJ 8 -! VER_MIN 4 +! VER_MIN 12 ! ROOM_FORMAT 1 ! DLG_COMPAT 0 ! TXT_MODE 0 diff --git a/src/test_omnibus.rs b/src/test_omnibus.rs index 6a2922d..8077fbb 100644 --- a/src/test_omnibus.rs +++ b/src/test_omnibus.rs @@ -68,6 +68,7 @@ mod test { ); } + #[test] fn test_the_rest_of_your_life() {str(include_str!("test-resources/omnibus/the-rest-of-your-life.bitsy.txt"), "the-rest-of-your-life");} #[test] fn test_0053b32f() {str(include_str!("test-resources/omnibus/0053B32F.bitsy.txt"), "0053B32F");} #[test] fn test_00e45dc5() {str(include_str!("test-resources/omnibus/00E45DC5.bitsy.txt"), "00E45DC5");} #[test] fn test_010beb39() {str(include_str!("test-resources/omnibus/010BEB39.bitsy.txt"), "010BEB39");} @@ -520,4 +521,5 @@ mod test { #[test] fn test_fe6547de() {str(include_str!("test-resources/omnibus/FE6547DE.bitsy.txt"), "FE6547DE");} #[test] fn test_ff3857ae() {str(include_str!("test-resources/omnibus/FF3857AE.bitsy.txt"), "FF3857AE");} #[test] fn test_ff7bcf9c() {str(include_str!("test-resources/omnibus/FF7BCF9C.bitsy.txt"), "FF7BCF9C");} + #[test] fn test_goodbyes() {str(include_str!("test-resources/goodbye_summer.bitsy"), "goodbye_summer");} } diff --git a/src/tile.rs b/src/tile.rs index f26934b..0f517b2 100644 --- a/src/tile.rs +++ b/src/tile.rs @@ -109,9 +109,14 @@ impl From for Tile { } } - let animation_frames = animation_frames_from_str( - &lines[1..].join("\n") - ); + let animation_frames = match animation_frames_from_str(&lines[1..].join("\n")) { + Ok((animation_frames, _warnings)) => { + animation_frames + }, + Err(_) => { + Vec::new() + }, + }; Tile { id, diff --git a/src/tune.rs b/src/tune.rs new file mode 100644 index 0000000..94df58c --- /dev/null +++ b/src/tune.rs @@ -0,0 +1,41 @@ +use std::fmt; +use std::fmt::{Formatter}; +use crate::note::Note; + +/// this seems to be complete as of 23/10/2022. there's no ExtraSlow or whatever +#[derive(Debug, Clone, PartialEq)] +pub enum Tempo { + Slow, + Medium, + Fast, + ExtraFast, +} + +impl fmt::Display for Tempo { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", match self { + Tempo::Slow => "SLW", + Tempo::Medium => "MED", + Tempo::Fast => "FST", + Tempo::ExtraFast => "XFST", + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Measure { + notes: [Note; 32], +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Tune { + id: String, + // how many measures? should this be a slice? default game data tunes always have 8 measures + measures: Vec, + name: Option, + // todo key (this seems confusing, maybe just implement as a non-parsed string for now?) + tempo: Tempo, + // but what's the contents? enum? + arpeggio: Option, + // todo sqr (waveform? not sure. maybe just implement as a non-parsed string for now?) +}