diff --git a/.github/resources/banner.gif b/.github/resources/banner.gif index 35d6d5a..260cc9b 100644 Binary files a/.github/resources/banner.gif and b/.github/resources/banner.gif differ diff --git a/.github/resources/screenshot.png b/.github/resources/screenshot.png new file mode 100644 index 0000000..cfe32ef Binary files /dev/null and b/.github/resources/screenshot.png differ diff --git a/.github/resources/screenshot2.png b/.github/resources/screenshot2.png new file mode 100644 index 0000000..c7f4677 Binary files /dev/null and b/.github/resources/screenshot2.png differ diff --git a/.github/resources/screenshots.png b/.github/resources/screenshots.png deleted file mode 100644 index 98a686a..0000000 Binary files a/.github/resources/screenshots.png and /dev/null differ diff --git a/.github/workflows/check-test-format.yaml b/.github/workflows/check.yaml similarity index 59% rename from .github/workflows/check-test-format.yaml rename to .github/workflows/check.yaml index a1e3f61..f643406 100644 --- a/.github/workflows/check-test-format.yaml +++ b/.github/workflows/check.yaml @@ -1,11 +1,11 @@ # shoutout @wermipls -name: check + test + format +name: check on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: windows: @@ -17,13 +17,16 @@ jobs: with: toolchain: nightly - - name: run cargo check + - name: install required rustup components + run: | + rustup component add rustfmt --toolchain nightly + rustup target add x86_64-pc-windows-gnu + + - name: check run: cargo check - - name: run unit tests + - name: test run: cargo test --all -- --nocapture - - name: verify code is formatted - run: | - rustup component add rustfmt --toolchain nightly - cargo fmt --all -- --check + - name: fmt + run: cargo fmt --all -- --check diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ab37fc6..f58547d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,7 +26,7 @@ jobs: # # This is disgusting - need to assume nothing else in Cargo.toml has # "version = x.x.x" from start of line - $project_version = cat .\cascade_cli\Cargo.toml | grep -oE '^version = \"?[0-9]+\.[0-9]+\.[0-9]+\"' | grep -oE "[0-9]+\.[0-9]+\.[0-9]+" + $project_version = cat .\cascade_app\Cargo.toml | grep -oE '^version = \"?[0-9]+\.[0-9]+\.[0-9]+\"' | grep -oE "[0-9]+\.[0-9]+\.[0-9]+" "project_version=$project_version" | Out-File -FilePath $env:GITHUB_ENV -Append - name: install nightly rust toolchain @@ -34,12 +34,16 @@ jobs: with: toolchain: nightly + - name: install required rustup components + run: | + rustup component add rustfmt --toolchain nightly + rustup target add x86_64-pc-windows-gnu + - name: cargo build - run: cargo build --release + run: cargo build --target x86_64-pc-windows-gnu --release - name: create release uses: softprops/action-gh-release@v1 with: generate_release_notes: true - # Probably a better way to do this - files: ./target/release/cascade.exe + files: ./target/x86_64-pc-windows-gnu/release/cascade.exe diff --git a/.justfile b/.justfile new file mode 100644 index 0000000..b995f43 --- /dev/null +++ b/.justfile @@ -0,0 +1,13 @@ +default: check test fmt + +check: + cargo check + +test: + cargo test --all -- --nocapture + +fmt: + cargo fmt --all -- --check + +build-windows: + cargo build --target x86_64-pc-windows-gnu --release diff --git a/Cargo.toml b/Cargo.toml index ee8bb2e..f595aeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["cascade", "cascade_cli", "cascade_gui"] +members = ["cascade", "cascade_app"] resolver = "2" diff --git a/README.md b/README.md index e8b6422..ded0f3c 100644 --- a/README.md +++ b/README.md @@ -1,198 +1,129 @@
- +
- 🌊 | a thug pro trickset copier. + A bulk save modifier for THUG Pro.
-# `πŸ“Έ screenshots` +## `πŸ“Έ screenshots` -![](.github/resources/screenshots.png) + -# `⏬ download` +## `πŸ“– table of contents` -download `cascade.exe` from -[here](https://github.com/1borgy/cascade/releases/latest). +* [installation](#installation) +* [usage](#usage) +* [backups](#backups) +* [contact](#contact) +* [faq](#faq) +* [shoutouts](#shoutouts) +* [development](#development) -# `🀠 usage` +## `⏬ installation` ->⚠️ **warning** ⚠️ -> -> please make a manual backup of your saves! i've tested this along with a few community -> members and have yet to observe any issues, but your results may vary, as cascade is -> still experimental software. +Download `cascade.exe` from [here](https://github.com/1borgy/cascade/releases/latest). -cascade will automatically detect your THUG Pro installation folder if it is in -`%localappdata%/THUG Pro/`. if you have it installed elsewhere, you will need to -[configure cascade](#configuration). +## `⚑️ usage` -**using cascade is very easy:** +> [!WARNING] +> I highly recommend making a manual backup of your saves! Though `cascade` has been rigorously +> tested against community CAS packs, the possibility of save corruption still exists. -**one-** click "set trickset" to tell cascade which save's trickset you want to use. +Upon opening Cascade, you will be greeted with three columns. -**two-** click "copy trickset to saves" to copy that trickset to all of your saves. +### `from` -it will tell you how many tricksets were successfully copied - if cascade can't -succesfully copy the trickset to a certain save, it won't touch that save file. this is -typically the case with corrupted saves, or saves from other games (like THUG1 saves). +The "from" column allows you to select the save to copy from. You may choose to copy trickset, +scales, or both. -if you have a save that is failing to be copied, or want to help contribute to cascade, -you can [help the project](#help-me)! +### `to` -# `πŸ’Ύ backups` +The "to" column allows you to select which saves to copy to. -if you want to revert copying a trickset, or the saves were corrupted for some reason, -cascade stores backups at `THUG Pro/.cascade/` every time it copies tricksets. each -entry in this folder is marked with a date and time so you know when it was backed -up. simply pick which backup you want to use and drag its contents into your -`THUG Pro/Save/` folder. +Cascade will automatically detect your save folder at `%localappdata%/THUG Pro/Save`. +If you have it installed elsewhere, or want to copy saves to a different folder, you will need to +select the desired folder manually. -# `πŸ™‹ frequently asked questions` +### `queue` -## why do i get a message saying "SmartScreen prevented an unrecognized app from starting" when trying to run cascade? +The "queue" column shows which save files are currently queued to be modified, as well as their +most recent modification status. Upon pressing the start (▢️) button, all saves in the queue will +be backed up then modified in-place. -this message basically means that i don't have -the certificate required to become a "recognized publisher". +If modification of a save is successful, it will turn green. If modification of a save results in +an error, it will turn red. If you encounter an error while modifying saves, please +[contact me](#contact). -these certificates -[cost hundreds or even thousands of dollars](https://signmycode.com/ev-code-signing), so -i don't plan on ever getting one. +> [!TIP] +> Use `ctrl+-` and `ctrl+=` to resize the UI. -## what does β€œ\[... path\] is not set” mean? +> [!TIP] +> Create an empty file named `cascade.toml` in the same directory as the executable to use a +> portable install. Otherwise, cascade will store files at `%localappdata%/cascade`. -this means you are missing at least one of the path entries in the cascade config. -see [configuration](#configuration). +## `πŸ’Ύ backups` -## how can i copy my trickset to only some saves? +If you want to revert a cascade run, backups are stored at `%localappdata%/cascade/backup`. +Simply pick which backup you want to use and drag its contents into your saves folder. +Each entry in this folder is labeled with the date and time of modification. -currently, you can make another folder, put the saves you want to copy in that folder, -then [configure](#configuration) your `thugpro saves` path to that folder instead. +## `πŸ“£ contact` -## how can i use multiple tricksets and swap between them? +Please direct any questions or concerns towards `@1borgy` on Discord. If you encounter an error, +please send the cascade log file, which you may find at `%localappdata%/cascade/cascade.log`. -i would recommend having a separate folder of tricksets and swapping between them with -the β€œset trickset” button. you could also [configure](#configuration) your `trickset` -path to swap between them, if that feels better for some reason. +## `πŸ™‹ frequently asked questions` -## why do the file modification times on my saves not change? +### Why do I get a message saying "SmartScreen prevented an unrecognized app from starting" when trying to run cascade? -during development, i found it annoying that copying tricksets would change the order -your saves show up in the β€œload save” menu, so by default, cascade rewrites the file -modification time to what it was before copying. +To put it simply, this message means that I don't have the certificate required to become a "recognized publisher". -i realize this probably shouldn't be default behavior, so please -[yell at me](#feedback) if you want that to be configurable. +These certificates [cost hundreds of dollars](https://signmycode.com/ev-code-signing), so I don't plan on getting one. -## why do not all of my saves copy successfully? +### Do I have to close THUG Pro to copy the trickset to my saves? -if a save is not copied succesfully, it's likely either a corrupted save or a save from -a different game (e.g. THUG1). +Save files are reloaded when entering the "Load Skater" menu, so you do not have to relaunch. -i tested a CAS pack with nearly 300 saves, and only one of them failed. the one that -failed to copy crashed THUG Pro when i tried to open it, so it's safe to say that CAS -is not a valid THUG Pro CAS anyways. +### Why do the file modification times on my saves not change? -## do i have to close thug pro to copy the trickset to my saves? +This was a design decision I made during development, to preserve ordering of save files. +If you'd like this to be configurable, please [contact me](#contact). -from my testing, you don't need to close thug pro. the save files are re-loaded when -loading a new skater. +## `✨ shoutouts` -# `πŸ“’ you can help cascade!` +Cascade would not exist without the following people, so shoutout to them: -please reach out to me on discord **("borgy" in most THPS servers)** with any -questions or comments, especially if you have any issues getting cascade to work. - -if you run into an issue, please send me the cascade logs -(you can find these at `THUG Pro/.cascade/cascade.log`) and any CAS that is failing so -i can help diagnose the issue. - -i'd also love to hear general [feedback](#i-hate-cascade-it-sucks)! i already have a -[few ideas for improvements](#thinking-emoji). - -## feedback 🀬 - -```diff -! i want a scale copier! -! i want a save selector for trickset copying! -! i want a cas randomizer! (so do i!) -! i want rotating backups they take up too much room! -! trickset copying is too slow!!! 🀬 -! i want a portable install! -! i want a rotating log file! -! i want a configurable source/destination folder for saves! - -+ what else do you want to see in cascade? -+ how can the user interface be improved? -+ is the UI's performance poor on your system? -+ etc. -``` - -## things i've thought about πŸ˜”πŸ’­ - -see [github issues](https://github.com/1borgy/cascade/issues). - -# `βš™οΈ configuration` - -## thugpro saves - -this path refers to the folder that cascade reads saves from and copies saves back -to. - -by default, cascade autodetects THUG Pro's install folder if it exists at -`%localappdata%/THUG Pro/` (if there is another commonly installed location -[let me know](#help-me)!). - -if your THUG Pro is installed elsewhere, you will need to tell cascade where to look -for saves. click on the `config` tab and select the path for `thugpro saves`. this -path should point to the `THUG Pro/Save/` folder, not the base `THUG Pro/` folder. - -## backups - -this path refers to the folder that cascade stores backups in. - -by default, cascade will store backups in the `THUG Pro/.cascade/backup/` folder. - -## trickset - -this path refers to the file cascade uses as the reference trickset when copying a -trickset to saves. - -by default, cascade will store the trickset at `THUG Pro/.cascade/trickset.SKA`. - -## portable install? - -by default, cascade stores all its required files in `THUG Pro/.cascade/`. - -if you prefer a portable install, you can configure these paths to be in the same -folder as the executable. - -currently the cascade config (`cascade.toml`) cannot be moved from this folder, but -that can change [if there is demand for it](#help-me)! - -# `βœ¨πŸ’« shoutouts πŸ’«βœ¨` - -cascade would not exist without the following people, so shoutout to them: - -- **source** 🧠 for the great work on [castool](https://castool.xyz) and for giving me +- **source** 🧠 for their great work on [castool](https://castool.xyz) and for giving me pointers regarding CAS format -- **[@chc](https://github.com/chc)** 🧠 for the great work on the +- **[@chc](https://github.com/chc)** 🧠 for their great work on the [save editor](http://save-editor.thmods.com/#/manage_save) and [THPS.API](https://github.com/chc/thps.api/tree/master) - **[@c4marilla](https://github.com/c4marilla)** πŸ¦† for being the first beta tester, making the project logo and banner, and being appointed cascade project manager ✨ -- **retro** πŸ¦† for being an early beta tester +- **retro**, **judy**, and **f1shy** πŸ¦† for being beta testers + +- **cdot** πŸ›Ή for releasing their CAS pack, which is used for automated integration testing + +- **[@wermipls](https://github.com/wermipls)** πŸͺ± for helping with GitHub actions + +- **[@catppuccin](https://github.com/catppuccin/catppuccin)** πŸˆβ€β¬› for providing cascade's color palette -- **cdot** πŸ›Ή for releasing his giant cas pack, which i'm using for automated - integration testing +- **[@iced-rs](https://github.com/iced-rs/iced)** 🧊 for powering cascade's UI -- **[@wermipls](https://github.com/wermipls)** πŸͺ± for helping with github actions +# `πŸ› οΈ development` -- **[@catppuccin](https://github.com/catppuccin/catppuccin)** πŸˆβ€β¬› for - [1] blessing my eyes while i waste away in vim and [2] being the color palette used as - a reference for cascade's themes +Building cascade requires nightly rust. -- **[@iced-rs](https://github.com/iced-rs/iced)** 🧊 for powering cascade's ui - and making me not hate frontend that much +```bash +rustup toolchain install nightly +rustup default nightly +``` + +To cross-compile for windows: + +```bash +cargo build --target x86_64-pc-windows-gnu --release +``` diff --git a/cascade/Cargo.toml b/cascade/Cargo.toml index a9c3bca..5122f11 100644 --- a/cascade/Cargo.toml +++ b/cascade/Cargo.toml @@ -1,20 +1,16 @@ [package] name = "cascade" -version = "0.2.0" +version = "0.3.0" edition = "2021" [dependencies] byteorder = "1.4.3" count-write = "0.1.0" crc = "3.0.1" -directories = "5.0.1" filetime = "0.2.21" -lazy_static = "1.4.0" +indexmap = "2.5.0" log = "0.4.17" -rayon = "1.8.0" +ron = "0.8.1" serde = { version = "1.0.190", features = ["rc", "derive"] } -serde_yaml = "0.9.27" thiserror = "1.0.50" -time = "0.3.30" toml = "0.8.6" -walkdir = "2.3.3" diff --git a/cascade/src/actions/backup.rs b/cascade/src/actions/backup.rs deleted file mode 100644 index 304c5bc..0000000 --- a/cascade/src/actions/backup.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -use time; - -use super::error::ActionError; - -pub fn backup>( - backup_dir: P, - saves_dir: P, -) -> Result<(), ActionError> { - let backup_dir = backup_dir.as_ref(); - let saves_dir = saves_dir.as_ref(); - - let datetime = time::OffsetDateTime::now_local().unwrap_or({ - log::warn!("could not get local timezone; using utc"); - time::OffsetDateTime::now_utc() - }); - - let subdir_name = format!( - "{:04}-{:02}-{:02}T{:02}-{:02}-{:02}", - datetime.year(), - u8::from(datetime.month()), - datetime.day(), - datetime.hour(), - datetime.minute(), - datetime.second() - ); - - let mut subdir = PathBuf::from(backup_dir); - subdir.push(subdir_name); - - fs::create_dir_all(&subdir)?; - - // TODO: use SaveFile here since it's lazy now - for file in fs::read_dir(saves_dir)? { - // why him so confused ?? - let file = file?; - let file_path = file.path(); - - if file.file_type()?.is_file() { - if let Some(extension) = file.path().extension() { - if extension == "SKA" { - // holy shit let it stop im so sorry - // just use .filter() or something u moron - if let Some(file_name) = file_path.file_name() { - let mut backup_file_path = subdir.clone(); - backup_file_path.push(file_name); - - log::info!( - "backing up {:?} to {:?}", - file_path, - backup_file_path - ); - - fs::copy(file_path, backup_file_path)?; - } - } - } - } - } - - Ok(()) -} diff --git a/cascade/src/actions/copy_trickset.rs b/cascade/src/actions/copy_trickset.rs deleted file mode 100644 index 5f8e9c3..0000000 --- a/cascade/src/actions/copy_trickset.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::{ - fmt::Display, - path::Path, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }, -}; - -use rayon::prelude::ParallelIterator; - -use crate::{ - actions::{backup::backup, error::ActionError}, - mutations::{self, Mutation}, - save::{SaveCollection, SaveFile, SaveFileExtension}, -}; - -fn try_result(result: Result) -> Option -where - E: Display, -{ - match result { - Err(err) => { - log::info!("error: {}", err); - None - } - Ok(v) => Some(v), - } -} - -pub fn copy_trickset + Sync>( - trickset_path: P, - backup_dir: P, - saves_dir: P, -) -> Result<(usize, usize), ActionError> { - backup(&backup_dir, &saves_dir)?; - - let trickset_file = SaveFile::at_path(&trickset_path)?; - - log::info!( - "generating trickset mutation from \"{}\"", - trickset_file.filename() - ); - let trickset_mutation = - mutations::Trickset::from_save(&trickset_file.load_content()?)?; - - let num_successful = Arc::new(AtomicUsize::new(0)); - - let saves = SaveCollection::at_dir(&saves_dir)? - .filter_extension(SaveFileExtension::SKA); - - let num_all_saves = saves.len(); - - // TODO: move this to functions - // TODO: please do it quickly - // TODO: it's so messy - // TODO: - // TODO: please - saves.par_iter().for_each(|save| { - try_result(save.load_content()).and_then(|content| { - match trickset_mutation.mutate(content) { - Ok(new_content) => { - log::info!( - "successfully copied trickset to save \"{}\"", - save.filename() - ); - - try_result( - save.with_dir(&saves_dir).write_content(&new_content), - ) - .and_then(|_| { - num_successful.fetch_add(1, Ordering::SeqCst); - None::<()> - }); - } - Err(err) => { - log::error!( - "could not copy trickset to save \"{}\": {}", - save.filename(), - err - ); - } - }; - - None::<()> - }); - }); - - for save in saves.iter() { - save.overwrite_metadata()?; - } - - Ok((num_successful.load(Ordering::SeqCst), num_all_saves)) -} diff --git a/cascade/src/actions/error.rs b/cascade/src/actions/error.rs deleted file mode 100644 index bad1b6b..0000000 --- a/cascade/src/actions/error.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::{backtrace::Backtrace, fmt::Debug, io}; - -use thiserror::Error; - -use crate::{mutations::MutationError, save::SaveError}; - -#[derive(Error, Debug)] -pub enum ActionError { - #[error("a mutation failed: {source}")] - Mutation { - #[from] - source: MutationError, - backtrace: Backtrace, - }, - - #[error("a save operation failed: {source}")] - Save { - #[from] - source: SaveError, - backtrace: Backtrace, - }, - - #[error("an io error occurred: {source}")] - Io { - #[from] - source: io::Error, - backtrace: Backtrace, - }, -} diff --git a/cascade/src/actions/mod.rs b/cascade/src/actions/mod.rs deleted file mode 100644 index ee726cf..0000000 --- a/cascade/src/actions/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod backup; -mod copy_trickset; -mod error; -mod set_trickset; - -pub use backup::backup; -pub use copy_trickset::copy_trickset; -pub use error::ActionError; -pub use set_trickset::set_trickset; diff --git a/cascade/src/actions/set_trickset.rs b/cascade/src/actions/set_trickset.rs deleted file mode 100644 index 54f4468..0000000 --- a/cascade/src/actions/set_trickset.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::{fs, path::Path}; - -use super::error::ActionError; - -pub fn set_trickset>( - trickset_path: P, - selected_path: P, -) -> Result<(), ActionError> { - log::info!( - "setting trickset at {:?} to {:?}", - trickset_path.as_ref(), - selected_path.as_ref() - ); - - fs::copy(selected_path, trickset_path)?; - - Ok(()) -} diff --git a/cascade/src/crc32.rs b/cascade/src/crc32.rs index 538081f..b5aa005 100644 --- a/cascade/src/crc32.rs +++ b/cascade/src/crc32.rs @@ -1,8 +1,9 @@ +use std::cell::LazyCell; + use crc::{Algorithm, Crc}; -use lazy_static::lazy_static; -lazy_static! { - static ref CRC_ALG: Algorithm = Algorithm { +const CRC: LazyCell> = LazyCell::new(|| { + Crc::::new(&Algorithm { width: 32, poly: 0x04c11db7, init: 0xffffffff, @@ -11,10 +12,9 @@ lazy_static! { xorout: 0x0000, check: 0xaee7, residue: 0x0000, - }; - static ref CRC: Crc = Crc::::new(&CRC_ALG); -} + }) +}); -pub fn get_checksum_for_bytes(bytes: &Vec) -> u32 { +pub fn checksum(bytes: &Vec) -> u32 { CRC.checksum(bytes.as_slice()) } diff --git a/cascade/src/lib.rs b/cascade/src/lib.rs index 8485531..fcb2d28 100644 --- a/cascade/src/lib.rs +++ b/cascade/src/lib.rs @@ -1,7 +1,5 @@ -#![feature(error_generic_member_access, try_blocks)] -pub mod actions; -mod crc32; -mod lookup; -pub mod mutations; +#![feature(error_generic_member_access)] +pub mod crc32; +pub mod lut; +pub mod qb; pub mod save; -pub mod structure; diff --git a/cascade/src/lookup.rs b/cascade/src/lookup.rs deleted file mode 100644 index 7cc78f6..0000000 --- a/cascade/src/lookup.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::collections::HashMap; - -use lazy_static::lazy_static; -use serde::{Deserialize, Serialize}; - -// I feel like this whole module is messy but it gets the job done ig - -const CHECKSUM_LOOKUP_BYTES: &[u8] = - include_bytes!("../../resources/checksum_lookup.yaml"); -const COMPRESSED_LOOKUP_BYTES: &[u8] = - include_bytes!("../../resources/compressed_lookup.yaml"); - -lazy_static! { - static ref CHECKSUM_LOOKUP: HashMap = load_checksum_lookup(); - static ref COMPRESSED_LOOKUP: CompressedLookupTable = - load_compressed_lookup(); - static ref REVERSE_CHECKSUM_LOOKUP: HashMap = - create_reverse_checksum_lookup(&CHECKSUM_LOOKUP); - static ref REVERSE_COMPRESSED_LOOKUP: ReverseCompressedLookupTable = - create_reverse_compressed_lookup(&COMPRESSED_LOOKUP); -} - -#[derive(Serialize, Deserialize)] -struct CompressedLookupTable { - byte: Vec, - word: Vec, -} - -struct ReverseCompressedLookupTable { - byte: HashMap, - word: HashMap, -} - -// .expect() is lazy here but we can guarantee it's valid YAML since we provide it -// (and is critical for execution) -fn load_checksum_lookup() -> HashMap { - serde_yaml::from_slice(CHECKSUM_LOOKUP_BYTES) - .expect("could not load checksum lookup table!") -} - -fn create_reverse_checksum_lookup( - checksum_lookup: &HashMap, -) -> HashMap { - let mut reverse_lookup = HashMap::new(); - - for (checksum, name) in checksum_lookup.iter() { - reverse_lookup.insert(name.clone(), *checksum as u32); - } - - reverse_lookup -} - -fn load_compressed_lookup() -> CompressedLookupTable { - serde_yaml::from_slice(COMPRESSED_LOOKUP_BYTES) - .expect("could not load compressed lookup table!") -} - -fn create_reverse_compressed_lookup( - compressed_lookup: &CompressedLookupTable, -) -> ReverseCompressedLookupTable { - let mut byte = HashMap::new(); - let mut word = HashMap::new(); - - for (compressed_index, name) in compressed_lookup.byte.iter().enumerate() { - byte.insert(name.clone(), compressed_index as u8); - } - - for (compressed_index, name) in compressed_lookup.word.iter().enumerate() { - word.insert(name.clone(), compressed_index as u16); - } - - ReverseCompressedLookupTable { byte, word } -} - -pub fn checksum(checksum: u32) -> Option { - CHECKSUM_LOOKUP.get(&(checksum as usize)).cloned() -} - -pub fn compressed8(byte: u8) -> Option { - COMPRESSED_LOOKUP.byte.get(byte as usize).cloned() -} - -pub fn compressed16(word: u16) -> Option { - COMPRESSED_LOOKUP.word.get(word as usize).cloned() -} - -pub fn reverse_checksum(name: &String) -> Option { - REVERSE_CHECKSUM_LOOKUP.get(name).cloned() -} - -pub fn reverse_compressed16(name: &String) -> Option { - REVERSE_COMPRESSED_LOOKUP.word.get(name).cloned() -} - -pub fn reverse_compressed8(name: &String) -> Option { - REVERSE_COMPRESSED_LOOKUP.byte.get(name).cloned() -} diff --git a/cascade/src/lut.rs b/cascade/src/lut.rs new file mode 100644 index 0000000..edb7284 --- /dev/null +++ b/cascade/src/lut.rs @@ -0,0 +1,166 @@ +use std::{collections::HashMap, result, str::Utf8Error}; + +use ron::de::SpannedError; +use serde::{Deserialize, Serialize}; + +use crate::qb; + +const LUT_THUGPRO_BYTES: &[u8] = + include_bytes!("../../resources/lut/thug_pro.ron"); + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("ron deserialization error: {0}")] + Spanned(#[from] SpannedError), + + #[error("utf8 decoding error: {0}")] + Utf8(#[from] Utf8Error), + + #[error("name not found in LUT: {0}")] + NameNotFound(String), + + #[error("id not found in LUT: {0:?}")] + IdNotFound(qb::Id), +} + +pub type Result = result::Result; + +#[derive(Serialize, Deserialize)] +struct LutFile { + checksum: HashMap, + compressed_8: Vec, + compressed_16: Vec, +} + +impl LutFile { + pub fn from_bytes(bytes: &[u8]) -> Result { + let as_str = std::str::from_utf8(bytes)?; + Ok(Self::from_str(as_str)?) + } + + pub fn from_str(s: &str) -> Result { + Ok(ron::from_str(s)?) + } + + pub fn thug_pro() -> Result { + LutFile::from_bytes(LUT_THUGPRO_BYTES) + } +} + +#[derive(Debug, Clone)] +struct NameLut { + checksum: HashMap, + compressed_8: HashMap, + compressed_16: HashMap, +} + +impl From<&LutFile> for NameLut { + fn from(file: &LutFile) -> Self { + Self { + checksum: file.checksum.clone(), + compressed_8: file + .compressed_8 + .iter() + .enumerate() + .map(|(index, name)| (name.clone(), index as u8)) + .collect(), + compressed_16: file + .compressed_16 + .iter() + .enumerate() + .map(|(index, name)| (name.clone(), index as u16)) + .collect(), + } + } +} + +impl NameLut { + pub fn lookup(&self, name: &impl ToString) -> Option { + let name = name.to_string(); + + if let Some(checksum) = self.checksum.get(&name) { + Some(qb::Id::Checksum(*checksum)) + } else if let Some(compressed8) = self.compressed_8.get(&name) { + Some(qb::Id::Compressed8(*compressed8)) + } else if let Some(compressed16) = self.compressed_16.get(&name) { + Some(qb::Id::Compressed16(*compressed16)) + } else { + None + } + } +} + +#[derive(Debug, Clone)] +struct IdLut { + checksum: HashMap, + compressed_8: HashMap, + compressed_16: HashMap, +} + +impl From<&LutFile> for IdLut { + fn from(file: &LutFile) -> Self { + Self { + checksum: file + .checksum + .iter() + .map(|(name, checksum)| (*checksum, name.clone())) + .collect(), + compressed_8: file + .compressed_8 + .iter() + .cloned() + .enumerate() + .map(|(index, name)| (index as u8, name)) + .collect(), + compressed_16: file + .compressed_16 + .iter() + .cloned() + .enumerate() + .map(|(index, name)| (index as u16, name)) + .collect(), + } + } +} + +impl IdLut { + pub fn lookup(&self, id: qb::Id) -> Option<&String> { + match id { + qb::Id::Checksum(checksum) => self.checksum.get(&checksum), + qb::Id::Compressed8(index) => self.compressed_8.get(&index), + qb::Id::Compressed16(index) => self.compressed_16.get(&index), + qb::Id::None => None, + } + } +} + +#[derive(Debug, Clone)] +pub struct Lut { + name: NameLut, + id: IdLut, +} + +impl From for Lut { + fn from(file: LutFile) -> Self { + Self { + name: NameLut::from(&file), + id: IdLut::from(&file), + } + } +} + +impl Lut { + pub fn by_name(&self, name: &impl ToString) -> Result { + self.name + .lookup(name) + .ok_or(Error::NameNotFound(name.to_string())) + } + + pub fn by_id(&self, id: qb::Id) -> Result<&String> { + self.id.lookup(id).ok_or(Error::IdNotFound(id)) + } + + pub fn thug_pro() -> Result { + Ok(Lut::from(LutFile::thug_pro()?)) + } +} diff --git a/cascade/src/mutations/error.rs b/cascade/src/mutations/error.rs deleted file mode 100644 index 050837e..0000000 --- a/cascade/src/mutations/error.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::{backtrace::Backtrace, fmt::Debug}; - -use thiserror::Error; - -use crate::structure::{self, StructureError}; - -#[derive(Error, Debug)] -pub enum MutationError { - #[error("a structure operation failed: {source}")] - Structure { - #[from] - source: StructureError, - backtrace: Backtrace, - }, - - #[error("symbol with checksum {0} could not be found")] - SymbolNotFound(structure::NameChecksum), - - #[error("mutation spec is invalid; cannot start with leaf")] - SpecRootIsLeaf, - - #[error("mutation spec is invalid; root must be at top")] - SpecNodeIsRoot, -} diff --git a/cascade/src/mutations/mod.rs b/cascade/src/mutations/mod.rs deleted file mode 100644 index febfa62..0000000 --- a/cascade/src/mutations/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::{mutations::spec::StructureMutation, save::SaveContent}; - -mod error; -mod spec; -mod trickset; - -pub use error::MutationError; -pub use trickset::Trickset; - -pub trait Mutation { - fn data_mutation(&self) -> Option<&StructureMutation>; - fn summary_mutation(&self) -> Option<&StructureMutation>; - - fn mutate( - &self, - content: SaveContent, - ) -> Result { - let data = match self.data_mutation() { - Some(mutation) => Some(mutation.mutate_structure(content.data())?), - None => None, - }; - - let summary = match self.summary_mutation() { - Some(mutation) => { - Some(mutation.mutate_structure(content.summary())?) - } - None => None, - }; - - let content = - data.map(|data| content.with_data(data)).unwrap_or(content); - - let content = summary - .map(|summary| content.with_summary(summary)) - .unwrap_or(content); - - Ok(content) - } -} diff --git a/cascade/src/mutations/name.rs b/cascade/src/mutations/name.rs deleted file mode 100644 index e69de29..0000000 diff --git a/cascade/src/mutations/spec.rs b/cascade/src/mutations/spec.rs deleted file mode 100644 index 1fd670c..0000000 --- a/cascade/src/mutations/spec.rs +++ /dev/null @@ -1,308 +0,0 @@ -use std::{collections::HashMap, sync::Arc}; - -use super::error::MutationError; -use crate::structure::{ - NameChecksum, Structure, StructureError, Symbol, Value, -}; - -// We define two types of datatypes for structure mutation here: `spec` and `mutation`. -// -// A spec simply defines the format of which keys should be mutated, and which actions -// should be performed on those keys. It does not specify what values to copy. -// -// A mutation contains the same information, but also contains information about the -// values to set. -// -// A spec can be populated from a structure to create a mutation. The spec actions will -// be converted to mutation actions (or errors) using the values in the structure. For -// example, a `CopyOrDelete` spec action will either result in a `Set` or `Delete` -// mutation action depending on whether the given key is present. A `CopyRequired` will -// either result in a `Set` or `MutationError` depending on whether the given key is -// present. - -#[derive(Copy, Clone, Debug)] -pub enum SpecAction { - #[allow(dead_code)] - // TODO: i promise this will be used in the cas randomizer i swear!!!! - CopyOrRemove, - CopyRequired, -} - -impl SpecAction { - pub fn populate( - &self, - name_checksum: NameChecksum, - symbol: Option<&Symbol>, - ) -> Result { - match self { - SpecAction::CopyOrRemove => match symbol { - Some(symbol) => Ok(MutationAction::Set(symbol.clone())), - None => Ok(MutationAction::Remove), - }, - SpecAction::CopyRequired => match symbol { - Some(symbol) => Ok(MutationAction::Set(symbol.clone())), - None => Err(MutationError::SymbolNotFound(name_checksum)), - }, - } - } -} - -#[derive(Clone, Debug)] -pub struct SpecChildren { - children: HashMap, -} - -impl SpecChildren { - fn build(children: I) -> Result - where - I: IntoIterator, - { - Ok(SpecChildren { - children: children - .into_iter() - .map(|child| (child.name(), child)) - .collect(), - }) - } - - pub fn populate_from_structure( - &self, - structure: Arc, - ) -> Result { - Ok(MutationChildren { - children: self - .children - .iter() - .map(|(name, node)| try { - (*name, node.populate_from_symbol(structure.get(*name))?) - }) - .collect::>()?, - }) - } -} - -#[derive(Clone, Debug)] -pub enum SpecNode { - Node { - name_checksum: NameChecksum, - children: SpecChildren, - }, - Leaf { - name_checksum: NameChecksum, - action: SpecAction, - }, -} - -// Root -pub struct Spec { - children: SpecChildren, -} - -impl Spec { - pub fn build(children: I) -> Result - where - I: IntoIterator, - { - Ok(Self { - children: SpecChildren::build(children)?, - }) - } - - pub fn populate_from_structure( - &self, - structure: Arc, - ) -> Result { - Ok(StructureMutation { - children: self.children.populate_from_structure(structure)?, - }) - } -} - -impl SpecNode { - pub fn node(name: N, children: I) -> Result - where - I: IntoIterator, - N: TryInto, - { - Ok(SpecNode::Node { - name_checksum: name.try_into()?, - children: SpecChildren::build(children)?, - }) - } - - pub fn leaf(name: N, action: SpecAction) -> Result - where - N: TryInto, - { - Ok(SpecNode::Leaf { - name_checksum: name.try_into()?, - action, - }) - } - - pub fn name(&self) -> NameChecksum { - match self { - SpecNode::Node { - name_checksum, - children: _, - } => *name_checksum, - SpecNode::Leaf { - name_checksum, - action: _, - } => *name_checksum, - } - } - - pub fn populate_from_symbol( - &self, - symbol: Option<&Symbol>, - ) -> Result { - match self { - SpecNode::Node { - name_checksum, - children, - } => SpecNode::populate_node(symbol, *name_checksum, children), - SpecNode::Leaf { - name_checksum, - action, - } => SpecNode::populate_leaf(symbol, *name_checksum, action), - } - } - - fn populate_node( - symbol: Option<&Symbol>, - name_checksum: NameChecksum, - children: &SpecChildren, - ) -> Result { - match symbol { - Some(symbol) => Ok(MutationNode::Node { - name_checksum, - children: children - .populate_from_structure(symbol.try_as_struct()?)?, - }), - None => Err(MutationError::SymbolNotFound(name_checksum)), - } - } - - fn populate_leaf( - symbol: Option<&Symbol>, - name_checksum: NameChecksum, - action: &SpecAction, - ) -> Result { - Ok(MutationNode::Leaf { - name_checksum, - action: action.populate(name_checksum, symbol)?, - }) - } -} - -#[derive(Clone, Debug)] -pub enum MutationAction { - Set(Symbol), - Remove, -} - -#[derive(Clone, Debug)] -pub struct MutationChildren { - children: HashMap, -} - -impl MutationChildren { - pub fn mutate_structure( - &self, - structure: Arc, - ) -> Result, MutationError> { - let mut children = self.children.clone(); - - let existing_children = structure - .iter() - .map(|symbol| { - let name_checksum = symbol.name_checksum(); - - match children.remove(&name_checksum) { - Some(node) => { - Ok(node.mutate_symbol(structure.get(name_checksum))?) - } - None => Ok(Some(symbol.clone())), - } - }) - .collect::>, MutationError>>()?; - - let missing_children = children - .drain() - .map(|(_, node)| node.mutate_symbol(None)) - .collect::>, MutationError>>()?; - - Ok(existing_children - .into_iter() - .chain(missing_children) - .filter_map(|symbol| symbol) - .into_iter() - .collect()) - } -} - -#[derive(Clone, Debug)] -pub enum MutationNode { - Node { - name_checksum: NameChecksum, - children: MutationChildren, - }, - Leaf { - name_checksum: NameChecksum, - action: MutationAction, - }, -} - -impl MutationNode { - pub fn mutate_symbol( - &self, - symbol: Option<&Symbol>, - ) -> Result, MutationError> { - match self { - MutationNode::Node { - name_checksum: name, - children, - } => Ok(Some(MutationNode::mutate_node(symbol, *name, children)?)), - MutationNode::Leaf { - name_checksum: _, - action, - } => Ok(MutationNode::mutate_leaf(action)), - } - } - - pub fn mutate_node( - symbol: Option<&Symbol>, - name_checksum: NameChecksum, - children: &MutationChildren, - ) -> Result { - match symbol { - Some(symbol) => Ok(symbol.with_value(Value::Structure( - children.mutate_structure(symbol.try_as_struct()?)?, - ))), - // TODO: Should we add a structure if it does not exist? - None => Err(MutationError::SymbolNotFound(name_checksum)), - } - } - - pub fn mutate_leaf(action: &MutationAction) -> Option { - match action { - MutationAction::Set(inner) => Some(inner.clone()), - MutationAction::Remove => None, - } - } -} - -// Root -pub struct StructureMutation { - children: MutationChildren, -} - -impl StructureMutation { - pub fn mutate_structure( - &self, - structure: Arc, - ) -> Result, MutationError> { - self.children.mutate_structure(structure) - } -} diff --git a/cascade/src/mutations/trickset.rs b/cascade/src/mutations/trickset.rs deleted file mode 100644 index bddf792..0000000 --- a/cascade/src/mutations/trickset.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::sync::Arc; - -use super::{spec::StructureMutation, Mutation}; -use crate::{ - mutations::{ - error::MutationError, - spec::{Spec, SpecAction, SpecNode}, - }, - save::SaveContent, -}; - -pub struct Trickset { - data: StructureMutation, -} - -impl Trickset { - pub fn from_save(save: &SaveContent) -> Result { - let spec = Spec::build(vec![ - SpecNode::node( - "CustomSkater", - vec![ - (SpecNode::node( - "custom", - vec![ - (SpecNode::node( - "info", - vec![ - SpecNode::leaf( - "trick_mapping", - SpecAction::CopyRequired, - )?, - SpecNode::leaf( - "specials", - SpecAction::CopyRequired, - )?, - ], - )?), - ], - )?), - ], - )?, - SpecNode::node( - "StorySkater", - vec![SpecNode::leaf("tricks", SpecAction::CopyRequired)?], - )?, - ])?; - - Ok(Self { - data: spec.populate_from_structure(Arc::clone(&save.data))?, - }) - } -} - -impl Mutation for Trickset { - fn data_mutation(&self) -> Option<&StructureMutation> { - Some(&self.data) - } - - fn summary_mutation(&self) -> Option<&StructureMutation> { - None - } -} diff --git a/cascade/src/qb/error.rs b/cascade/src/qb/error.rs new file mode 100644 index 0000000..5deb875 --- /dev/null +++ b/cascade/src/qb/error.rs @@ -0,0 +1,29 @@ +use std::{fmt::Debug, io}; + +use crate::qb::Value; + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("io error: {0}")] + Io(io::ErrorKind), + + #[error("invalid symbol type {0}")] + InvalidType(u8), + + #[error("internal error: {0} is not implemented")] + NotImplemented(String), + + #[error( + "both checksum table lookup bits set in symbol type byte: {0:#02x}" + )] + BothChecksumBits(u8), + + #[error("expected value type {0}, got {1}")] + ExpectedValueType(String, Value), +} + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Io(value.kind()) + } +} diff --git a/cascade/src/qb/id.rs b/cascade/src/qb/id.rs new file mode 100644 index 0000000..b7abec9 --- /dev/null +++ b/cascade/src/qb/id.rs @@ -0,0 +1,46 @@ +use std::{ + fmt::Debug, + io::{Read, Write}, +}; + +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use serde::{Deserialize, Serialize}; + +use crate::qb::{Error, Kind}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum Id { + None, + Checksum(u32), + Compressed8(u8), + Compressed16(u16), +} + +impl Id { + pub fn read( + reader: &mut impl Read, + kind: Kind, + compressed_8: bool, + compressed_16: bool, + ) -> Result { + Ok(if kind == Kind::None { + Id::None + } else if compressed_8 { + Id::Compressed8(reader.read_u8()?) + } else if compressed_16 { + Id::Compressed16(reader.read_u16::()?) + } else { + Id::Checksum(reader.read_u32::()?) + }) + } + + pub fn write(&self, writer: &mut W) -> Result<(), Error> { + match self { + Id::Checksum(val) => writer.write_u32::(*val)?, + Id::Compressed8(val) => writer.write_u8(*val)?, + Id::Compressed16(val) => writer.write_u16::(*val)?, + Id::None => (), + } + Ok(()) + } +} diff --git a/cascade/src/qb/kind.rs b/cascade/src/qb/kind.rs new file mode 100644 index 0000000..27c7e46 --- /dev/null +++ b/cascade/src/qb/kind.rs @@ -0,0 +1,59 @@ +use std::fmt::Debug; + +use serde::{Deserialize, Serialize}; + +use crate::qb::Error; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Kind { + None, + Integer, + Float, + String, + LocalString, + Pair, + Vector, + QScript, + CFunction, + MemberFunction, + Structure, + StructurePointer, + Array, + Name, + I8, + I16, + U8, + U16, + ZeroInt, + ZeroFloat, +} + +impl TryFrom for Kind { + type Error = Error; + + fn try_from(v: u8) -> Result { + match v { + 0 => Ok(Kind::None), + 1 => Ok(Kind::Integer), + 2 => Ok(Kind::Float), + 3 => Ok(Kind::String), + 4 => Ok(Kind::LocalString), + 5 => Ok(Kind::Pair), + 6 => Ok(Kind::Vector), + 7 => Ok(Kind::QScript), + 8 => Ok(Kind::CFunction), + 9 => Ok(Kind::MemberFunction), + 10 => Ok(Kind::Structure), + 11 => Ok(Kind::StructurePointer), + 12 => Ok(Kind::Array), + 13 => Ok(Kind::Name), + 14 => Ok(Kind::I8), + 15 => Ok(Kind::I16), + 16 => Ok(Kind::U8), + 17 => Ok(Kind::U16), + 18 => Ok(Kind::ZeroInt), + 19 => Ok(Kind::ZeroFloat), + _ => Err(Error::InvalidType(v)), + } + } +} diff --git a/cascade/src/structure/mod.rs b/cascade/src/qb/mod.rs similarity index 51% rename from cascade/src/structure/mod.rs rename to cascade/src/qb/mod.rs index 40bcbd5..2521903 100644 --- a/cascade/src/structure/mod.rs +++ b/cascade/src/qb/mod.rs @@ -1,13 +1,13 @@ mod error; -mod name_checksum; +mod id; +mod kind; mod structure; mod symbol; -mod types; mod value; -pub use error::StructureError; -pub use name_checksum::NameChecksum; +pub use error::Error; +pub use id::Id; +pub use kind::Kind; pub use structure::Structure; pub use symbol::Symbol; -pub use types::Type; pub use value::Value; diff --git a/cascade/src/qb/structure.rs b/cascade/src/qb/structure.rs new file mode 100644 index 0000000..1f261f0 --- /dev/null +++ b/cascade/src/qb/structure.rs @@ -0,0 +1,122 @@ +use std::{ + fmt::Debug, + io::{Read, Write}, +}; + +use serde::{ser::SerializeSeq, Serialize, Serializer}; + +use super::Id; +use crate::qb::{Error, Kind, Symbol}; + +#[derive(Debug, Clone)] +pub struct Structure { + symbols: Vec, +} + +impl Serialize for Structure { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.len()))?; + + for symbol in &self.symbols { + seq.serialize_element(&symbol)?; + } + + seq.end() + } +} + +impl Structure { + pub fn new(symbols: Vec) -> Self { + // TODO: check duplicate symbols? + Self { + symbols, //: symbols + //.into_iter() + //.map(|symbol| (symbol.id, symbol)) + //.collect(), + } + } + + pub fn read(reader: &mut impl Read) -> Result { + let mut symbols = vec![]; + + while { + // do: + // Read symbol from the reader + let symbol = Symbol::read(reader)?; + let kind = symbol.kind; + + // while: + // The symbol is not none + match kind { + Kind::None => false, + _ => { + // Only push the symbol if it is non-none + symbols.push(symbol); + true + } + } + } {} + + Ok(Self::new(symbols)) + } + + pub fn write(&self, writer: &mut W) -> Result<(), Error> { + for symbol in &self.symbols { + symbol.write(writer)?; + } + + // Each structure is terminated with a none symbol + Symbol::none().write(writer)?; + + Ok(()) + } + + pub fn raw_bytes(&self) -> Result, Error> { + let mut bytes = vec![]; + self.write(&mut bytes)?; + + Ok(bytes) + } + + pub fn get(&self, id: Id) -> Option<&Symbol> { + self.symbols.iter().filter(|symbol| symbol.id == id).next() + } + + pub fn get_mut(&mut self, id: Id) -> Option<&mut Symbol> { + self.symbols + .iter_mut() + .filter(|symbol| symbol.id == id) + .next() + } + + pub fn len(&self) -> usize { + self.symbols.len() + } + + pub fn insert(&mut self, symbol: Symbol) -> Option { + match self.get_mut(symbol.id) { + Some(existing) => { + let ret = existing.clone(); + *existing = symbol; + Some(ret) + } + None => { + self.symbols.push(symbol); + None + } + } + } + + pub fn remove(&mut self, id: Id) { + self.symbols.retain(|symbol| symbol.id != id); + } +} + +impl FromIterator for Structure { + fn from_iter>(iter: T) -> Self { + Self::new(iter.into_iter().collect()) + } +} diff --git a/cascade/src/qb/symbol.rs b/cascade/src/qb/symbol.rs new file mode 100644 index 0000000..b3b5ec4 --- /dev/null +++ b/cascade/src/qb/symbol.rs @@ -0,0 +1,79 @@ +use std::{ + fmt::Debug, + io::{Read, Write}, +}; + +use byteorder::{ReadBytesExt, WriteBytesExt}; +use serde::Serialize; + +use super::Structure; +use crate::qb::{Error, Id, Kind, Value}; + +const CHECKSUM_LOOKUP_MASK_8: u8 = 1 << 7; +const CHECKSUM_LOOKUP_MASK_16: u8 = 1 << 6; + +#[derive(Debug, Clone, Serialize)] +pub struct Symbol { + pub kind: Kind, + pub id: Id, + pub value: Value, +} + +impl Symbol { + pub fn none() -> Self { + Self { + kind: Kind::None, + id: Id::None, + value: Value::None, + } + } + + pub fn structure(id: Id, structure: Box) -> Self { + Self { + kind: Kind::Structure, + id, + value: Value::Structure(structure), + } + } + + pub fn read(reader: &mut impl Read) -> Result { + let type_byte = reader.read_u8()?; + + // 8-bit / 16-bit mask in bits 6/7 + let type_byte_masked = + type_byte & (!CHECKSUM_LOOKUP_MASK_8) & (!CHECKSUM_LOOKUP_MASK_16); + + let use_lookup_8 = (type_byte & CHECKSUM_LOOKUP_MASK_8) > 0; + let use_lookup_16 = (type_byte & CHECKSUM_LOOKUP_MASK_16) > 0; + + // Error if both checksum bits are set + (!(use_lookup_8 && use_lookup_16)) + .then(|| ()) + .ok_or(Error::BothChecksumBits(type_byte))?; + + let kind = Kind::try_from(type_byte_masked)?; + + let id = Id::read(reader, kind, use_lookup_8, use_lookup_16)?; + + let value = Value::read(reader, kind)?; + let symbol = Symbol { kind, id, value }; + + Ok(symbol) + } + + pub fn write(&self, writer: &mut W) -> Result<(), Error> { + let type_byte = self.kind as u8; + + let type_byte_with_checksum_bits = match self.id { + Id::Compressed16(_) => type_byte | CHECKSUM_LOOKUP_MASK_16, + Id::Compressed8(_) => type_byte | CHECKSUM_LOOKUP_MASK_8, + _ => type_byte, + }; + + writer.write_u8(type_byte_with_checksum_bits)?; + self.id.write(writer)?; + self.value.write(writer)?; + + Ok(()) + } +} diff --git a/cascade/src/structure/value.rs b/cascade/src/qb/value.rs similarity index 60% rename from cascade/src/structure/value.rs rename to cascade/src/qb/value.rs index dce0d4c..4b754d6 100644 --- a/cascade/src/structure/value.rs +++ b/cascade/src/qb/value.rs @@ -1,15 +1,14 @@ use std::{ fmt::{self, Debug}, - io::{BufRead, Write}, - sync::Arc, + io::{Read, Write}, }; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; -use crate::structure::{Structure, StructureError, Type}; +use crate::qb::{Error, Kind, Structure}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub enum Value { None, U8(u8), @@ -23,8 +22,8 @@ pub enum Value { String(Vec), Pair(f32, f32), Vector(f32, f32, f32), - Structure(Arc), - Array(Type, Arc>), + Structure(Box), + Array(Kind, Vec), Name(u32), } @@ -35,15 +34,12 @@ impl fmt::Display for Value { } impl Value { - pub fn from_reader( - reader: &mut R, - symbol_type: Type, - ) -> Result { - Ok(match symbol_type { - Type::None => Value::None, - Type::Integer => Value::I32(reader.read_i32::()?), - Type::Float => Value::F32(reader.read_f32::()?), - Type::String | Type::LocalString => { + pub fn read(reader: &mut impl Read, kind: Kind) -> Result { + Ok(match kind { + Kind::None => Value::None, + Kind::Integer => Value::I32(reader.read_i32::()?), + Kind::Float => Value::F32(reader.read_f32::()?), + Kind::String | Kind::LocalString => { let mut bytes = vec![]; while { @@ -63,50 +59,47 @@ impl Value { Value::String(bytes) } // https://doc.rust-lang.org/reference/expressions.html#evaluation-order-of-operands - Type::Pair => Value::Pair( + Kind::Pair => Value::Pair( reader.read_f32::()?, reader.read_f32::()?, ), - Type::Vector => Value::Vector( + Kind::Vector => Value::Vector( reader.read_f32::()?, reader.read_f32::()?, reader.read_f32::()?, ), - Type::Structure => { - Value::Structure(Arc::new(Structure::from_reader(reader)?)) + Kind::Structure => { + Value::Structure(Box::new(Structure::read(reader)?)) } - Type::Array => { + Kind::Array => { // TODO this is code reuse w/ Symbol::deserialize let type_byte = reader.read_u8()?; - let symbol_type = Type::try_from(type_byte)?; + let kind = Kind::try_from(type_byte)?; let len = reader.read_u16::()?; let mut elements = vec![]; for _ in 0..len { - elements.push(Value::from_reader(reader, symbol_type)?) + elements.push(Value::read(reader, kind)?) } - Value::Array(symbol_type, Arc::new(elements)) + Value::Array(kind, elements) } - Type::Name => Value::Name(reader.read_u32::()?), - Type::I8 => Value::I8(reader.read_i8()?), - Type::I16 => Value::I16(reader.read_i16::()?), - Type::U8 => Value::U8(reader.read_u8()?), - Type::U16 => Value::U16(reader.read_u16::()?), - Type::ZeroInt => Value::ZeroInt, - Type::ZeroFloat => Value::ZeroFloat, + Kind::Name => Value::Name(reader.read_u32::()?), + Kind::I8 => Value::I8(reader.read_i8()?), + Kind::I16 => Value::I16(reader.read_i16::()?), + Kind::U8 => Value::U8(reader.read_u8()?), + Kind::U16 => Value::U16(reader.read_u16::()?), + Kind::ZeroInt => Value::ZeroInt, + Kind::ZeroFloat => Value::ZeroFloat, _ => { - return Err(StructureError::NotImplemented(format!( + return Err(Error::NotImplemented(format!( "deserializing symbol type {:?}", - symbol_type + kind ))) } }) } - pub fn write( - &self, - writer: &mut W, - ) -> Result<(), StructureError> { + pub fn write(&self, writer: &mut W) -> Result<(), Error> { match self { Value::U8(val) => writer.write_u8(*val)?, Value::U16(val) => writer.write_u16::(*val)?, @@ -129,8 +122,8 @@ impl Value { writer.write_f32::(*c)?; } Value::Structure(structure) => structure.write(writer)?, - Value::Array(symbol_type, values) => { - writer.write_u8(*symbol_type as u8)?; + Value::Array(kind, values) => { + writer.write_u8(*kind as u8)?; writer.write_u16::(values.len() as u16)?; for value in values.iter() { value.write(writer)?; @@ -142,4 +135,26 @@ impl Value { } Ok(()) } + + pub fn try_as_structure(self) -> Result, Error> { + match self { + Value::Structure(value) => Ok(value), + value => Err(Error::ExpectedValueType( + "Structure".to_string(), + value.clone(), + )), + } + } + + pub fn try_as_structure_mut( + &mut self, + ) -> Result<&mut Box, Error> { + match self { + Value::Structure(value) => Ok(value), + value => Err(Error::ExpectedValueType( + "Structure".to_string(), + value.clone(), + )), + } + } } diff --git a/cascade/src/save/collection.rs b/cascade/src/save/collection.rs deleted file mode 100644 index 3dc51ed..0000000 --- a/cascade/src/save/collection.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::path::{Path, PathBuf}; - -use rayon::prelude::*; - -use super::extension::SaveFileExtension; -use crate::save::{SaveError, SaveFile}; - -pub struct SaveCollection { - saves: Vec, -} - -// TODO: lol this is just a vec how do i properly implement a collection type -impl SaveCollection { - pub fn at_dir>(dir: P) -> Result { - let dir = PathBuf::from(dir.as_ref()); - - dir.is_dir() - .then(|| ()) - .ok_or_else(|| SaveError::NoSuchDirectory(dir.clone()))?; - - log::info!("using saves from {:?}", dir); - - let saves: Vec = dir - .read_dir()? - .filter_map(|file| file.ok()) - .filter_map(|file| { - let filepath = file.path(); - - match SaveFile::at_path(&filepath) { - Ok(save) => { - log::info!("found save {:?}", filepath); - Some(save) - } - Err(e) => { - log::warn!("error finding save {:?}: {}", filepath, e); - None - } - } - }) - .collect(); - - Ok(Self { saves }) - } - - pub fn filter_extension(&self, extension: SaveFileExtension) -> Self { - Self { - saves: self - .saves - .iter() - .filter(|save| save.extension() == extension) - .cloned() - .collect(), - } - } - - pub fn len(&self) -> usize { - self.saves.len() - } - - pub fn into_par_iter(self) -> rayon::vec::IntoIter { - self.saves.into_par_iter() - } - - pub fn par_iter<'a>(&'a self) -> rayon::slice::Iter<'a, SaveFile> { - self.saves.par_iter() - } - - pub fn into_iter(self) -> impl Iterator { - self.saves.into_iter() - } - - pub fn iter(&self) -> impl Iterator { - self.saves.iter() - } -} - -impl FromIterator for SaveCollection { - fn from_iter>(iter: T) -> Self { - Self { - saves: iter.into_iter().collect(), - } - } -} diff --git a/cascade/src/save/file.rs b/cascade/src/save/entry.rs similarity index 53% rename from cascade/src/save/file.rs rename to cascade/src/save/entry.rs index d03ce66..2d497c2 100644 --- a/cascade/src/save/file.rs +++ b/cascade/src/save/entry.rs @@ -1,35 +1,46 @@ use std::{ fs::{self}, - io::{BufReader, BufWriter, Write}, + hash::{Hash, Hasher}, + io::{BufReader, BufWriter, Read, Write}, path::{Path, PathBuf}, }; -use filetime; - -use super::extension::SaveFileExtension; -use crate::save::{SaveContent, SaveError}; +use crate::save::{extension::Extension, Error, Result}; #[derive(Debug, Clone)] -pub struct SaveFile { - dir: PathBuf, - name: String, - extension: SaveFileExtension, - metadata: fs::Metadata, +pub struct Entry { + pub dir: PathBuf, + pub name: String, + pub extension: Extension, + pub metadata: fs::Metadata, +} + +impl Hash for Entry { + fn hash(&self, state: &mut H) { + self.filepath().hash(state); + } +} + +impl PartialEq for Entry { + fn eq(&self, other: &Self) -> bool { + // TODO: store filepath in entry + self.filepath() == other.filepath() + } } -impl SaveFile { - pub fn at_path>(filepath: P) -> Result { +impl Eq for Entry {} + +impl Entry { + pub fn at_path>(filepath: P) -> Result { let metadata = fs::metadata(&filepath)?; - let extension = SaveFileExtension::try_from( + let extension = Extension::try_from( filepath .as_ref() .extension() .and_then(|name| name.to_str()) .ok_or_else(|| { - SaveError::InvalidSaveFilePath(PathBuf::from( - filepath.as_ref(), - )) + Error::InvalidSaveFilePath(PathBuf::from(filepath.as_ref())) })?, )?; @@ -40,7 +51,7 @@ impl SaveFile { .and_then(|name| name.to_str()) .map(|name| name.to_string()) .ok_or_else(|| { - SaveError::InvalidSaveFilePath(PathBuf::from(filepath.as_ref())) + Error::InvalidSaveFilePath(PathBuf::from(filepath.as_ref())) })?; let dir = filepath @@ -48,7 +59,7 @@ impl SaveFile { .parent() .map(|dir| PathBuf::from(dir)) .ok_or_else(|| { - SaveError::InvalidSaveFilePath(PathBuf::from(filepath.as_ref())) + Error::InvalidSaveFilePath(PathBuf::from(filepath.as_ref())) })?; Ok(Self { @@ -68,12 +79,13 @@ impl SaveFile { } } - pub fn name(&self) -> &String { - &self.name - } - - pub fn extension(&self) -> SaveFileExtension { - self.extension + pub fn with_name(&self, name: impl ToString) -> Self { + Self { + dir: self.dir.clone(), + name: name.to_string(), + extension: self.extension, + metadata: self.metadata.clone(), + } } pub fn filename(&self) -> String { @@ -84,24 +96,21 @@ impl SaveFile { self.dir.join(self.filename()) } - pub fn write_content( - &self, - content: &SaveContent, - ) -> Result<(), SaveError> { - let filepath = self.filepath(); - let file = fs::File::create(&filepath)?; - let mut writer = BufWriter::new(file); - - // TODO: should we set the filename in the content structures? - content.write(&mut writer)?; - writer.flush()?; + pub fn metadata(&self) -> &fs::Metadata { + &self.metadata + } - log::info!("wrote save to {:?}", filepath); + pub fn reader(&self) -> Result { + let file = fs::File::open(&self.filepath())?; + Ok(BufReader::new(file)) + } - Ok(()) + pub fn writer(&self) -> Result { + let file = fs::File::create(&self.filepath())?; + Ok(BufWriter::new(file)) } - pub fn overwrite_metadata(&self) -> Result<(), SaveError> { + pub fn overwrite_metadata(&self) -> Result<()> { let filepath = self.filepath(); // TODO: this should probably be configurable @@ -118,13 +127,4 @@ impl SaveFile { Ok(()) } - - pub fn load_content(&self) -> Result { - let filepath = self.filepath(); - - let file = fs::File::open(&filepath)?; - let mut reader = BufReader::new(file); - - SaveContent::from_reader(&mut reader) - } } diff --git a/cascade/src/save/error.rs b/cascade/src/save/error.rs index cb8e09b..cf5c48f 100644 --- a/cascade/src/save/error.rs +++ b/cascade/src/save/error.rs @@ -1,26 +1,16 @@ -use std::{backtrace::Backtrace, io, path::PathBuf}; +use std::{io, path::PathBuf, result}; -use thiserror::Error; +use crate::qb; -use crate::structure::StructureError; +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("io error: {0}")] + Io(io::ErrorKind), -#[derive(Error, Debug)] -pub enum SaveError { - #[error("an io error occurred: {source}")] - Io { - #[from] - source: io::Error, - backtrace: Backtrace, - }, + #[error("structure error: {0}")] + Symbol(#[from] qb::Error), - #[error("an error occurred while reading/writing symbols")] - Symbol { - #[from] - source: StructureError, - backtrace: Backtrace, - }, - - #[error("unknown save file extension {0}")] + #[error("unknown save file extension \"{0}\"")] UnknownFileExtension(String), #[error("directory \"{0}\" was not found")] @@ -29,3 +19,11 @@ pub enum SaveError { #[error("save file path \"{0}\" is not valid")] InvalidSaveFilePath(PathBuf), } + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Io(value.kind()) + } +} + +pub type Result = result::Result; diff --git a/cascade/src/save/extension.rs b/cascade/src/save/extension.rs index 5617e50..66f058a 100644 --- a/cascade/src/save/extension.rs +++ b/cascade/src/save/extension.rs @@ -1,31 +1,30 @@ use std::fmt; -// TODO: remove super uses -use super::SaveError; +use super::Error; #[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum SaveFileExtension { +pub enum Extension { SKA, } -impl TryFrom<&str> for SaveFileExtension { - type Error = SaveError; +impl TryFrom<&str> for Extension { + type Error = Error; fn try_from(value: &str) -> Result { match value { - "SKA" => Ok(SaveFileExtension::SKA), - _ => Err(SaveError::UnknownFileExtension(value.to_string())), + "SKA" => Ok(Extension::SKA), + _ => Err(Error::UnknownFileExtension(value.to_string())), } } } -impl fmt::Display for SaveFileExtension { +impl fmt::Display for Extension { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}", match self { - SaveFileExtension::SKA => "SKA", + Extension::SKA => "SKA", } ) } diff --git a/cascade/src/save/mod.rs b/cascade/src/save/mod.rs index b38231e..bb19e8b 100644 --- a/cascade/src/save/mod.rs +++ b/cascade/src/save/mod.rs @@ -1,11 +1,39 @@ -mod collection; -mod content; +use std::path::{Path, PathBuf}; + +mod entry; mod error; mod extension; -mod file; +pub mod thug_pro; + +pub use entry::Entry; +pub use error::{Error, Result}; +pub use extension::Extension; + +pub fn find_entries(dir: impl AsRef) -> Result> { + let dir = PathBuf::from(dir.as_ref()); + + dir.is_dir() + .then(|| ()) + .ok_or_else(|| Error::NoSuchDirectory(dir.clone()))?; + + log::info!("finding entries in {:?}", dir); + + Ok(dir + .read_dir()? + .filter_map(|file| file.ok()) + .filter_map(|file| { + let filepath = file.path(); -pub use collection::SaveCollection; -pub use content::SaveContent; -pub use error::SaveError; -pub use extension::SaveFileExtension; -pub use file::SaveFile; + match Entry::at_path(&filepath) { + Ok(save) => { + log::info!("found entry {:?}", filepath); + Some(save) + } + Err(e) => { + log::warn!("error loading entry {:?}: {}", filepath, e); + None + } + } + }) + .collect()) +} diff --git a/cascade/src/save/thug_pro/cas.rs b/cascade/src/save/thug_pro/cas.rs new file mode 100644 index 0000000..313fda9 --- /dev/null +++ b/cascade/src/save/thug_pro/cas.rs @@ -0,0 +1,470 @@ +use serde::Serialize; + +use crate::{ + qb, + save::thug_pro::{ska, Error, Result}, +}; + +fn expect_symbol( + parent: Box, + id: qb::Id, + name: impl ToString, +) -> Result { + Ok(parent + .get(id) + .ok_or(Error::SymbolNotFound(name.to_string()))? + .clone()) +} + +fn expect_symbol_mut( + parent: &mut Box, + id: qb::Id, + name: impl ToString, +) -> Result<&mut qb::Symbol> { + Ok(parent + .get_mut(id) + .ok_or(Error::SymbolNotFound(name.to_string()))?) +} + +// expect symbol and expect structure +fn expect_structure( + parent: Box, + id: qb::Id, + name: impl ToString, +) -> Result> { + let symbol = expect_symbol(parent, id, name)?; + Ok(symbol.value.try_as_structure()?) +} + +fn expect_structure_mut( + parent: &mut Box, + id: qb::Id, + name: impl ToString, +) -> Result<&mut Box> { + let symbol = expect_symbol_mut(parent, id, name)?; + Ok(symbol.value.try_as_structure_mut()?) +} + +#[derive(Debug, Clone, Serialize)] +pub struct Cas { + pub summary: Summary, + pub data: Data, +} + +impl TryFrom for Cas { + type Error = Error; + + fn try_from(content: ska::Ska) -> Result { + Ok(Self { + data: Data::try_from(content.data)?, + summary: Summary(content.summary), + }) + } +} + +impl Cas { + pub fn modify(&self, content: &mut ska::Ska) -> Result<()> { + self.data.modify(&mut content.data)?; + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Summary(pub Box); + +const CUSTOM_SKATER_NAME: &'static str = "CustomSkater"; +const CUSTOM_SKATER_ID: qb::Id = qb::Id::Checksum(314551426); + +const STORY_SKATER_NAME: &'static str = "StorySkater"; +const STORY_SKATER_ID: qb::Id = qb::Id::Checksum(234026056); + +#[derive(Debug, Clone, Serialize, Default)] +pub struct Data { + pub custom_skater: Option, + pub story_skater: Option, +} + +impl TryFrom> for Data { + type Error = Error; + + fn try_from(data: Box) -> Result { + Ok(Self { + custom_skater: Some(CustomSkater::try_from(expect_structure( + Box::clone(&data), + CUSTOM_SKATER_ID, + CUSTOM_SKATER_NAME, + )?)?), + story_skater: Some(StorySkater::try_from(expect_structure( + Box::clone(&data), + STORY_SKATER_ID, + STORY_SKATER_NAME, + )?)?), + }) + } +} + +impl Data { + pub fn modify(&self, data: &mut Box) -> Result<()> { + if let Some(custom_skater) = &self.custom_skater { + custom_skater.modify(expect_structure_mut( + data, + CUSTOM_SKATER_ID, + CUSTOM_SKATER_NAME, + )?)?; + } + + if let Some(story_skater) = &self.story_skater { + story_skater.modify(expect_structure_mut( + data, + STORY_SKATER_ID, + STORY_SKATER_NAME, + )?)?; + } + + Ok(()) + } +} + +const CUSTOM_NAME: &'static str = "CustomSkater.custom"; +const CUSTOM_ID: qb::Id = qb::Id::Compressed8(195); + +#[derive(Debug, Clone, Serialize, Default)] +pub struct CustomSkater { + pub custom: Option, +} + +impl TryFrom> for CustomSkater { + type Error = Error; + + fn try_from(custom_skater: Box) -> Result { + Ok(Self { + custom: Some(Custom::try_from(expect_structure( + custom_skater, + CUSTOM_ID, + CUSTOM_NAME, + )?)?), + }) + } +} + +impl CustomSkater { + pub fn modify(&self, custom_skater: &mut Box) -> Result<()> { + if let Some(custom) = &self.custom { + custom.modify(expect_structure_mut( + custom_skater, + CUSTOM_ID, + CUSTOM_NAME, + )?)?; + } + Ok(()) + } +} + +const APPEARANCE_NAME: &'static str = "CustomSkater.custom.appearance"; +const APPEARANCE_ID: qb::Id = qb::Id::Checksum(1431076207); + +const INFO_NAME: &'static str = "CustomSkater.custom.info"; +const INFO_ID: qb::Id = qb::Id::Checksum(880201384); + +// TODO: implement entire Appearance struct +#[derive(Debug, Clone, Serialize, Default)] +pub struct Custom { + pub scales: Option, + pub info: Option, +} + +impl TryFrom> for Custom { + type Error = Error; + + fn try_from(custom: Box) -> Result { + let appearance = expect_structure( + Box::clone(&custom), + APPEARANCE_ID, + APPEARANCE_NAME, + )?; + + Ok(Self { + scales: Some(Scales::try_from(appearance)?), + info: Some(Info::try_from(expect_structure( + custom, INFO_ID, INFO_NAME, + )?)?), + }) + } +} + +impl Custom { + pub fn modify(&self, custom: &mut Box) -> Result<()> { + if let Some(scales) = &self.scales { + let appearance = + expect_structure_mut(custom, APPEARANCE_ID, APPEARANCE_NAME)?; + + scales.modify(appearance); + } + + if let Some(info) = &self.info { + info.modify(expect_structure_mut(custom, INFO_ID, INFO_NAME)?)?; + } + + Ok(()) + } +} + +const TRICK_MAPPING_NAME: &'static str = + "CustomSkater.custom.info.trick_mapping"; +const TRICK_MAPPING_ID: qb::Id = qb::Id::Compressed8(61); + +const SPECIALS_NAME: &'static str = "CustomSkater.custom.info.specials"; +const SPECIALS_ID: qb::Id = qb::Id::Compressed8(64); + +#[derive(Debug, Clone, Serialize, Default)] +pub struct Info { + pub trick_mapping: Option, + pub specials: Option, +} + +impl TryFrom> for Info { + type Error = Error; + + fn try_from(info: Box) -> Result { + Ok(Self { + trick_mapping: Some(expect_symbol( + Box::clone(&info), + TRICK_MAPPING_ID, + TRICK_MAPPING_NAME, + )?), + specials: Some(expect_symbol( + Box::clone(&info), + SPECIALS_ID, + SPECIALS_NAME, + )?), + }) + } +} + +impl Info { + pub fn modify(&self, info: &mut qb::Structure) -> Result<()> { + if let Some(trick_mapping) = &self.trick_mapping { + info.insert(trick_mapping.clone()); + } + + if let Some(specials) = &self.specials { + info.insert(specials.clone()); + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Scales { + pub board_bone_group: Scalable, + pub feet_bone_group: Scalable, + pub hands_bone_group: Scalable, + pub head_bone_group: Scalable, + pub headtop_bone_group: Scalable, + pub jaw_bone_group: Scalable, + pub lower_arm_bone_group: Scalable, + pub lower_leg_bone_group: Scalable, + pub nose_bone_group: Scalable, + pub object_scaling: Scalable, + pub stomach_bone_group: Scalable, + pub torso_bone_group: Scalable, + pub upper_arm_bone_group: Scalable, + pub upper_leg_bone_group: Scalable, +} + +impl Scales { + pub fn modify(&self, appearance: &mut qb::Structure) { + self.board_bone_group + .modify(appearance, BOARD_BONE_GROUP_ID); + self.feet_bone_group.modify(appearance, FEET_BONE_GROUP_ID); + self.hands_bone_group + .modify(appearance, HANDS_BONE_GROUP_ID); + self.head_bone_group.modify(appearance, HEAD_BONE_GROUP_ID); + self.headtop_bone_group + .modify(appearance, HEADTOP_BONE_GROUP_ID); + self.jaw_bone_group.modify(appearance, JAW_BONE_GROUP_ID); + self.lower_arm_bone_group + .modify(appearance, LOWER_ARM_BONE_GROUP_ID); + self.lower_leg_bone_group + .modify(appearance, LOWER_LEG_BONE_GROUP_ID); + self.nose_bone_group.modify(appearance, NOSE_BONE_GROUP_ID); + self.object_scaling.modify(appearance, OBJECT_SCALING_ID); + self.stomach_bone_group + .modify(appearance, STOMACH_BONE_GROUP_ID); + self.torso_bone_group + .modify(appearance, TORSO_BONE_GROUP_ID); + self.upper_arm_bone_group + .modify(appearance, UPPER_ARM_BONE_GROUP_ID); + self.upper_leg_bone_group + .modify(appearance, UPPER_LEG_BONE_GROUP_ID); + } +} + +const BOARD_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(208); +const FEET_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(207); +const HANDS_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(204); +const HEAD_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(199); +const HEADTOP_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(196); +const JAW_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(197); +const LOWER_ARM_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(203); +const LOWER_LEG_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(206); +const NOSE_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(198); +const OBJECT_SCALING_ID: qb::Id = qb::Id::Compressed8(209); +const STOMACH_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(201); +const TORSO_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(200); +const UPPER_ARM_BONE_GROUP_ID: qb::Id = qb::Id::Compressed8(202); +const UPPER_LEG_BONE_GROUP_ID: qb::Id = qb::Id::Checksum(3191687513); + +impl TryFrom> for Scales { + type Error = Error; + + fn try_from(structure: Box) -> Result { + Ok(Self { + board_bone_group: Scalable::from( + structure.get(BOARD_BONE_GROUP_ID).cloned(), + ), + feet_bone_group: Scalable::from( + structure.get(FEET_BONE_GROUP_ID).cloned(), + ), + hands_bone_group: Scalable::from( + structure.get(HANDS_BONE_GROUP_ID).cloned(), + ), + head_bone_group: Scalable::from( + structure.get(HEAD_BONE_GROUP_ID).cloned(), + ), + headtop_bone_group: Scalable::from( + structure.get(HEADTOP_BONE_GROUP_ID).cloned(), + ), + jaw_bone_group: Scalable::from( + structure.get(JAW_BONE_GROUP_ID).cloned(), + ), + lower_arm_bone_group: Scalable::from( + structure.get(LOWER_ARM_BONE_GROUP_ID).cloned(), + ), + lower_leg_bone_group: Scalable::from( + structure.get(LOWER_LEG_BONE_GROUP_ID).cloned(), + ), + nose_bone_group: Scalable::from( + structure.get(NOSE_BONE_GROUP_ID).cloned(), + ), + object_scaling: Scalable::from( + structure.get(OBJECT_SCALING_ID).cloned(), + ), + stomach_bone_group: Scalable::from( + structure.get(STOMACH_BONE_GROUP_ID).cloned(), + ), + torso_bone_group: Scalable::from( + structure.get(TORSO_BONE_GROUP_ID).cloned(), + ), + upper_arm_bone_group: Scalable::from( + structure.get(UPPER_ARM_BONE_GROUP_ID).cloned(), + ), + upper_leg_bone_group: Scalable::from( + structure.get(UPPER_LEG_BONE_GROUP_ID).cloned(), + ), + }) + } +} + +//const X_ID: qb::Id = qb::Id::Compressed8(165); +//const Y_ID: qb::Id = qb::Id::Compressed8(166); +//const Z_ID: qb::Id = qb::Id::Compressed8(167); +//const USE_DEFAULT_SCALE_ID: qb::Id = qb::Id::Compressed8(26); + +//#[derive(Debug, Clone, Serialize)] +//pub struct Scaled { +// pub x: qb::Symbol, // "X" +// pub y: qb::Symbol, // "Y" +// pub z: qb::Symbol, // "Z" +// pub use_default_scale: qb::Symbol, // "use_default_scale" +//} + +//impl TryFrom> for Scaled { +// type Error = Error; +// fn try_from(structure: Box) -> Result { +// let x = expect_symbol(Box::clone(&structure), X_ID, "{Scaled}.X")?; +// let y = expect_symbol(Box::clone(&structure), Y_ID, "{Scaled}.Y")?; +// let z = expect_symbol(Box::clone(&structure), Z_ID, "{Scaled}.Z")?; +// let use_default_scale = expect_symbol( +// Box::clone(&structure), +// USE_DEFAULT_SCALE_ID, +// "{Scaled}.Z", +// )?; +// +// Ok(Self { +// x, +// y, +// z, +// use_default_scale, +// }) +// } +//} + +// have not observed a scaled item only have desc_id, unlike colors +#[derive(Debug, Clone, Serialize)] +pub enum Scalable { + Scaled(qb::Symbol), + Vacant, +} + +impl Scalable { + pub fn modify(&self, appearance: &mut qb::Structure, id: qb::Id) { + match self { + Scalable::Scaled(symbol) => { + appearance.insert(symbol.clone()); + } + Scalable::Vacant => { + appearance.remove(id); + } + } + } +} + +impl From> for Scalable { + fn from(structure: Option) -> Self { + match structure { + Some(structure) => Self::Scaled(structure), + None => Self::Vacant, + } + } +} + +//#[derive(Debug)] +//struct Colored { +// desc_id: Option, +// // lowercase hsv +// h: Option, +// s: Option, +// v: Option, +// use_default_hsv: Option, // ZeroInt +//} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct StorySkater { + // qb::Value::Array(qb::Kind::Structure, ...) + pub tricks: Option, +} + +const TRICKS_ID: qb::Id = qb::Id::Checksum(505871678); +const TRICKS_NAME: &'static str = "StorySkater.tricks"; + +impl TryFrom> for StorySkater { + type Error = Error; + + fn try_from(structure: Box) -> Result { + Ok(Self { + tricks: Some(expect_symbol(structure, TRICKS_ID, TRICKS_NAME)?), + }) + } +} + +impl StorySkater { + pub fn modify(&self, story_skater: &mut Box) -> Result<()> { + if let Some(tricks) = &self.tricks { + story_skater.insert(tricks.clone()); + } + + Ok(()) + } +} diff --git a/cascade/src/save/thug_pro/error.rs b/cascade/src/save/thug_pro/error.rs new file mode 100644 index 0000000..c719c1d --- /dev/null +++ b/cascade/src/save/thug_pro/error.rs @@ -0,0 +1,29 @@ +use std::{io, result}; + +use crate::{qb, save}; + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("io error: {0}")] + Io(io::ErrorKind), + + #[error("qb error: {0}")] + Qb(#[from] qb::Error), + + #[error("save error: {0}")] + Save(#[from] save::Error), + + #[error("symbol not found: {0}")] + SymbolNotFound(String), + + #[error("symbol not found: {0}")] + ExpectedStructure(String, qb::Value), +} + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Io(value.kind()) + } +} + +pub type Result = result::Result; diff --git a/cascade/src/save/thug_pro/mod.rs b/cascade/src/save/thug_pro/mod.rs new file mode 100644 index 0000000..7ee5e38 --- /dev/null +++ b/cascade/src/save/thug_pro/mod.rs @@ -0,0 +1,7 @@ +pub mod cas; +pub mod error; +pub mod ska; + +pub use cas::Cas; +pub use error::{Error, Result}; +pub use ska::Ska; diff --git a/cascade/src/save/content.rs b/cascade/src/save/thug_pro/ska.rs similarity index 58% rename from cascade/src/save/content.rs rename to cascade/src/save/thug_pro/ska.rs index 71647a7..ee5a530 100644 --- a/cascade/src/save/content.rs +++ b/cascade/src/save/thug_pro/ska.rs @@ -1,21 +1,22 @@ use std::{ - io::{BufRead, Write}, + io::{Read, Write}, mem::size_of, - sync::Arc, }; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use count_write::CountWrite; use serde::{Deserialize, Serialize}; -use super::SaveError; -use crate::{crc32::get_checksum_for_bytes, structure::Structure}; +use crate::{ + crc32, qb, + save::{self, thug_pro::Result}, +}; const SAVE_FILE_SIZE: usize = 90112; const PADDING_BYTE: u8 = 0x69; -#[derive(Debug, Serialize, Deserialize)] -struct Header { +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Header { pub checksum: u32, pub summary_checksum: u32, pub summary_size: i32, @@ -24,7 +25,7 @@ struct Header { } impl Header { - pub fn from_reader(stream: &mut R) -> Result { + pub fn read(stream: &mut impl Read) -> Result { let checksum = stream.read_u32::()?; let summary_checksum = stream.read_u32::()?; let summary_size = stream.read_i32::()?; @@ -40,7 +41,7 @@ impl Header { }) } - pub fn write(&self, writer: &mut W) -> Result<(), SaveError> { + pub fn write(&self, writer: &mut W) -> Result<()> { writer.write_u32::(self.checksum)?; writer.write_u32::(self.summary_checksum)?; writer.write_i32::(self.summary_size)?; @@ -49,44 +50,32 @@ impl Header { Ok(()) } - pub fn raw_bytes(&self) -> Result, SaveError> { + pub fn raw_bytes(&self) -> Result> { let mut bytes = vec![]; self.write(&mut bytes)?; Ok(bytes) } - - pub fn invalidate(&self) -> Header { - Header { - checksum: 0, - summary_checksum: 0, - summary_size: 0, - total_size: 0, - version: self.version, - } - } } -#[derive(Debug, Serialize, Deserialize)] -pub struct SaveContent { - // Header is invalid as soon as we make a mutation - // TODO: remove, just keep version - header: Header, +#[derive(Debug, Clone, Serialize)] +pub struct Ska { + pub header: Header, - pub summary: Arc, - pub data: Arc, + pub summary: Box, + pub data: Box, } -impl SaveContent { - pub fn from_reader(reader: &mut R) -> Result { - Ok(SaveContent { - header: Header::from_reader(reader)?, - summary: Arc::new(Structure::from_reader(reader)?), - data: Arc::new(Structure::from_reader(reader)?), +impl Ska { + pub fn read(reader: &mut impl Read) -> Result { + Ok(Self { + header: Header::read(reader)?, + summary: Box::new(qb::Structure::read(reader)?), + data: Box::new(qb::Structure::read(reader)?), }) } - pub fn write(&self, writer: &mut W) -> Result<(), SaveError> { + pub fn write(&self, writer: &mut W) -> Result<()> { let mut count_writer = CountWrite::from(writer); // TODO fix this @@ -102,7 +91,7 @@ impl SaveContent { SAVE_FILE_SIZE.saturating_sub(num_bytes_written); log::info!( - "wrote {} bytes, padding {} bytes to hit {}", + "wrote {} bytes, padding with {} bytes to fill {} bytes", num_bytes_written, num_padding_bytes, SAVE_FILE_SIZE @@ -113,21 +102,32 @@ impl SaveContent { Ok(()) } - fn calculate_header(&self) -> Result { + pub fn read_from(entry: &save::Entry) -> Result { + let mut reader = entry.reader()?; + Self::read(&mut reader) + } + + pub fn write_to(self, entry: &save::Entry) -> Result<()> { + let mut reader = entry.writer()?; + self.write(&mut reader) + } + + fn calculate_header(&self) -> Result
{ let mut summary_bytes = self.summary.raw_bytes()?; let mut data_bytes = self.data.raw_bytes()?; - let summary_checksum = get_checksum_for_bytes(&summary_bytes); + let summary_checksum = crc32::checksum(&summary_bytes); let summary_size = summary_bytes.len() as i32; let data_size = data_bytes.len() as i32; let total_size = size_of::
() as i32 + summary_size + data_size; + let version = self.header.version; let header_zero_checksum = Header { checksum: 0, summary_checksum, summary_size, total_size, - version: self.header.version, + version, }; let mut all_bytes = vec![]; @@ -135,37 +135,29 @@ impl SaveContent { all_bytes.append(&mut summary_bytes); all_bytes.append(&mut data_bytes); - let checksum = get_checksum_for_bytes(&all_bytes); + let checksum = crc32::checksum(&all_bytes); Ok(Header { checksum, summary_checksum, summary_size, total_size, - version: self.header.version, + version, }) } - pub fn summary(&self) -> Arc { - Arc::clone(&self.summary) - } - - pub fn data(&self) -> Arc { - Arc::clone(&self.data) - } - - pub fn with_summary(&self, summary: Arc) -> Self { + pub fn with_summary(&self, summary: Box) -> Self { Self { - header: self.header.invalidate(), + header: self.header.clone(), summary, - data: Arc::clone(&self.data), + data: Box::clone(&self.data), } } - pub fn with_data(&self, data: Arc) -> Self { + pub fn with_data(&self, data: Box) -> Self { Self { - header: self.header.invalidate(), - summary: Arc::clone(&self.summary), + header: self.header.clone(), + summary: Box::clone(&self.summary), data, } } diff --git a/cascade/src/structure/error.rs b/cascade/src/structure/error.rs deleted file mode 100644 index aa9b54d..0000000 --- a/cascade/src/structure/error.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::{backtrace::Backtrace, fmt::Debug, io}; - -use thiserror::Error; - -use super::NameChecksum; - -#[derive(Error, Debug)] -pub enum StructureError { - #[error("invalid symbol type {0}")] - InvalidType(u8), - - #[error("an io error occurred: {source}")] - Io { - #[from] - source: io::Error, - backtrace: Backtrace, - }, - - #[error("internal error: {0} is not implemented")] - NotImplemented(String), - - #[error("symbol with checksum {0} is not a structure")] - NotAStructure(NameChecksum), - - #[error( - "both checksum table lookup bits set in symbol type byte: {0:#02x}" - )] - BothChecksumBits(u8), - - #[error("could not find name \"{0}\" in lookup tables")] - UnknownChecksumName(String), - - #[error("could not find symbol with name {0} in structure")] - NameNotFound(String), - - #[error("could not find symbol with name checksum {0} in structure")] - NameChecksumNotFound(NameChecksum), - - #[error("index {0} is out of range of the structure")] - IndexOutOfRange(usize), -} diff --git a/cascade/src/structure/name_checksum.rs b/cascade/src/structure/name_checksum.rs deleted file mode 100644 index 5f44d6e..0000000 --- a/cascade/src/structure/name_checksum.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::{ - fmt::{self, Debug}, - io::{Read, Write}, -}; - -use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; -use serde::{Deserialize, Serialize, Serializer}; - -use crate::{ - lookup, - structure::{StructureError, Type}, -}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize)] -pub enum NameChecksum { - None, - Checksum(u32), - Compressed8(u8), - Compressed16(u16), -} - -impl NameChecksum { - pub fn from_reader( - reader: &mut R, - symbol_type: Type, - use_lookup_8: bool, - use_lookup_16: bool, - ) -> Result { - Ok(if symbol_type == Type::None { - NameChecksum::None - } else if use_lookup_8 { - NameChecksum::Compressed8(reader.read_u8()?) - } else if use_lookup_16 { - NameChecksum::Compressed16(reader.read_u16::()?) - } else { - NameChecksum::Checksum(reader.read_u32::()?) - }) - } - - pub fn write( - &self, - writer: &mut W, - ) -> Result<(), StructureError> { - match self { - NameChecksum::Checksum(val) => { - writer.write_u32::(*val)? - } - NameChecksum::Compressed8(val) => writer.write_u8(*val)?, - NameChecksum::Compressed16(val) => { - writer.write_u16::(*val)? - } - NameChecksum::None => (), - } - Ok(()) - } - - pub fn to_name(&self) -> Option { - match self { - NameChecksum::Checksum(checksum) => lookup::checksum(*checksum), - NameChecksum::Compressed8(byte) => lookup::compressed8(*byte), - NameChecksum::Compressed16(word) => lookup::compressed16(*word), - NameChecksum::None => None, - } - } -} - -impl Serialize for NameChecksum { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - // TODO: do some serialize/deserialize shenanigans - serializer.serialize_str( - self.to_name().unwrap_or(format!("{:?}", self)).as_str(), - ) - } -} - -impl fmt::Display for NameChecksum { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let name = self.to_name().unwrap_or("".to_string()); - - let checksum = match self { - NameChecksum::Checksum(checksum) => { - format!("Checksum({})", checksum) - } - NameChecksum::Compressed8(compressed8) => { - format!("Compressed8({})", compressed8) - } - NameChecksum::Compressed16(compressed16) => { - format!("Compressed16({})", compressed16) - } - NameChecksum::None => format!("None"), - }; - - write!(f, "{} (\"{}\")", checksum, name) - } -} - -impl TryFrom<&String> for NameChecksum { - type Error = StructureError; - - fn try_from(value: &String) -> Result { - // TODO: can probably micro-optimize depending on which of these is most likely - if let Some(checksum) = lookup::reverse_checksum(value) { - Ok(NameChecksum::Checksum(checksum)) - } else if let Some(compressed8) = lookup::reverse_compressed8(value) { - Ok(NameChecksum::Compressed8(compressed8)) - } else if let Some(compressed16) = lookup::reverse_compressed16(value) { - Ok(NameChecksum::Compressed16(compressed16)) - } else { - Err(StructureError::UnknownChecksumName(value.clone())) - } - } -} - -impl TryFrom<&str> for NameChecksum { - type Error = StructureError; - - fn try_from(value: &str) -> Result { - NameChecksum::try_from(&value.to_string()) - } -} diff --git a/cascade/src/structure/structure.rs b/cascade/src/structure/structure.rs deleted file mode 100644 index 5dfe736..0000000 --- a/cascade/src/structure/structure.rs +++ /dev/null @@ -1,135 +0,0 @@ -use std::{ - collections::HashMap, - fmt::Debug, - io::{BufRead, Write}, - sync::Arc, -}; - -use serde::{Deserialize, Serialize}; - -use super::NameChecksum; -use crate::structure::{StructureError, Symbol, Type}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Structure { - symbols: Vec, - - index_lookup: HashMap, -} - -impl Structure { - pub fn new(symbols: Vec) -> Self { - // TODO: Should duplicate symbols be checked? - let index_lookup = symbols - .iter() - .enumerate() - .map(|(index, symbol)| (symbol.name_checksum().clone(), index)) - .collect(); - - Self { - symbols, - index_lookup, - } - } - - pub fn from_reader( - reader: &mut R, - ) -> Result { - let mut symbols = vec![]; - - while { - // do: - // Read symbol from the reader - let symbol = Symbol::from_reader(reader)?; - let symbol_type = symbol.symbol_type(); - - // while: - // The symbol is not none - match symbol_type { - Type::None => false, - _ => { - // Only push the symbol if it is non-none - symbols.push(symbol); - true - } - } - } {} - - Ok(Self::new(symbols)) - } - - pub fn write( - &self, - writer: &mut W, - ) -> Result<(), StructureError> { - for symbol in self.symbols.iter() { - symbol.write(writer)?; - } - - // Each structure is terminated with a none symbol - Symbol::none().write(writer)?; - - Ok(()) - } - - pub fn raw_bytes(&self) -> Result, StructureError> { - let mut bytes = vec![]; - self.write(&mut bytes)?; - - Ok(bytes) - } - - pub fn index(&self, name_checksum: NameChecksum) -> Option { - self.index_lookup.get(&name_checksum).cloned() - } - - pub fn try_index(&self, name: N) -> Result, StructureError> - where - N: TryInto, - { - Ok(self.index(name.try_into()?)) - } - - pub fn get(&self, name_checksum: NameChecksum) -> Option<&Symbol> { - self.index(name_checksum) - .and_then(|index| self.symbols.get(index)) - } - - pub fn try_get(&self, name: N) -> Result, StructureError> - where - N: TryInto, - { - Ok(self.get(name.try_into()?)) - } - - pub fn has(&self, name_checksum: NameChecksum) -> bool { - self.index(name_checksum).is_some() - } - - pub fn try_has(&self, name: N) -> Result - where - N: TryInto, - { - Ok(self.has(name.try_into()?)) - } - - pub fn iter(&self) -> impl Iterator { - self.symbols.iter() - } - - pub fn len(&self) -> usize { - self.symbols.len() - } -} - -impl FromIterator for Structure { - fn from_iter>(iter: T) -> Self { - Self::new(iter.into_iter().collect()) - } -} - -impl FromIterator for Arc { - fn from_iter>(iter: T) -> Self { - Self::new(iter.into_iter().collect()) - } -} diff --git a/cascade/src/structure/symbol.rs b/cascade/src/structure/symbol.rs deleted file mode 100644 index b1d5b1e..0000000 --- a/cascade/src/structure/symbol.rs +++ /dev/null @@ -1,130 +0,0 @@ -use std::{ - fmt::Debug, - io::{BufRead, Write}, - sync::Arc, -}; - -use byteorder::{ReadBytesExt, WriteBytesExt}; -use serde::{Deserialize, Serialize}; - -use crate::structure::{NameChecksum, Structure, StructureError, Type, Value}; - -const CHECKSUM_LOOKUP_MASK_8: u8 = 1 << 7; -const CHECKSUM_LOOKUP_MASK_16: u8 = 1 << 6; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Symbol { - symbol_type: Type, - name_checksum: NameChecksum, - value: Value, -} - -impl Symbol { - pub fn none() -> Symbol { - Symbol { - symbol_type: Type::None, - name_checksum: NameChecksum::None, - value: Value::None, - } - } - - pub fn from_reader( - reader: &mut R, - ) -> Result { - let type_byte = reader.read_u8()?; - - // 8-bit / 16-bit mask in bits 6/7 - let type_byte_masked = - type_byte & (!CHECKSUM_LOOKUP_MASK_8) & (!CHECKSUM_LOOKUP_MASK_16); - - let use_lookup_8 = (type_byte & CHECKSUM_LOOKUP_MASK_8) > 0; - let use_lookup_16 = (type_byte & CHECKSUM_LOOKUP_MASK_16) > 0; - - // Error if both checksum bits are set - (!(use_lookup_8 && use_lookup_16)) - .then(|| ()) - .ok_or(StructureError::BothChecksumBits(type_byte))?; - - let symbol_type = Type::try_from(type_byte_masked)?; - - let name_checksum = NameChecksum::from_reader( - reader, - symbol_type, - use_lookup_8, - use_lookup_16, - )?; - - let value = Value::from_reader(reader, symbol_type)?; - - let symbol = Symbol { - symbol_type, - name_checksum, - value, - }; - - Ok(symbol) - } - - pub fn write( - &self, - writer: &mut W, - ) -> Result<(), StructureError> { - let type_byte = self.symbol_type as u8; - - let type_byte_with_checksum_bits = match self.name_checksum { - NameChecksum::Compressed16(_) => { - type_byte | CHECKSUM_LOOKUP_MASK_16 - } - NameChecksum::Compressed8(_) => type_byte | CHECKSUM_LOOKUP_MASK_8, - _ => type_byte, - }; - - writer.write_u8(type_byte_with_checksum_bits)?; - self.name_checksum.write(writer)?; - self.value.write(writer)?; - - Ok(()) - } - - pub fn name(&self) -> Option { - self.name_checksum.to_name() - } - - pub fn has_name_checksum(&self, name_checksum: NameChecksum) -> bool { - self.name_checksum == name_checksum - } - - pub fn has_name(&self, name: &String) -> bool { - self.name_checksum - .to_name() - .filter(|lookup_name| lookup_name == name) - .is_some() - } - - pub fn with_value(&self, value: Value) -> Self { - Self { - symbol_type: self.symbol_type, - name_checksum: self.name_checksum, - value, - } - } - - pub fn symbol_type(&self) -> Type { - self.symbol_type - } - - pub fn name_checksum(&self) -> NameChecksum { - self.name_checksum - } - - pub fn value(&self) -> &Value { - &self.value - } - - pub fn try_as_struct(&self) -> Result, StructureError> { - match &self.value { - Value::Structure(structure) => Ok(Arc::clone(structure)), - _ => Err(StructureError::NotAStructure(self.name_checksum)), - } - } -} diff --git a/cascade/src/structure/types.rs b/cascade/src/structure/types.rs deleted file mode 100644 index e4585d5..0000000 --- a/cascade/src/structure/types.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::fmt::Debug; - -use serde::{Deserialize, Serialize}; - -use crate::structure::StructureError; - -#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum Type { - None = 0, - Integer, - Float, - String, - LocalString, - Pair, - Vector, - QScript, - CFunction, - MemberFunction, - Structure, - StructurePointer, - Array, - Name, - I8, - I16, - U8, - U16, - ZeroInt, - ZeroFloat, -} - -impl TryFrom for Type { - type Error = StructureError; - - fn try_from(v: u8) -> Result { - match v { - x if x == Type::None as u8 => Ok(Type::None), - x if x == Type::Integer as u8 => Ok(Type::Integer), - x if x == Type::Float as u8 => Ok(Type::Float), - x if x == Type::String as u8 => Ok(Type::String), - x if x == Type::LocalString as u8 => Ok(Type::LocalString), - x if x == Type::Pair as u8 => Ok(Type::Pair), - x if x == Type::Vector as u8 => Ok(Type::Vector), - x if x == Type::QScript as u8 => Ok(Type::QScript), - x if x == Type::CFunction as u8 => Ok(Type::CFunction), - x if x == Type::MemberFunction as u8 => Ok(Type::MemberFunction), - x if x == Type::Structure as u8 => Ok(Type::Structure), - x if x == Type::StructurePointer as u8 => { - Ok(Type::StructurePointer) - } - x if x == Type::Array as u8 => Ok(Type::Array), - x if x == Type::Name as u8 => Ok(Type::Name), - x if x == Type::I8 as u8 => Ok(Type::I8), - x if x == Type::I16 as u8 => Ok(Type::I16), - x if x == Type::U8 as u8 => Ok(Type::U8), - x if x == Type::U16 as u8 => Ok(Type::U16), - x if x == Type::ZeroInt as u8 => Ok(Type::ZeroInt), - x if x == Type::ZeroFloat as u8 => Ok(Type::ZeroFloat), - _ => Err(StructureError::InvalidType(v)), - } - } -} diff --git a/cascade/tests/common/mod.rs b/cascade/tests/common/mod.rs index 6410537..97a548b 100644 --- a/cascade/tests/common/mod.rs +++ b/cascade/tests/common/mod.rs @@ -1,6 +1,6 @@ use std::{env, fs, path::PathBuf}; -use cascade::save::SaveCollection; +use cascade::save; pub fn output_dir() -> PathBuf { let temp_dir = env::temp_dir(); @@ -16,13 +16,15 @@ pub fn output_dir() -> PathBuf { output_dir } -pub fn save_collection() -> SaveCollection { +pub fn entries() -> Vec { let cwd = env::current_dir().expect("could not get cwd"); - let mut saves_dir = PathBuf::from(cwd); - saves_dir.push(".."); - saves_dir.push("resources"); - saves_dir.push("saves"); + let saves_dir = PathBuf::from_iter(vec![ + cwd, + "..".into(), + "resources".into(), + "saves".into(), + ]); - SaveCollection::at_dir(&saves_dir).expect("could not find saves directory") + save::find_entries(&saves_dir).expect("could not find saves directory") } diff --git a/cascade/tests/round_trip.rs b/cascade/tests/round_trip.rs index 56265cb..526af0e 100644 --- a/cascade/tests/round_trip.rs +++ b/cascade/tests/round_trip.rs @@ -4,12 +4,11 @@ use std::{ sync::atomic::{AtomicBool, Ordering}, }; -use cascade::save::SaveFile; -use rayon::prelude::ParallelIterator; +use cascade::save::{thug_pro, Entry}; mod common; -fn read_save_file_bytes(save: &SaveFile) -> Vec { +fn read_entry_bytes(save: &Entry) -> Vec { let filepath = save.filepath(); let mut file = fs::File::open(&filepath).expect("could not open file for reading"); @@ -20,9 +19,9 @@ fn read_save_file_bytes(save: &SaveFile) -> Vec { bytes } -fn diff_save_files(input_save: &SaveFile, output_save: &SaveFile) -> bool { - let input_bytes = read_save_file_bytes(&input_save); - let output_bytes = read_save_file_bytes(&output_save); +fn diff_save_files(input_entry: &Entry, output_entry: &Entry) -> bool { + let input_bytes = read_entry_bytes(&input_entry); + let output_bytes = read_entry_bytes(&output_entry); if input_bytes.len() == output_bytes.len() { let mut num_diff_bytes = 0; @@ -43,48 +42,43 @@ fn diff_save_files(input_save: &SaveFile, output_save: &SaveFile) -> bool { println!( "result for {}: {} ({} bytes different)", - input_save.filename(), + input_entry.filename(), status, num_diff_bytes ); passed } else { - println!("result for {}: input size ({}) and output size ({}) are different!", input_save.filename(), input_bytes.len(), output_bytes.len()); + println!("result for {}: input size ({}) and output size ({}) are different!", input_entry.filename(), input_bytes.len(), output_bytes.len()); false } } -fn round_trip_save_file( - input_save_file: &SaveFile, - output_save_file: &SaveFile, -) -> bool { - let input_save_content = input_save_file - .load_content() +fn round_trip_save_file(input_entry: &Entry, output_entry: &Entry) -> bool { + let input_ska = thug_pro::Ska::read_from(input_entry) .expect("could not load input save"); - output_save_file - .write_content(&input_save_content) + input_ska + .write_to(output_entry) .expect("could not write output save"); - diff_save_files(input_save_file, output_save_file) + diff_save_files(input_entry, output_entry) } #[test] fn round_trip() { - let collection = common::save_collection(); + let entries = common::entries(); let output_dir = common::output_dir(); let all_passed = AtomicBool::new(true); - collection.par_iter().for_each(|input_save_file| { - let output_save_file = input_save_file.with_dir(&output_dir); + for entry in entries { + let output_entry = entry.with_dir(&output_dir); - let file_passed = - round_trip_save_file(&input_save_file, &output_save_file); + let file_passed = round_trip_save_file(&entry, &output_entry); all_passed.fetch_and(file_passed, Ordering::SeqCst); - }); + } assert!(all_passed.load(Ordering::SeqCst)) } diff --git a/cascade_app/Cargo.toml b/cascade_app/Cargo.toml new file mode 100644 index 0000000..92cd138 --- /dev/null +++ b/cascade_app/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "cascade_app" +version = "0.3.0" +edition = "2021" + +[[bin]] +name = "cascade" +path = "src/main.rs" + +[dependencies] +cascade = { path = "../cascade" } +clap = { version = "4.5.16", features = ["derive"] } +dark-light = "1.0.0" +directories = "5.0.1" +enum-iterator = "1.4.1" +fern = { version = "0.6.2", features = ["colored"] } +hex-literal = "0.4.1" +image = "0.24.0" +indexmap = "2.7.0" +log = "0.4.20" +rfd = "0.12.1" +ron = "0.8.1" +serde = "1.0.193" +thiserror = "1.0.50" +time = { version = "0.3.30", features = ["local-offset", "formatting"] } +tokio = { version = "1.40.0", features = ["fs", "io-util"] } +toml = "0.8.8" + +[dependencies.iced] +version = "0.13.1" +default-features = false +features = ["tokio", "lazy", "advanced", "image", "debug", "wgpu", "fira-sans"] + +[target.'cfg(windows)'.build-dependencies] +winres = "0.1.12" diff --git a/cascade_cli/build.rs b/cascade_app/build.rs similarity index 81% rename from cascade_cli/build.rs rename to cascade_app/build.rs index 3aa0dca..b0957c6 100644 --- a/cascade_cli/build.rs +++ b/cascade_app/build.rs @@ -4,3 +4,6 @@ fn main() { res.set_icon("../resources/cascade.ico"); res.compile().unwrap(); } + +#[cfg(not(windows))] +fn main() {} diff --git a/cascade_app/src/app.rs b/cascade_app/src/app.rs new file mode 100644 index 0000000..382b568 --- /dev/null +++ b/cascade_app/src/app.rs @@ -0,0 +1,212 @@ +use std::path::PathBuf; + +use iced::{ + event, + keyboard::{self, key}, + widget::container, + Event, Length, Padding, Subscription, Task, +}; + +use crate::{ + config::{Config, Format, Selections}, + dashboard, paths, tasks, Element, Theme, +}; + +#[derive(Debug, Clone)] +pub enum Message { + Dashboard(dashboard::Message), + + WroteConfig(Result), + WroteSelections(Result), + + EventOccurred(Event), +} + +pub struct Cascade { + config_path: PathBuf, + selections_path: PathBuf, + + config: Config, + debug: bool, + theme: Theme, + + dashboard: dashboard::Dashboard, +} + +impl Cascade { + pub fn new( + flags: (PathBuf, Config, Selections, Theme, bool), + ) -> (Self, Task) { + let (cascade_dir, config, selections, theme, debug) = flags; + let backup_dir = paths::backup_dir(&cascade_dir); + + let (dashboard, dashboard_command) = dashboard::Dashboard::new( + config.source_path.clone(), + config.saves_dir.clone(), + backup_dir, + config.default_selection, + selections, + config.trickset, + config.scales, + ); + + let config_path = paths::config(&cascade_dir); + let selections_path = paths::selections(&cascade_dir); + + ( + Cascade { + config_path, + selections_path, + theme, + config, + debug, + dashboard, + }, + dashboard_command.map(Message::Dashboard), + ) + } + + fn write_config(&self) -> Task { + Task::perform( + tasks::write( + self.config.clone(), + self.config_path.clone(), + Format::Toml, + ), + Message::WroteConfig, + ) + } + + fn write_selections(&self, selections: Selections) -> Task { + Task::perform( + tasks::write( + selections.clone(), + self.selections_path.clone(), + Format::Ron, + ), + Message::WroteSelections, + ) + } + + pub fn scale_factor(&self) -> f64 { + self.config.scale_factor + } + + pub fn theme(&self) -> Theme { + self.theme.clone() + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::Dashboard(message) => { + let (command, event) = self.dashboard.update(message); + + Task::batch(vec![ + command.map(Message::Dashboard), + match event { + Some(dashboard::Event::SetSavesDir(saves_dir)) => { + self.config.saves_dir = Some(saves_dir.clone()); + + Task::batch(vec![ + self.dashboard + .set_saves_dir(saves_dir) + .map(Message::Dashboard), + self.write_config(), + ]) + } + Some(dashboard::Event::SetSelections(selections)) => { + self.write_selections(selections) + } + Some(dashboard::Event::SetDefaultSelection( + default_selection, + selections, + )) => { + self.config.default_selection = default_selection; + + Task::batch(vec![ + self.write_config(), + self.write_selections(selections), + ]) + } + + Some(dashboard::Event::SetSourcePath(path)) => { + self.config.source_path = Some(path); + self.write_config() + } + + Some(dashboard::Event::SetTrickset(value)) => { + self.config.trickset = value; + self.write_config() + } + + Some(dashboard::Event::SetScales(value)) => { + self.config.scales = value; + self.write_config() + } + + None => Task::none(), + }, + ]) + } + Message::WroteConfig(Ok(_)) | Message::WroteSelections(Ok(_)) => { + Task::none() + } + Message::WroteConfig(Err(err)) => { + log::info!("error writing config: {:?}", err); + Task::none() + } + Message::WroteSelections(Err(err)) => { + log::info!("error writing selections: {:?}", err); + Task::none() + } + Message::EventOccurred(event) => match event { + Event::Keyboard(event) => match event { + keyboard::Event::KeyPressed { + physical_key: key::Physical::Code(key::Code::Equal), + modifiers: keyboard::Modifiers::CTRL, + .. + } => { + if self.config.scale_factor < 5. { + self.config.scale_factor += 0.1; + self.write_config() + } else { + Task::none() + } + } + keyboard::Event::KeyPressed { + physical_key: key::Physical::Code(key::Code::Minus), + modifiers: keyboard::Modifiers::CTRL, + .. + } => { + if self.config.scale_factor > 0.2 { + self.config.scale_factor -= 0.1; + self.write_config() + } else { + Task::none() + } + } + _ => Task::none(), + }, + _ => Task::none(), + }, + } + } + + pub fn view(&self) -> Element { + let content: Element = + container(self.dashboard.view().map(Message::Dashboard)) + .width(Length::Fill) + .height(Length::Fill) + .padding(Padding::new(20.)) + .into(); + + match self.debug { + true => content.explain(iced::Color::WHITE), + false => content, + } + } + + pub fn subscription(&self) -> Subscription { + event::listen().map(Message::EventOccurred) + } +} diff --git a/cascade_app/src/config/frappe.rs b/cascade_app/src/config/frappe.rs new file mode 100644 index 0000000..c7a1e51 --- /dev/null +++ b/cascade_app/src/config/frappe.rs @@ -0,0 +1,36 @@ +use std::sync::LazyLock; + +use iced::Color; + +macro_rules! color { + ($r:expr, $g:expr, $b:expr) => { + LazyLock::new(|| Color::from_rgb8($r, $g, $b)) + }; +} + +pub static BASE: LazyLock = color!(48, 52, 70); +pub static SURFACE0: LazyLock = color!(65, 69, 89); +pub static TEXT: LazyLock = color!(198, 208, 245); +pub static BLUE: LazyLock = color!(140, 170, 238); +pub static GREEN: LazyLock = color!(166, 209, 137); +pub static YELLOW: LazyLock = color!(229, 200, 144); +pub static RED: LazyLock = color!(231, 130, 132); +pub static MAUVE: LazyLock = color!(202, 158, 230); +// pub static CRUST: LazyLock = color!(35, 38, 52); +// pub static MANTLE: LazyLock = color!(41, 44, 60); +// pub static SURFACE1: LazyLock = color!(81, 87, 109); +// pub static SURFACE2: LazyLock = color!(98, 104, 128); +// pub static OVERLAY0: LazyLock = color!(115, 121, 148); +// pub static OVERLAY1: LazyLock = color!(131, 139, 167); +// pub static OVERLAY2: LazyLock = color!(148, 156, 187); +// pub static SUBTEXT0: LazyLock = color!(165, 173, 206); +// pub static SUBTEXT1: LazyLock = color!(181, 191, 226); +// pub static LAVENDER: LazyLock = color!(186, 187, 241); +// pub static SAPPHIRE: LazyLock = color!(133, 193, 220); +// pub static SKY: LazyLock = color!(153, 209, 219); +// pub static TEAL: LazyLock = color!(129, 200, 190); +// pub static PEACH: LazyLock = color!(239, 159, 118); +// pub static MAROON: LazyLock = color!(234, 153, 156); +// pub static PINK: LazyLock = color!(244, 184, 228); +// pub static FLAMINGO: LazyLock = color!(238, 190, 190); +// pub static ROSEWATER: LazyLock = color!(242, 213, 207); diff --git a/cascade_app/src/config/mod.rs b/cascade_app/src/config/mod.rs new file mode 100644 index 0000000..93689b8 --- /dev/null +++ b/cascade_app/src/config/mod.rs @@ -0,0 +1,41 @@ +use std::io; + +use ron::de::SpannedError; + +use crate::paths; + +pub mod frappe; +pub mod options; +pub mod selections; +pub mod theme; + +pub use options::Config; +pub use selections::Selections; +pub use theme::Theme; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Format { + Ron, + Toml, +} + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("io error: {0}")] + Io(io::ErrorKind), + + #[error("paths error: {0}")] + Paths(#[from] paths::Error), + + #[error("ron deserialization error: {0}")] + Ron(#[from] SpannedError), + + #[error("toml deserialization error: {0}")] + Toml(#[from] toml::de::Error), +} + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Io(value.kind()) + } +} diff --git a/cascade_app/src/config/options.rs b/cascade_app/src/config/options.rs new file mode 100644 index 0000000..58713af --- /dev/null +++ b/cascade_app/src/config/options.rs @@ -0,0 +1,68 @@ +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{config::Error, paths}; + +fn default_scale_factor() -> f64 { + 1. +} + +fn default_saves_dir() -> Option { + match paths::default_saves_dir() { + Ok(dir) => { + log::info!("autodetected thug pro saves dir at {:?}", dir); + Some(dir) + } + Err(err) => { + log::warn!("could not autodetect thug pro saves dir: {}", err); + None + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub saves_dir: Option, + #[serde(default)] + pub source_path: Option, + #[serde(default = "default_scale_factor")] + pub scale_factor: f64, + #[serde(default)] + pub default_selection: bool, + #[serde(default)] + pub scales: bool, + #[serde(default)] + pub trickset: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + saves_dir: default_saves_dir(), + source_path: None, + scale_factor: 1., + default_selection: true, + scales: false, + trickset: false, + } + } +} + +impl Config { + pub fn load(path: impl AsRef) -> Result { + let file = fs::File::open(&path)?; + + log::info!("reading config from {:?}", path.as_ref()); + + let contents = io::read_to_string(file)?; + + let config = toml::from_str(contents.as_str())?; + + Ok(config) + } +} diff --git a/cascade_app/src/config/selections.rs b/cascade_app/src/config/selections.rs new file mode 100644 index 0000000..753aa30 --- /dev/null +++ b/cascade_app/src/config/selections.rs @@ -0,0 +1,36 @@ +use std::{collections::HashMap, fs, io, ops::Deref, path::Path}; + +use serde::{Deserialize, Serialize}; + +use crate::config::Error; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Selections(HashMap); + +impl Selections { + pub fn load(path: impl AsRef) -> Result { + let file = fs::File::open(&path)?; + + log::info!("reading selections from {:?}", path.as_ref()); + + let contents = io::read_to_string(file)?; + + let config = ron::from_str(contents.as_str())?; + + Ok(config) + } +} + +impl From> for Selections { + fn from(value: HashMap) -> Self { + Self(value) + } +} + +impl Deref for Selections { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/cascade_app/src/config/theme.rs b/cascade_app/src/config/theme.rs new file mode 100644 index 0000000..19ac9c6 --- /dev/null +++ b/cascade_app/src/config/theme.rs @@ -0,0 +1,166 @@ +use std::{fs, io, path::Path}; + +use iced::{application, Color}; +use serde::{Deserialize, Serialize}; + +use crate::config::{frappe, Error}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Theme { + #[serde(with = "color_serde", default = "default_background")] + pub background: Color, + #[serde(with = "color_serde", default = "default_text")] + pub text: Color, + #[serde(with = "color_serde", default = "default_primary")] + pub primary: Color, + #[serde(with = "color_serde", default = "default_secondary")] + pub secondary: Color, + #[serde(with = "color_serde", default = "default_success")] + pub success: Color, + #[serde(with = "color_serde", default = "default_danger")] + pub danger: Color, + #[serde(with = "color_serde", default = "default_warning")] + pub warning: Color, + #[serde(with = "color_serde", default = "default_highlight")] + pub highlight: Color, +} + +impl Default for Theme { + fn default() -> Self { + Self { + background: default_background(), + text: default_text(), + primary: default_primary(), + secondary: default_secondary(), + success: default_success(), + danger: default_danger(), + warning: default_warning(), + highlight: default_highlight(), + } + } +} + +impl Theme { + pub fn load(path: impl AsRef) -> Result { + let file = fs::File::open(&path)?; + + log::info!("reading theme from {:?}", path.as_ref()); + + let contents = io::read_to_string(file)?; + + let config = toml::from_str(contents.as_str())?; + + Ok(config) + } +} + +fn default_background() -> iced::Color { + *frappe::BASE +} + +fn default_text() -> iced::Color { + *frappe::TEXT +} + +fn default_primary() -> iced::Color { + *frappe::BLUE +} + +fn default_secondary() -> iced::Color { + *frappe::SURFACE0 +} + +fn default_success() -> iced::Color { + *frappe::GREEN +} + +fn default_danger() -> iced::Color { + *frappe::RED +} + +fn default_warning() -> iced::Color { + *frappe::YELLOW +} + +fn default_highlight() -> iced::Color { + *frappe::MAUVE +} + +impl application::DefaultStyle for Theme { + fn default_style(&self) -> application::Appearance { + application::Appearance { + background_color: self.background, + text_color: self.text, + } + } +} + +pub fn hex_to_color(hex: &str) -> Option { + if hex.len() == 7 || hex.len() == 9 { + let hash = &hex[0..1]; + let r = u8::from_str_radix(&hex[1..3], 16); + let g = u8::from_str_radix(&hex[3..5], 16); + let b = u8::from_str_radix(&hex[5..7], 16); + let a = (hex.len() == 9) + .then(|| u8::from_str_radix(&hex[7..9], 16).ok()) + .flatten(); + + return match (hash, r, g, b, a) { + ("#", Ok(r), Ok(g), Ok(b), None) => Some(Color { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: 1.0, + }), + ("#", Ok(r), Ok(g), Ok(b), Some(a)) => Some(Color { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: a as f32 / 255.0, + }), + _ => None, + }; + } + + None +} + +pub fn color_to_hex(color: Color) -> String { + use std::fmt::Write; + + let mut hex = String::with_capacity(9); + + let [r, g, b, a] = color.into_rgba8(); + + let _ = write!(&mut hex, "#"); + let _ = write!(&mut hex, "{:02X}", r); + let _ = write!(&mut hex, "{:02X}", g); + let _ = write!(&mut hex, "{:02X}", b); + + if a < u8::MAX { + let _ = write!(&mut hex, "{:02X}", a); + } + + hex +} + +mod color_serde { + use iced::Color; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(String::deserialize(deserializer) + .map(|hex| super::hex_to_color(&hex))? + .unwrap_or(Color::TRANSPARENT)) + } + + pub fn serialize(color: &Color, serializer: S) -> Result + where + S: Serializer, + { + super::color_to_hex(*color).serialize(serializer) + } +} diff --git a/cascade_app/src/dashboard.rs b/cascade_app/src/dashboard.rs new file mode 100644 index 0000000..d43265c --- /dev/null +++ b/cascade_app/src/dashboard.rs @@ -0,0 +1,759 @@ +use std::{ + collections::HashMap, + fmt::Debug, + io, + path::{Path, PathBuf}, + result, + sync::Arc, +}; + +use cascade::{ + lut::{self, Lut}, + save::{self, thug_pro}, +}; +use iced::{ + alignment::Vertical, + font::Weight, + widget::{button, checkbox, container, scrollable, text, tooltip}, + Font, Length, Task, +}; +use indexmap::IndexMap; +use rfd::AsyncFileDialog; +use tokio::fs; + +use crate::{ + config::{Format, Selections}, + fonts, paths, tasks, theme, + widget::{self, heading}, + Column, Element, Row, +}; + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("io error: {0}")] + Io(io::ErrorKind), + + #[error("save error: {0}")] + Save(#[from] save::Error), + + #[error("paths error: {0}")] + Path(#[from] paths::Error), + + #[error("LUT error: {0}")] + Lut(#[from] lut::Error), + + #[error("tasks error: {0}")] + Tasks(#[from] tasks::Error), + + #[error("thug pro save error: {0}")] + ThugPro(#[from] thug_pro::Error), + + #[error("error spawning task")] + Task, + + #[error("no saves dir set")] + NoSavesDir, +} + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Io(value.kind()) + } +} + +pub type Result = result::Result; + +#[derive(Debug, Clone)] +enum Status { + // i.e. last known status of processing an entry + InProgress, + Success, + #[expect(dead_code)] + Error(Error), +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct Components { + trickset: bool, + scales: bool, +} + +#[derive(Debug, Clone)] +pub enum Message { + LoadedLut(Result), + LoadedCandidates(Result>), + LoadedSource(Result), + + PickSource, + SourcePicked(Option), + + PickSavesDir, + SavesDirChanged(PathBuf), + ClosedSavesDirDialog, + + ToggleSelectAll, + ToggleSelection(save::Entry), + ToggleTricksetComponent(bool), + ToggleScalesComponent(bool), + + Start, + PreProcessDone(Result<(Arc, PathBuf)>), + EntryProcessed(save::Entry, Result<()>), +} + +#[derive(Debug, Clone)] +pub enum Event { + SetSavesDir(PathBuf), + SetSourcePath(PathBuf), + SetDefaultSelection(bool, Selections), + SetSelections(Selections), + SetTrickset(bool), + SetScales(bool), +} + +pub struct Dashboard { + backup_dir: PathBuf, + saves_dir: Option, + default_selection: bool, + enabled: bool, + + source_entry: Option, + source: Option, + + candidates: IndexMap, + components: Components, + queue: IndexMap, + + warning_message: Option, + + lut: Option, +} + +impl Dashboard { + pub fn new( + source_path: Option, + saves_dir: Option, + backup_dir: PathBuf, + default_selection: bool, + selections: Selections, + trickset: bool, + scales: bool, + ) -> (Self, Task) { + let source_entry = source_path + .map(|path| save::Entry::at_path(path).ok()) + .flatten(); + + let tasks = Task::batch(vec![ + match &source_entry { + Some(entry) => Task::perform( + load_source(entry.clone()), + Message::LoadedSource, + ), + None => Task::none(), + }, + Task::perform( + load_candidates( + saves_dir.clone(), + selections, + default_selection, + ), + Message::LoadedCandidates, + ), + Task::perform(load_lut(), Message::LoadedLut), + ]); + + let dashboard = Dashboard { + enabled: true, + backup_dir, + source_entry, + source: None, + saves_dir, + candidates: IndexMap::new(), + queue: IndexMap::new(), + default_selection, + components: Components { scales, trickset }, + warning_message: None, + lut: None, + }; + + (dashboard, tasks) + } + + fn selections(&self) -> Selections { + self.candidates + .iter() + .map(|(entry, selected)| (entry.name.clone(), *selected)) + .collect::>() + .into() + } + + pub fn set_saves_dir( + &mut self, + saves_dir: impl AsRef, + ) -> Task { + self.saves_dir = Some(saves_dir.as_ref().into()); + + Task::perform( + load_candidates( + self.saves_dir.clone(), + self.selections(), + self.default_selection, + ), + Message::LoadedCandidates, + ) + } + + fn notify(&mut self, msg: impl Into) { + let msg = msg.into(); + log::warn!("{}", msg); + self.warning_message = Some(msg); + } + + pub fn update( + &mut self, + message: Message, + ) -> (Task, Option) { + match message { + Message::PickSavesDir => ( + Task::perform(pick_saves_dir(), |dir| { + dir.map(|dir| Message::SavesDirChanged(dir)) + .unwrap_or(Message::ClosedSavesDirDialog) + }), + None, + ), + Message::ClosedSavesDirDialog => (Task::none(), None), + Message::SavesDirChanged(saves_dir) => { + self.saves_dir = Some(saves_dir.clone()); + (Task::none(), Some(Event::SetSavesDir(saves_dir.clone()))) + } + Message::LoadedSource(Ok(content)) => { + self.source = Some(content); + (Task::none(), None) + } + Message::LoadedSource(Err(err)) => { + self.notify(format!("error loading source: {}", err)); + (Task::none(), None) + } + + Message::LoadedCandidates(Ok(entries)) => { + self.candidates = entries; + (Task::none(), None) + } + Message::LoadedCandidates(Err(err)) => { + self.notify(format!("error loading selections: {}", err)); + (Task::none(), None) + } + + Message::LoadedLut(Ok(lut)) => { + self.lut = Some(lut); + (Task::none(), None) + } + Message::LoadedLut(Err(err)) => { + self.notify(format!("error loading LUT: {}", err)); + (Task::none(), None) + } + + Message::PickSource => { + (Task::perform(pick_source(), Message::SourcePicked), None) + } + + Message::SourcePicked(Some(path)) => { + match save::Entry::at_path(path.clone()) { + Ok(entry) => { + self.source_entry = Some(entry.clone()); + ( + Task::perform( + load_source(entry), + Message::LoadedSource, + ), + Some(Event::SetSourcePath(path)), + ) + } + Err(err) => { + self.notify(format!("error picking source: {err}")); + (Task::none(), None) + } + } + } + Message::SourcePicked(None) => (Task::none(), None), + + Message::ToggleSelectAll => { + self.default_selection = !self.default_selection; + + for selected in self.candidates.values_mut() { + *selected = self.default_selection; + } + + ( + Task::none(), + Some(Event::SetDefaultSelection( + self.default_selection, + self.selections(), + )), + ) + } + + Message::ToggleSelection(entry) => { + if let Some(selected) = self.candidates.get_mut(&entry) { + *selected = !*selected; + } + (Task::none(), Some(Event::SetSelections(self.selections()))) + } + + Message::ToggleTricksetComponent(selected) => { + self.components.trickset = selected; + (Task::none(), Some(Event::SetTrickset(selected))) + } + + Message::ToggleScalesComponent(selected) => { + self.components.scales = selected; + (Task::none(), Some(Event::SetScales(selected))) + } + + Message::Start => match &self.source { + Some(source) => { + if self.candidates.values().any(|selected| *selected) { + self.enabled = false; + + let datetime = time::OffsetDateTime::now_local() + .unwrap_or(time::OffsetDateTime::now_utc()); + + let backup_dir = self.backup_dir.join(format!( + "{:04}-{:02}-{:02}T{:02}-{:02}-{:02}", + datetime.year(), + u8::from(datetime.month()), + datetime.day(), + datetime.hour(), + datetime.minute(), + datetime.second() + )); + + ( + Task::perform( + pre_process( + backup_dir, + source.clone(), + self.components, + ), + Message::PreProcessDone, + ), + None, + ) + } else { + (Task::none(), None) + } + } + None => (Task::none(), None), + }, + Message::PreProcessDone(Ok((transform, backup_dir))) => { + let selected_entries = self + .candidates + .iter() + .filter_map(|(entry, selected)| { + selected.then_some(entry.clone()) + }) + .collect::>(); + + self.queue = selected_entries + .iter() + .map(|entry| (entry.clone(), Status::InProgress)) + .collect::>(); + + ( + Task::batch( + selected_entries + .iter() + .map(|entry| { + let entry = entry.clone(); + Task::perform( + process_entry( + entry.clone(), + backup_dir.clone(), + Arc::clone(&transform), + ), + move |result| { + Message::EntryProcessed( + entry.clone(), + result, + ) + }, + ) + }) + .collect::>(), + ), + None, + ) + } + Message::PreProcessDone(Err(err)) => { + self.enabled = true; + self.notify(format!("error during pre-process: {}", err)); + + (Task::none(), None) + } + Message::EntryProcessed(entry, result) => { + let new_status = match result { + Ok(_) => Status::Success, + Err(err) => { + self.notify(format!( + "error for entry {}: {:?}", + entry.name, err + )); + Status::Error(err) + } + }; + + self.queue.entry(entry).and_modify(|status| { + *status = new_status; + }); + + if self.queue.values().all(|entry| match entry { + Status::InProgress => false, + Status::Success | Status::Error(_) => true, + }) { + self.enabled = true; + } + + (Task::none(), None) + } + } + } + + fn view_source_info(&self) -> Element { + if let Some(entry) = &self.source_entry { + tooltip( + container(text(entry.filename())) + .style(theme::container::monobox) + .padding(10) + .align_y(Vertical::Center), + container(text(entry.filepath().display().to_string())) + .padding(10) + .align_y(Vertical::Center), + tooltip::Position::Bottom, + ) + .gap(10) + .style(theme::container::bordered) + .into() + } else { + container(text("(none)")) + .style(theme::container::monobox) + .padding(10) + .align_y(Vertical::Center) + .into() + } + } + + fn view_saves_dir(&self) -> Element { + // :( + if let Some(dir) = &self.saves_dir { + if let Some(name) = dir.file_name() { + if let Some(name) = name.to_str() { + return tooltip( + container(text(name)) + .style(theme::container::monobox) + .padding(10) + .align_y(Vertical::Center), + container(text(dir.display().to_string())) + .padding(10) + .align_y(Vertical::Center), + tooltip::Position::Bottom, + ) + .gap(10) + .style(theme::container::bordered) + .into(); + } + } + } + container(text("(none)")) + .style(theme::container::monobox) + .padding(10) + .align_y(Vertical::Center) + .into() + } + + fn view_entries(&self) -> Element { + self.candidates + .iter() + .fold(Column::new().spacing(2), |column, (entry, selected)| { + column.push(widget::entry::selectable( + &entry, + *selected, + self.enabled + .then_some(Message::ToggleSelection(entry.clone())), + )) + }) + .into() + } + + fn view_queue(&self) -> Element { + scrollable( + self.candidates + .iter() + .filter(|(_, selected)| **selected) + .fold(Column::new().spacing(2), |column, (entry, _)| { + let style = match self.queue.get(entry) { + Some(Status::InProgress) => { + theme::button::entry_warning + } + Some(Status::Success) => theme::button::entry_success, + Some(Status::Error(_)) => theme::button::entry_danger, + None => theme::button::entry_queued, + }; + column.push( + button(text(entry.name.clone())) + .style(style) + .on_press_maybe(self.enabled.then_some( + Message::ToggleSelection(entry.clone()), + )) + .width(Length::Fill), + ) + }), + ) + .into() + } + + fn view_left(&self) -> Column { + Column::new() + .spacing(10) + .push( + Row::new() + .spacing(10) + .align_y(Vertical::Center) + .height(Length::Shrink) + .push( + button(text("\u{E802}").font(fonts::ICONS_FONT)) + .on_press_maybe( + self.enabled.then_some(Message::PickSource), + ), + ) + .push(heading("from")) + .push(self.view_source_info()), + ) + .push( + checkbox("trickset", self.components.trickset).on_toggle_maybe( + self.enabled.then_some(Message::ToggleTricksetComponent), + ), + ) + .push(checkbox("scales", self.components.scales).on_toggle_maybe( + self.enabled.then_some(Message::ToggleScalesComponent), + )) + .push(Row::new().height(Length::Fill).align_y(Vertical::Bottom)) + } + + fn view_center(&self) -> Column { + Column::new() + .spacing(10) + .push( + Row::new() + .spacing(10) + .align_y(Vertical::Center) + .height(Length::Shrink) + .push( + button(text("\u{E802}").font(fonts::ICONS_FONT)) + .on_press_maybe( + self.enabled.then_some(Message::PickSavesDir), + ), + ) + .push(heading("to")) + .push(self.view_saves_dir()), + ) + .push( + button( + text(match self.default_selection { + true => "deselect all", + false => "select all", + }) + .font(Font { + weight: Weight::Semibold, + ..Default::default() + }), + ) + .style(theme::button::secondary) + .on_press_maybe( + self.enabled.then_some(Message::ToggleSelectAll), + ) + .width(Length::Fill), + ) + .push(scrollable(self.view_entries())) + } + + fn view_right(&self) -> Column { + Column::new() + .spacing(10) + .push( + Row::new() + .spacing(10) + .align_y(Vertical::Center) + .push( + button(text("\u{E803}").font(fonts::ICONS_FONT)) + .on_press_maybe( + self.enabled.then_some(Message::Start), + ), + ) + .push(heading("queue")), + ) + .push(self.view_queue()) + } + + pub fn view(&self) -> Element { + Row::new() + .push(self.view_left().width(Length::Fill)) + .push(self.view_center().width(Length::Fill)) + .push(self.view_right().width(Length::Fill)) + .width(Length::Fill) + .spacing(10) + .into() + } +} + +async fn pick_source() -> Option { + Some( + AsyncFileDialog::new() + .add_filter("CAS file (.SKA)", &["SKA"]) + .pick_file() + .await? + .path() + .into(), + ) +} + +async fn load_source(entry: save::Entry) -> Result { + let content = tokio::spawn(async move { thug_pro::Ska::read_from(&entry) }) + .await + .map_err(|_| Error::Task)??; + + let cas = thug_pro::Cas::try_from(content)?; + + Ok(cas) +} + +async fn load_lut() -> Result { + let content = tokio::spawn(async move { Lut::thug_pro() }) + .await + .map_err(|_| Error::Task)??; + + Ok(content) +} + +async fn load_candidates( + saves_dir: Option>, + selections: Selections, + default_selection: bool, +) -> Result> { + let saves_dir = saves_dir.ok_or(Error::NoSavesDir)?; + let entries = save::find_entries(saves_dir)?; + + log::info!("found {} saves", entries.len()); + + let candidates = entries + .into_iter() + .map(|entry| { + ( + entry.clone(), + *selections.get(&entry.name).unwrap_or(&default_selection), + ) + }) + .collect::>(); + + Ok(candidates) +} + +fn make_transform( + source: &thug_pro::cas::Cas, + components: Components, +) -> thug_pro::cas::Cas { + thug_pro::cas::Cas { + summary: source.summary.clone(), + data: thug_pro::cas::Data { + // CustomSkater + custom_skater: source.data.custom_skater.clone().map( + move |custom_skater| { + thug_pro::cas::CustomSkater { + // CustomSkater.custom + custom: custom_skater.custom.map(move |custom| { + thug_pro::cas::Custom { + // CustomSkater.custom.info + info: custom.info.map(|info| { + thug_pro::cas::Info { + // CustomSkater.custom.info.trick_mapping + trick_mapping: components + .trickset + .then_some(info.trick_mapping) + .flatten(), + // CustomSkater.custom.info.specials + specials: components + .trickset + .then_some(info.specials) + .flatten(), + } + }), + // CustomSkater.custom.appearance (scales group) + scales: components + .scales + .then_some(custom.scales) + .flatten(), + } + }), + } + }, + ), + story_skater: source.data.story_skater.clone().map( + move |story_skater| thug_pro::cas::StorySkater { + tricks: components + .trickset + .then_some(story_skater.tricks) + .flatten(), + }, + ), + }, + } +} + +async fn pre_process>( + backup_dir: P, + source: thug_pro::Cas, + components: Components, +) -> Result<(Arc, PathBuf)> { + let backup_dir = backup_dir.as_ref(); + fs::create_dir_all(backup_dir).await?; + let transform = Arc::new(make_transform(&source, components)); + + tasks::write( + Arc::clone(&transform), + backup_dir.join("transform.ron"), + Format::Ron, + ) + .await?; + + Ok((Arc::clone(&transform), PathBuf::from(backup_dir))) +} + +async fn process_entry>( + entry: save::Entry, + backup_dir: P, + transform: Arc, +) -> Result<()> { + let backup_dir = backup_dir.as_ref(); + + let backup_entry = entry.with_dir(backup_dir); + let backup_filepath = backup_entry.filepath(); + + let filepath = entry.filepath(); + + log::info!("backing up {:?} to {:?}", filepath, backup_filepath); + fs::copy(&filepath, &backup_filepath).await?; + + let mut ska = thug_pro::Ska::read_from(&entry)?; + + transform.modify(&mut ska)?; + ska.write_to(&entry)?; + + log::info!("overwrote save at {:?}", filepath); + + entry.overwrite_metadata()?; + + Ok(()) +} + +async fn pick_saves_dir() -> Option { + Some(AsyncFileDialog::new().pick_folder().await?.path().into()) +} diff --git a/cascade_app/src/fonts.rs b/cascade_app/src/fonts.rs new file mode 100644 index 0000000..38481f2 --- /dev/null +++ b/cascade_app/src/fonts.rs @@ -0,0 +1,29 @@ +//pub const IOSEVKA_REGULAR_BYTES: &[u8] = +// include_bytes!("../../resources/IosevkaNerdFont-Regular.ttf"); +//pub const IOSEVKA_BOLD_BYTES: &[u8] = +// include_bytes!("../../resources/IosevkaNerdFont-Bold.ttf"); + +//pub static IOSEVKA_REGULAR: Font = Font { +// family: Family::Name("Iosevka Nerd Font"), +// weight: Weight::Normal, +// stretch: Stretch::Normal, +// style: Style::Normal, +//}; +//pub static IOSEVKA_BOLD: Font = Font { +// family: Family::Name("Iosevka Nerd Font"), +// weight: Weight::Bold, +// stretch: Stretch::Normal, +// style: Style::Normal, +//}; +use iced::{ + font::{Family, Stretch, Style, Weight}, + Font, +}; + +pub const ICONS_FONT_BYTES: &[u8] = include_bytes!("../../resources/icons.ttf"); +pub const ICONS_FONT: Font = Font { + family: Family::Name("icons"), + weight: Weight::Normal, + stretch: Stretch::Normal, + style: Style::Normal, +}; diff --git a/cascade_app/src/main.rs b/cascade_app/src/main.rs new file mode 100644 index 0000000..3aec4e1 --- /dev/null +++ b/cascade_app/src/main.rs @@ -0,0 +1,135 @@ +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] +#![feature(error_generic_member_access, path_add_extension)] + +use std::{io, path::Path, result}; + +use app::Cascade; +use clap::Parser; +use config::{Config, Selections}; +use fern::colors::{Color, ColoredLevelConfig}; +use iced::{window, Size}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +mod app; +mod config; +mod dashboard; +mod fonts; +mod paths; +mod tasks; +mod theme; +mod widget; + +pub use config::Theme; + +pub type Renderer = iced::Renderer; +pub type Element<'a, Message> = iced::Element<'a, Message, Theme, Renderer>; +pub type Content<'a, Message> = + iced::widget::pane_grid::Content<'a, Message, Theme, Renderer>; +pub type TitleBar<'a, Message> = + iced::widget::pane_grid::TitleBar<'a, Message, Theme, Renderer>; +pub type Column<'a, Message> = + iced::widget::Column<'a, Message, Theme, Renderer>; +pub type Row<'a, Message> = iced::widget::Row<'a, Message, Theme, Renderer>; +pub type Text<'a> = iced::widget::Text<'a, Theme, Renderer>; +pub type Container<'a, Message> = + iced::widget::Container<'a, Message, Theme, Renderer>; +pub type Button<'a, Message> = iced::widget::Button<'a, Message, Theme>; + +const CASCADE_ICON_BYTES: &[u8] = include_bytes!("../../resources/cascade.ico"); + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("an io error occurred: {0}")] + Io(#[from] io::Error), + + #[error("an logging error occurred: {0}")] + Log(#[from] log::SetLoggerError), + + #[error("a gui error occurred: {0}")] + Gui(#[from] iced::Error), + + #[error("a path error occurred: {0}")] + Paths(#[from] paths::Error), +} + +type Result = result::Result; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + #[arg(short, long, default_value_t = false)] + debug: bool, +} + +fn configure_logging(path: impl AsRef) -> Result<()> { + let colors = ColoredLevelConfig::new().info(Color::Green); + + fern::Dispatch::new() + .format(move |out, message, record| { + let time = OffsetDateTime::now_local() + .unwrap_or(OffsetDateTime::now_utc()) + .format(&Rfc3339) + .unwrap_or("".to_string()); + + out.finish(format_args!( + "[{}] {} [{}] {}", + time, + record.target(), + colors.color(record.level()), + message, + )) + }) + .chain( + fern::Dispatch::new() + .level(log::LevelFilter::Info) + .level_for("wgpu_core", log::LevelFilter::Error) + .level_for("wgpu_hal", log::LevelFilter::Error) + .level_for("iced_winit", log::LevelFilter::Error) + .chain(io::stdout()) + .chain(fern::log_file(&path)?), + ) + .apply()?; + + log::info!("logging to {:?}", path.as_ref()); + + Ok(()) +} + +fn main() -> Result<()> { + let Args { debug } = Args::parse(); + + let cascade_dir = + paths::cascade_dir().expect("could not determine cascade dir"); + + configure_logging(paths::log(&cascade_dir))?; + + let config = Config::load(paths::config(&cascade_dir)).unwrap_or_default(); + log::info!("loaded config: {:?}", config); + + let selections = + Selections::load(paths::selections(&cascade_dir)).unwrap_or_default(); + log::info!("loaded selections: {:?}", selections); + + let theme = Theme::load(paths::theme(&cascade_dir)).unwrap_or_default(); + log::info!("loaded theme: {:?}", theme); + + iced::application("cascade", Cascade::update, Cascade::view) + .theme(Cascade::theme) + .window(window::Settings { + min_size: Some(Size::new(720., 520.)), + icon: window::icon::from_file_data( + CASCADE_ICON_BYTES, + Some(image::ImageFormat::Ico), + ) + .ok(), + ..Default::default() + }) + .font(fonts::ICONS_FONT_BYTES) + .scale_factor(Cascade::scale_factor) + .subscription(Cascade::subscription) + .run_with(move || { + Cascade::new((cascade_dir, config, selections, theme, debug)) + })?; + + Ok(()) +} diff --git a/cascade_app/src/paths.rs b/cascade_app/src/paths.rs new file mode 100644 index 0000000..8106687 --- /dev/null +++ b/cascade_app/src/paths.rs @@ -0,0 +1,123 @@ +use std::{ + env, fs, io, + path::{Path, PathBuf}, + result, +}; + +const CONFIG_FILENAME: &'static str = "cascade.toml"; +const SELECTIONS_FILENAME: &'static str = "selections.ron"; +const THEME_FILENAME: &'static str = "theme.toml"; +const LOG_FILENAME: &'static str = "cascade.log"; + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("an io error occurred: {0}")] + Io(io::ErrorKind), + + #[error("no home directory was found")] + NoHomeDir, + + #[error("no thug pro directory was found")] + NoThugProDir, + + #[error("no thug pro saves directory was found")] + NoThugProSavesDir, + + #[error("could not determine cwd")] + Cwd, +} + +pub type Result = result::Result; + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Io(value.kind()) + } +} + +fn local_appdata_dir() -> Result { + // %localappdata%/ + if let Some(user_dirs) = directories::BaseDirs::new() { + Ok(user_dirs.data_local_dir().into()) + } else { + Err(Error::NoHomeDir) + } +} + +pub fn default_thug_pro_dir() -> Result { + // %localappdata%/THUG Pro/ + let path = local_appdata_dir().map(|dir| dir.join("THUG Pro"))?; + + match path.is_dir() { + true => Ok(path), + false => Err(Error::NoThugProDir), + } +} + +pub fn default_saves_dir() -> Result { + // %localappdata%/THUG Pro/Save/ + let path = default_thug_pro_dir().map(|dir| dir.join("Save"))?; + + match path.is_dir() { + true => Ok(path), + false => Err(Error::NoThugProSavesDir), + } +} + +fn cwd() -> Result { + let exe = env::current_exe()?; + Ok(exe.parent().ok_or(Error::Cwd)?.into()) +} + +fn portable_dir() -> Option { + let cwd = cwd().ok()?; + let config_path = cwd.join(CONFIG_FILENAME); + + match config_path.is_file() { + true => Some(cwd.to_path_buf()), + false => None, + } +} + +fn default_cascade_dir() -> Result { + // %localappdata%/cascade/ + let path = local_appdata_dir().map(|dir| dir.join("cascade"))?; + + if !path.is_dir() { + fs::create_dir_all(&path)?; + } + + Ok(path) +} + +pub fn cascade_dir() -> Result { + match portable_dir() { + Some(dir) => Ok(dir), + None => default_cascade_dir().or_else(|_| cwd()), + } +} + +pub fn backup_dir(cascade_dir: impl AsRef) -> PathBuf { + // %localappdata%/cascade/backup/ + cascade_dir.as_ref().join("backup") +} + +pub fn config(cascade_dir: impl AsRef) -> PathBuf { + // %localappdata%/cascade/cascade.toml + cascade_dir.as_ref().join(CONFIG_FILENAME) +} + +pub fn selections(cascade_dir: impl AsRef) -> PathBuf { + // %localappdata%/cascade/selections.ron + cascade_dir.as_ref().join(SELECTIONS_FILENAME) +} + +pub fn theme(cascade_dir: impl AsRef) -> PathBuf { + // %localappdata%/cascade/theme.toml + cascade_dir.as_ref().join(THEME_FILENAME) +} + +pub fn log(cascade_dir: impl AsRef) -> PathBuf { + // %localappdata%/cascade/cascade.log + cascade_dir.as_ref().join(LOG_FILENAME) +} diff --git a/cascade_app/src/tasks.rs b/cascade_app/src/tasks.rs new file mode 100644 index 0000000..6140684 --- /dev/null +++ b/cascade_app/src/tasks.rs @@ -0,0 +1,47 @@ +use std::{io, path::Path}; + +use ron::de::SpannedError; +use serde::Serialize; +use tokio::{fs, io::AsyncWriteExt}; + +use crate::config::Format; + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("io error: {0}")] + Io(io::ErrorKind), + + #[error("ron serialization error: {0}")] + Ron(#[from] ron::Error), + + #[error("toml serialization error: {0}")] + Toml(#[from] toml::ser::Error), + + #[error("ron deserialization error: {0}")] + Spanned(#[from] SpannedError), +} + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Io(value.kind()) + } +} + +pub async fn write( + obj: impl Serialize, + to: impl AsRef, + format: Format, +) -> Result { + let mut file = fs::File::create(&to).await?; + + let contents = match format { + Format::Ron => ron::ser::to_string(&obj)?, + Format::Toml => toml::ser::to_string_pretty(&obj)?, + }; + + let bytes = file.write(&contents.as_bytes()).await?; + + log::info!("wrote {} bytes to {:?}", bytes, to.as_ref()); + + Ok(bytes) +} diff --git a/cascade_app/src/theme/button.rs b/cascade_app/src/theme/button.rs new file mode 100644 index 0000000..22ebbab --- /dev/null +++ b/cascade_app/src/theme/button.rs @@ -0,0 +1,168 @@ +use iced::{ + widget::button::{Catalog, Status, Style, StyleFn}, + Background, Border, Color, +}; + +use crate::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +fn default(theme: &Theme, status: Status) -> Style { + primary(theme, status) +} + +fn button(fg: Color, bg: Color, status: Status) -> Style { + match status { + Status::Active | Status::Pressed => Style { + background: Some(Background::Color(bg)), + text_color: fg, + border: Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + }, + Status::Hovered => Style { + background: Some(Background::Color(bg.scale_alpha(0.8))), + text_color: fg, + border: Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + }, + Status::Disabled => Style { + background: Some(Background::Color(bg.scale_alpha(0.2))), + text_color: fg.scale_alpha(0.5), + border: Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + }, + } +} + +pub fn primary(theme: &Theme, status: Status) -> Style { + button(theme.background, theme.primary, status) +} + +pub fn secondary(theme: &Theme, status: Status) -> Style { + button(theme.text, theme.secondary, status) +} + +// pub fn warning(theme: &Theme, status: Status) -> Style { +// button(theme.background, theme.warning, status) +// } +// +// pub fn danger(theme: &Theme, status: Status) -> Style { +// button(theme.background, theme.danger, status) +// } + +fn entry_button(bg: Color, fg: Color, accent: Color, status: Status) -> Style { + let bg_pressed = accent.scale_alpha(0.5); + let bg_hovered = accent.scale_alpha(0.2); + + match status { + Status::Active => Style { + background: Some(Background::Color(bg)), + text_color: fg, + border: Border { + radius: 4.0.into(), + color: accent, + ..Default::default() + }, + ..Default::default() + }, + Status::Pressed => Style { + background: Some(Background::Color(bg_pressed)), + text_color: fg, + border: Border { + radius: 4.0.into(), + width: 1.0, + color: accent, + ..Default::default() + }, + ..Default::default() + }, + Status::Hovered => Style { + background: Some(Background::Color(bg_hovered)), + text_color: fg, + border: Border { + radius: 4.0.into(), + width: 1.0, + color: accent, + ..Default::default() + }, + ..Default::default() + }, + Status::Disabled => Style { + background: Some(Background::Color(bg.scale_alpha(0.2))), + text_color: fg.scale_alpha(0.2), + border: Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + }, + } +} + +pub fn entry_selected(theme: &Theme, status: Status) -> Style { + entry_button( + theme.secondary.scale_alpha(0.4), + theme.text, + theme.primary, + status, + ) +} + +pub fn entry_unselected(theme: &Theme, status: Status) -> Style { + entry_button(theme.background, theme.text, theme.primary, status) +} + +pub fn entry_queued(theme: &Theme, status: Status) -> Style { + entry_button( + theme.secondary.scale_alpha(0.2), + theme.text, + theme.danger, + status, + ) +} + +pub fn entry_warning(theme: &Theme, status: Status) -> Style { + entry_button( + theme.warning.scale_alpha(0.2), + theme.text, + theme.warning, + status, + ) +} + +pub fn entry_success(theme: &Theme, status: Status) -> Style { + entry_button( + theme.success.scale_alpha(0.2), + theme.text, + theme.success, + status, + ) +} + +pub fn entry_danger(theme: &Theme, status: Status) -> Style { + entry_button( + theme.danger.scale_alpha(0.3), + theme.text, + theme.danger, + status, + ) +} diff --git a/cascade_app/src/theme/checkbox.rs b/cascade_app/src/theme/checkbox.rs new file mode 100644 index 0000000..a6e55f3 --- /dev/null +++ b/cascade_app/src/theme/checkbox.rs @@ -0,0 +1,118 @@ +use iced::{ + widget::checkbox::{Catalog, Status, Style, StyleFn}, + Background, Border, Color, +}; + +use crate::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(primary) + } + + fn style( + &self, + class: &Self::Class<'_>, + status: iced::widget::checkbox::Status, + ) -> Style { + class(self, status) + } +} + +fn checkbox(fg: Color, bg: Color, accent: Color, status: Status) -> Style { + let accent_hovered = accent.scale_alpha(0.7); + let accent_disabled = accent.scale_alpha(0.2); + let fg_disabled = fg.scale_alpha(0.5); + + let border_radius = 4.0; + + match status { + Status::Active { is_checked } => Style { + background: Background::Color(if is_checked { accent } else { bg }), + icon_color: bg, + border: Border { + color: accent, + width: 1.0, + radius: border_radius.into(), + }, + text_color: Some(fg), + }, + Status::Hovered { .. } => Style { + background: Background::Color(accent_hovered), + icon_color: bg, + border: Border { + color: accent_hovered, + width: 1.0, + radius: border_radius.into(), + }, + text_color: Some(fg), + }, + Status::Disabled { is_checked } => Style { + background: Background::Color(if is_checked { + accent_disabled + } else { + bg + }), + icon_color: bg, + border: Border { + color: accent_disabled, + width: 1.0, + radius: border_radius.into(), + }, + text_color: Some(fg_disabled), + }, + } +} + +pub fn primary(theme: &Theme, status: Status) -> Style { + checkbox(theme.text, theme.background, theme.primary, status) +} + +// pub fn secondary(theme: &Theme, status: Status) -> Style { +// checkbox(theme.text, theme.background, theme.secondary, status) +// } + +/// checkbox for entries +fn entry_checkbox( + bg: Color, + fg: Color, + border: Color, + status: Status, +) -> Style { + let fg_disabled = fg.scale_alpha(0.6); + let bg_disabled = bg.scale_alpha(0.3); + let border_disabled = border.scale_alpha(0.3); + + match status { + Status::Active { .. } | Status::Hovered { .. } => Style { + background: Background::Color(bg), + icon_color: fg, + border: Border { + color: border, + width: 1.0, + radius: 4.0.into(), + }, + text_color: Some(fg), + }, + Status::Disabled { .. } => Style { + background: Background::Color(bg_disabled), + icon_color: fg_disabled, + border: Border { + color: border_disabled, + width: 1.0, + radius: 4.0.into(), + }, + text_color: Some(fg), + }, + } +} + +pub fn entry_selected(theme: &Theme, status: Status) -> Style { + entry_checkbox(theme.primary, theme.background, theme.primary, status) +} + +pub fn entry_unselected(theme: &Theme, status: Status) -> Style { + entry_checkbox(theme.background, theme.primary, theme.primary, status) +} diff --git a/cascade_app/src/theme/container.rs b/cascade_app/src/theme/container.rs new file mode 100644 index 0000000..20fe2ca --- /dev/null +++ b/cascade_app/src/theme/container.rs @@ -0,0 +1,51 @@ +use iced::{ + widget::container::{transparent, Catalog, Style, StyleFn}, + Background, Border, +}; + +use crate::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(transparent) + } + + fn style(&self, class: &Self::Class<'_>) -> Style { + class(self) + } +} + +// pub fn none(_theme: &Theme) -> Style { +// Style { +// background: None, +// ..Default::default() +// } +// } + +pub fn monobox(theme: &Theme) -> Style { + Style { + background: Some(Background::Color(theme.secondary.scale_alpha(0.5))), + border: Border { + color: theme.secondary, + width: 1.0, + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + } +} + +pub fn bordered(theme: &Theme) -> Style { + Style { + background: Some(Background::Color(theme.background)), + border: Border { + color: theme.highlight, + width: 1.5, + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + } +} diff --git a/cascade_app/src/theme/mod.rs b/cascade_app/src/theme/mod.rs new file mode 100644 index 0000000..50ef0d8 --- /dev/null +++ b/cascade_app/src/theme/mod.rs @@ -0,0 +1,6 @@ +pub mod button; +pub mod checkbox; +pub mod container; +pub mod scrollable; +pub mod slider; +pub mod text; diff --git a/cascade_app/src/theme/scrollable.rs b/cascade_app/src/theme/scrollable.rs new file mode 100644 index 0000000..73ec895 --- /dev/null +++ b/cascade_app/src/theme/scrollable.rs @@ -0,0 +1,49 @@ +use iced::{ + widget::{ + container, + scrollable::{Catalog, Rail, Scroller, Status, Style, StyleFn}, + }, + Background, Border, Color, +}; + +use crate::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(primary) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn primary(theme: &Theme, status: Status) -> Style { + let rail = Rail { + background: Some(Background::Color(theme.secondary.scale_alpha(0.5))), + border: Border::default(), + scroller: Scroller { + color: theme.text.scale_alpha(0.5), + border: Border { + radius: 8.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, + }, + }; + + match status { + Status::Active | Status::Hovered { .. } | Status::Dragged { .. } => { + Style { + container: container::Style { + ..Default::default() + }, + vertical_rail: rail, + horizontal_rail: rail, + gap: None, + } + } + } +} diff --git a/cascade_app/src/theme/slider.rs b/cascade_app/src/theme/slider.rs new file mode 100644 index 0000000..03e8918 --- /dev/null +++ b/cascade_app/src/theme/slider.rs @@ -0,0 +1,47 @@ +use iced::{ + widget::slider::{ + Catalog, Handle, HandleShape, Rail, Status, Style, StyleFn, + }, + Background, Border, +}; + +use crate::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(primary) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn primary(theme: &Theme, status: Status) -> Style { + match status { + Status::Active | Status::Hovered { .. } | Status::Dragged { .. } => { + Style { + rail: Rail { + backgrounds: ( + Background::Color(theme.primary), + Background::Color(theme.background), + ), + width: 5., + border: Border { + color: theme.secondary, + width: 5., + radius: 8.0.into(), + }, + }, + handle: Handle { + shape: HandleShape::Circle { radius: 8.0.into() }, + background: Background::Color(theme.background), + border_width: 8.0.into(), + border_color: theme.primary, + }, + } + } + } +} diff --git a/cascade_app/src/theme/text.rs b/cascade_app/src/theme/text.rs new file mode 100644 index 0000000..592343f --- /dev/null +++ b/cascade_app/src/theme/text.rs @@ -0,0 +1,25 @@ +use iced::widget::text::{Catalog, Style, StyleFn}; + +use crate::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(none) + } + + fn style(&self, class: &Self::Class<'_>) -> Style { + class(self) + } +} + +pub fn none(_theme: &Theme) -> Style { + Style { color: None } +} + +// pub fn secondary(theme: &Theme) -> Style { +// Style { +// color: Some(theme.secondary), +// } +// } diff --git a/cascade_app/src/widget/entry.rs b/cascade_app/src/widget/entry.rs new file mode 100644 index 0000000..4f0af8a --- /dev/null +++ b/cascade_app/src/widget/entry.rs @@ -0,0 +1,51 @@ +use cascade::save; +use iced::{ + widget::{button, checkbox, text}, + Length, +}; + +use crate::{theme, Element, Row}; + +pub fn selectable<'a, Message>( + entry: &save::Entry, + selected: bool, + on_press_maybe: Option, +) -> Element<'a, Message> +where + Message: 'a + Clone, +{ + let checkbox = checkbox("", selected) + .style(match selected { + true => theme::checkbox::entry_selected, + false => theme::checkbox::entry_unselected, + }) + .on_toggle_maybe( + on_press_maybe + .clone() + .map(|on_press| move |_| on_press.clone()), + ); + + let name = entry.name.clone(); + button(Row::new().push(checkbox).push(text(name))) + .style(match selected { + true => theme::button::entry_selected, + false => theme::button::entry_unselected, + }) + .on_press_maybe(on_press_maybe) + .width(Length::Fill) + .into() +} + +// pub fn queued<'a, Message>( +// name: String, +// on_press_maybe: Option, +// ) -> Element<'a, Message> +// where +// Message: 'a + Clone, +// { +// button(text(name)) +// .style(theme::button::entry_queued) +// .on_press_maybe(on_press_maybe) +// .width(Length::Fill) +// .into() +// } diff --git a/cascade_app/src/widget/heading.rs b/cascade_app/src/widget/heading.rs new file mode 100644 index 0000000..e2e785c --- /dev/null +++ b/cascade_app/src/widget/heading.rs @@ -0,0 +1,13 @@ +use iced::{font::Weight, widget::text, Font}; + +use crate::Element; + +pub fn heading<'a, M>(label: impl ToString) -> Element<'a, M> { + text(label.to_string()) + .size(32) + .font(Font { + weight: Weight::Bold, + ..Default::default() + }) + .into() +} diff --git a/cascade_app/src/widget/mod.rs b/cascade_app/src/widget/mod.rs new file mode 100644 index 0000000..ddc5e8e --- /dev/null +++ b/cascade_app/src/widget/mod.rs @@ -0,0 +1,3 @@ +pub mod entry; +mod heading; +pub use heading::heading; diff --git a/cascade_cli/Cargo.toml b/cascade_cli/Cargo.toml deleted file mode 100644 index 006d3a7..0000000 --- a/cascade_cli/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "cascade_cli" -version = "0.2.0" -edition = "2021" -build = "build.rs" - -[[bin]] -name = "cascade" -path = "src/main.rs" - -[dependencies] -cascade = { path = "../cascade" } -cascade_gui = { path = "../cascade_gui" } - -clap = { version = "4.2.7", features = ["derive", "cargo"] } -thiserror = "1.0.50" - -[target.'cfg(windows)'.build-dependencies] -winres = "0.1.12" diff --git a/cascade_cli/src/error.rs b/cascade_cli/src/error.rs deleted file mode 100644 index 814af11..0000000 --- a/cascade_cli/src/error.rs +++ /dev/null @@ -1,14 +0,0 @@ -use std::{backtrace::Backtrace, fmt::Debug}; - -use cascade_gui::error::CascadeGuiError; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum CascadeCliError { - #[error("a gui error occurred: {source}")] - Gui { - #[from] - source: CascadeGuiError, - backtrace: Backtrace, - }, -} diff --git a/cascade_cli/src/main.rs b/cascade_cli/src/main.rs deleted file mode 100644 index fd311eb..0000000 --- a/cascade_cli/src/main.rs +++ /dev/null @@ -1,28 +0,0 @@ -#![windows_subsystem = "windows"] -#![feature(error_generic_member_access)] - -use clap::{Parser, Subcommand}; -use error::CascadeCliError; - -mod error; - -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -struct Cli { - #[command(subcommand)] - command: Option, -} - -// TODO: implement actual CLI commands -#[derive(Subcommand)] -enum Commands { - Gui, -} - -pub fn main() -> Result<(), CascadeCliError> { - let cli = Cli::parse(); - - match &cli.command { - Some(Commands::Gui) | None => Ok(cascade_gui::run()?), - } -} diff --git a/cascade_gui/Cargo.toml b/cascade_gui/Cargo.toml deleted file mode 100644 index c3e478f..0000000 --- a/cascade_gui/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "cascade_gui" -version = "0.2.0" -edition = "2021" - -[dependencies] -cascade = { path = "../cascade" } - -dark-light = "1.0.0" -directories = "5.0.1" -enum-iterator = "1.4.1" -fern = { version = "0.6.2", features = ["colored"] } -hex-literal = "0.4.1" -iced = { version = "0.10.0", default-features = false, features = ["lazy", "image"] } -iced_aw = { version = "0.7.0", default-features = false, features = ["icons"] } -log = "0.4.20" -rfd = "0.12.1" -serde = "1.0.193" -thiserror = "1.0.50" -time = { version = "0.3.30", features = ["local-offset", "formatting"] } -toml = "0.8.8" diff --git a/cascade_gui/src/about.rs b/cascade_gui/src/about.rs deleted file mode 100644 index 9d8f529..0000000 --- a/cascade_gui/src/about.rs +++ /dev/null @@ -1 +0,0 @@ -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/cascade_gui/src/config.rs b/cascade_gui/src/config.rs deleted file mode 100644 index 4b24ea4..0000000 --- a/cascade_gui/src/config.rs +++ /dev/null @@ -1,349 +0,0 @@ -use std::{ - backtrace::Backtrace, - fmt, fs, - io::{self, Write}, - path::PathBuf, -}; - -use enum_iterator::Sequence; -use hex_literal::hex; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::paths; - -#[derive(Error, Debug)] -pub enum ConfigError { - #[error("toml deserialization operation failed")] - TomlDeserialize { - #[from] - source: toml::de::Error, - backtrace: Backtrace, - }, - - #[error("toml serialization operation failed")] - TomlSerialize { - #[from] - source: toml::ser::Error, - backtrace: Backtrace, - }, - - #[error("an io error occurred: {source}")] - Io { - #[from] - source: io::Error, - backtrace: Backtrace, - }, - - #[error("an error finding paths occurred: {source}")] - Paths { - #[from] - source: paths::PathError, - backtrace: Backtrace, - }, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CascadeConfig { - pub paths: CascadePaths, - pub theme: CascadeTheme, -} - -impl CascadeConfig { - pub fn load() -> Result { - let path = paths::get_config_path()?; - let file = fs::File::open(&path)?; - - log::info!("reading config from {:?}", path); - - let contents = io::read_to_string(file)?; - let config = toml::from_str(contents.as_str())?; - - Ok(config) - } - - pub fn write(&self) -> Result<(), ConfigError> { - let path = paths::get_config_path()?; - let mut file = fs::File::create(&path)?; - - log::info!("writing config to {:?}", path); - - let contents = toml::to_string(&self)?; - write!(file, "{}", contents)?; - - Ok(()) - } - - pub fn load_or_create() -> Result { - match Self::load() { - Ok(config) => Ok(config), - Err(err) => match err { - // if io error is file not found, that's fine, just create a default config - ConfigError::Io { source, .. } - if (source.kind() == io::ErrorKind::NotFound) => - { - log::info!("no config detected"); - - let config: Self = Default::default(); - config.write()?; - - Ok(config) - } - _ => Err(err), - }, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CascadePaths { - pub saves_dir: Option, - pub backup_dir: Option, - pub trickset_path: Option, -} - -impl Default for CascadePaths { - fn default() -> Self { - let saves_dir = match paths::detect_thugpro_saves_dir() { - Ok(dir) => { - log::info!("autodetected thug pro saves dir at {:?}", dir); - Some(dir) - } - Err(err) => { - log::warn!("could not autodetect thug pro saves dir: {}", err); - None - } - }; - - let backup_dir = match paths::default_backup_path() { - Ok(dir) => { - log::info!("defaulting to backup directory at {:?}", dir); - Some(dir) - } - Err(err) => { - log::warn!("could not use default backup dir: {}", err); - None - } - }; - - let trickset_path = match paths::default_trickset_path() { - Ok(path) => { - log::info!("defaulting to trickset at {:?}", path); - Some(path) - } - Err(err) => { - log::warn!("could not use default trickset path: {}", err); - None - } - }; - - Self { - saves_dir, - backup_dir, - trickset_path, - } - } -} - -#[derive(Debug, Clone, Copy)] -pub struct RgbColor { - pub red: u8, - pub green: u8, - pub blue: u8, -} - -pub struct CascadePalette { - pub background: RgbColor, - pub text: RgbColor, - pub subtext: RgbColor, - pub rosewater: RgbColor, - pub flamingo: RgbColor, - pub pink: RgbColor, - pub mauve: RgbColor, - pub red: RgbColor, - pub maroon: RgbColor, - pub peach: RgbColor, - pub yellow: RgbColor, - pub green: RgbColor, - pub teal: RgbColor, - pub sky: RgbColor, - pub sapphire: RgbColor, - pub blue: RgbColor, - pub lavender: RgbColor, -} - -macro_rules! hexcolor { - ($code:literal) => {{ - let [red, green, blue] = hex!($code); - RgbColor { red, green, blue } - }}; -} - -impl CascadePalette { - pub fn light() -> Self { - Self { - background: hexcolor!("eff1f5"), - text: hexcolor!("4c4f69"), - subtext: hexcolor!("6c6f85"), - rosewater: hexcolor!("dc8a78"), - flamingo: hexcolor!("dd7878"), - pink: hexcolor!("ea76cb"), - mauve: hexcolor!("8839ef"), - red: hexcolor!("d20f39"), - maroon: hexcolor!("e64553"), - peach: hexcolor!("fe640b"), - yellow: hexcolor!("df8e1d"), - green: hexcolor!("40a02b"), - teal: hexcolor!("179299"), - sky: hexcolor!("04a5e5"), - sapphire: hexcolor!("209fb5"), - blue: hexcolor!("1e66f5"), - lavender: hexcolor!("7287fd"), - } - } - - pub fn dark() -> Self { - Self { - background: hexcolor!("24273a"), - text: hexcolor!("cad3f5"), - subtext: hexcolor!("a5adcb"), - rosewater: hexcolor!("f4dbd6"), - flamingo: hexcolor!("f0c6c6"), - pink: hexcolor!("f5bde6"), - mauve: hexcolor!("c6a0f6"), - red: hexcolor!("ed8796"), - maroon: hexcolor!("ee99a0"), - peach: hexcolor!("f5a97f"), - yellow: hexcolor!("eed49f"), - green: hexcolor!("a6da95"), - teal: hexcolor!("8bd5ca"), - sky: hexcolor!("91d7e3"), - sapphire: hexcolor!("7dc4e4"), - blue: hexcolor!("8aadf4"), - lavender: hexcolor!("b7bdf8"), - } - } - - pub fn get_color(&self, color: CascadeColor) -> RgbColor { - match color { - CascadeColor::Rosewater => self.rosewater, - CascadeColor::Flamingo => self.flamingo, - CascadeColor::Pink => self.pink, - CascadeColor::Mauve => self.mauve, - CascadeColor::Red => self.red, - CascadeColor::Maroon => self.maroon, - CascadeColor::Peach => self.peach, - CascadeColor::Yellow => self.yellow, - CascadeColor::Green => self.green, - CascadeColor::Teal => self.teal, - CascadeColor::Sky => self.sky, - CascadeColor::Sapphire => self.sapphire, - CascadeColor::Blue => self.blue, - CascadeColor::Lavender => self.lavender, - } - } -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] -pub struct CascadeTheme { - pub background: CascadeBackground, - pub color: CascadeColor, -} - -#[derive( - Debug, Clone, Copy, Default, PartialEq, Eq, Sequence, Serialize, Deserialize, -)] -pub enum CascadeBackground { - Light, - Dark, - #[default] - System, -} - -impl CascadeBackground { - pub fn get_palette(&self) -> CascadePalette { - match self { - CascadeBackground::Light => CascadePalette::light(), - CascadeBackground::Dark => CascadePalette::dark(), - CascadeBackground::System => { - // autodetect dark/light theme on system - let mode = dark_light::detect(); - - match mode { - dark_light::Mode::Dark => { - log::info!("autodetected system dark theme"); - CascadePalette::dark() - } - dark_light::Mode::Light => { - log::info!("autodetected system light theme"); - CascadePalette::light() - } - dark_light::Mode::Default => { - log::warn!("could not autodetect system theme; defaulting to light"); - CascadePalette::light() - } - } - } - } - } -} - -#[derive( - Debug, Clone, Copy, Default, PartialEq, Eq, Sequence, Serialize, Deserialize, -)] -pub enum CascadeColor { - Rosewater, - Flamingo, - Pink, - Mauve, - Red, - Maroon, - Peach, - Yellow, - Green, - Teal, - Sky, - Sapphire, - #[default] - Blue, - Lavender, -} - -impl fmt::Display for CascadeBackground { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - CascadeBackground::Light => "light", - CascadeBackground::Dark => "dark", - CascadeBackground::System => "system", - } - ) - } -} - -impl fmt::Display for CascadeColor { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - CascadeColor::Rosewater => "rosewater", - CascadeColor::Flamingo => "flamingo", - CascadeColor::Pink => "pink", - CascadeColor::Mauve => "mauve", - CascadeColor::Red => "red", - CascadeColor::Maroon => "maroon", - CascadeColor::Peach => "peach", - CascadeColor::Yellow => "yellow", - CascadeColor::Green => "green", - CascadeColor::Teal => "teal", - CascadeColor::Sky => "sky", - CascadeColor::Sapphire => "sapphire", - CascadeColor::Blue => "blue", - CascadeColor::Lavender => "lavender", - }, - ) - } -} diff --git a/cascade_gui/src/error.rs b/cascade_gui/src/error.rs deleted file mode 100644 index 335b560..0000000 --- a/cascade_gui/src/error.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::{backtrace::Backtrace, fmt::Debug, io}; - -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum CascadeGuiError { - #[error("an io error occurred: {source}")] - Io { - #[from] - source: io::Error, - backtrace: Backtrace, - }, - - #[error("an logging error occurred: {source}")] - Log { - #[from] - source: log::SetLoggerError, - backtrace: Backtrace, - }, - - #[error("a gui error occurred: {source}")] - Gui { - #[from] - source: iced::Error, - backtrace: Backtrace, - }, -} diff --git a/cascade_gui/src/gui.rs b/cascade_gui/src/gui.rs deleted file mode 100644 index 7833f49..0000000 --- a/cascade_gui/src/gui.rs +++ /dev/null @@ -1,261 +0,0 @@ -use std::{ - backtrace::Backtrace, - default::Default, - fmt::{self, Debug}, -}; - -use enum_iterator::{all, Sequence}; -use iced::{ - alignment, font, - widget::{column, container, row, text, Button}, - window, Application, Command, Element, Length, -}; -use thiserror::Error; - -use crate::{ - about, - config::{CascadeConfig, ConfigError}, - resources, - theming::config_to_iced_theme, - views::{ - config::{ConfigMessage, ConfigView}, - dashboard::{DashboardMessage, DashboardView}, - View, - }, -}; - -#[derive(Error, Debug)] -pub enum CascadeError { - #[error("an error occurred when loading/writing config")] - ConfigError { - #[from] - source: ConfigError, - backtrace: Backtrace, - }, -} - -#[derive(Debug, Clone)] -pub enum CascadeMessage { - // cannot put Result in this variant - // because they do not implement Clone - WindowIconLoaded(bool), - IconFontLoaded(Result<(), font::Error>), - - ComponentChanged(ViewType), - - // View messages - Dashboard(DashboardMessage), - Config(ConfigMessage), -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Sequence)] -pub enum ViewType { - #[default] - Dashboard, - Config, -} - -impl fmt::Display for ViewType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - ViewType::Dashboard => "dashboard", - ViewType::Config => "config", - } - ) - } -} - -struct ViewManager { - current_view: ViewType, - - dashboard: DashboardView, - config: ConfigView, -} - -impl ViewManager { - fn new(config: &CascadeConfig) -> Self { - ViewManager { - current_view: Default::default(), - - dashboard: DashboardView::new(config.clone()), - config: ConfigView::new(config.clone()), - } - } - - fn change_component(&mut self, view: ViewType) { - self.current_view = view; - } - - fn view<'a>(&'a self) -> Element { - match self.current_view { - ViewType::Dashboard => { - self.dashboard.view().map(CascadeMessage::Dashboard).into() - } - - ViewType::Config => { - self.config.view().map(CascadeMessage::Config).into() - } - } - } -} - -pub struct Cascade { - view_manager: ViewManager, - config: CascadeConfig, - theme: iced::Theme, -} - -impl Cascade { - fn settings() -> iced::Settings<()> { - iced::Settings { - window: window::Settings { - size: (720, 520), - min_size: Some((720, 520)), - ..Default::default() - }, - ..Default::default() - } - } - - pub fn start() -> iced::Result { - Self::run(Self::settings()) - } - - fn set_config(&mut self, config: CascadeConfig) { - // TODO: code reuse - self.theme = config_to_iced_theme(&config.theme); - // TODO: lmfao what is even the point of the view manager - self.view_manager.dashboard.set_config(config.clone()); - self.view_manager.config.set_config(config.clone()); - self.config = config; - - if let Err(err) = self.config.write() { - log::error!("could not write config: {err}") - } - } -} - -impl Application for Cascade { - type Message = CascadeMessage; - type Theme = iced::Theme; - type Executor = iced::executor::Default; - type Flags = (); - - fn title(&self) -> String { - format!("cascade v{}", about::VERSION) - } - - fn new(_flags: ()) -> (Cascade, Command) { - let config = CascadeConfig::load_or_create().unwrap_or_default(); - let theme = config_to_iced_theme(&config.theme); - let view_manager = ViewManager::new(&config); - - ( - Cascade { - view_manager, - config, - theme, - }, - // - Command::batch(vec![ - // load icon font - font::load(iced_aw::graphics::icons::ICON_FONT_BYTES) - .map(CascadeMessage::IconFontLoaded), - // load window icon - window::icon::from_file_data( - resources::WINDOW_ICON_BYTES, - None, - ) - .map(|icon| { - window::change_icon(icon) - .map(CascadeMessage::WindowIconLoaded) - }) - // fucky workaround since window::icon::Icon and window::icon::Error - // don't implement Clone - .unwrap_or_else(|err| { - log::error!("could not load window icon: {}", err); - Command::none() - }), - ]), - ) - } - - fn update( - &mut self, - event: CascadeMessage, - ) -> iced::Command { - match event { - CascadeMessage::IconFontLoaded(Ok(_)) => { - log::info!("icon font loaded"); - } - CascadeMessage::IconFontLoaded(Err(err)) => { - log::error!("could not load icon font: {:?}", err); - } - CascadeMessage::WindowIconLoaded(false) => (), - CascadeMessage::WindowIconLoaded(true) => { - log::info!("window icon loaded"); - } - CascadeMessage::ComponentChanged(component) => { - self.view_manager.change_component(component); - } - CascadeMessage::Dashboard(inner_message) => { - self.view_manager.dashboard.update(inner_message); - } - - CascadeMessage::Config(inner_message) => { - self.view_manager - .config - .update(inner_message) - .map(|message| match message { - ConfigMessage::ConfigChanged(config) => { - self.set_config(config) - } - _ => (), - }); - } - } - - Command::none() - } - - fn view(&self) -> Element { - let switcher: Element<_> = column![row(all::() - .map(|component_type| { - button(format!("{}", component_type).as_str()) - .on_press(CascadeMessage::ComponentChanged(component_type)) - .width(Length::Fill) - .into() - }) - .collect()) - .width(Length::Fill) - .spacing(2),] - .align_items(alignment::Alignment::Center) - .into(); - - let current_component = self.view_manager.view(); - - let fill_container: Element<_> = container(current_component) - .width(Length::Fill) - .height(Length::Fill) - .into(); - - let stack: Element<_> = column![switcher, fill_container].into(); - - stack //.explain(iced::Color::WHITE) - } - - fn theme(&self) -> iced::Theme { - self.theme.clone() - } -} - -fn button<'a, Message: Clone>(label: &str) -> Button<'a, Message> { - iced::widget::button( - text(label).horizontal_alignment(alignment::Horizontal::Center), - ) - .padding(10) - .width(100) -} diff --git a/cascade_gui/src/lib.rs b/cascade_gui/src/lib.rs deleted file mode 100644 index 1484b31..0000000 --- a/cascade_gui/src/lib.rs +++ /dev/null @@ -1,56 +0,0 @@ -#![feature(error_generic_member_access)] - -use std::{env, io, path::PathBuf}; - -use error::CascadeGuiError; -use fern::colors::{Color, ColoredLevelConfig}; -use gui::Cascade; -use time::{format_description::well_known::Rfc3339, OffsetDateTime}; - -mod about; -mod config; -pub mod error; -mod gui; -pub mod paths; -mod resources; -mod theming; -mod views; - -// TODO: turn this into CascadeResult or something -pub fn run() -> Result<(), CascadeGuiError> { - let colors = ColoredLevelConfig::new().info(Color::Green); - - let mut log_path = - paths::get_cascade_dir().unwrap_or(PathBuf::from(env::current_dir()?)); - - log_path.push("cascade.log"); - - fern::Dispatch::new() - .format(move |out, message, record| { - let time = OffsetDateTime::now_local() - .unwrap_or(OffsetDateTime::now_utc()) - .format(&Rfc3339) - .unwrap_or("".to_string()); - - out.finish(format_args!( - "[{}] {} [{}] {}", - time, - record.target(), - colors.color(record.level()), - message, - )) - }) - .chain( - fern::Dispatch::new() - .level(log::LevelFilter::Info) - // shut up - // TODO: should probably move this to cascade_gui - .level_for("wgpu_core", log::LevelFilter::Warn) - .level_for("wgpu_hal", log::LevelFilter::Warn) - .chain(io::stdout()) - .chain(fern::log_file(log_path)?), - ) - .apply()?; - - Ok(Cascade::start()?) -} diff --git a/cascade_gui/src/paths.rs b/cascade_gui/src/paths.rs deleted file mode 100644 index 4475b37..0000000 --- a/cascade_gui/src/paths.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::{backtrace::Backtrace, fs, io, path::PathBuf}; - -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum PathError { - #[error("an io error occurred: {source}")] - Io { - #[from] - source: io::Error, - backtrace: Backtrace, - }, - - #[error("no home directory was found")] - NoHomeDir, - - #[error("no thugpro directory was found")] - NoThugProDir, - - #[error("no thugpro saves directory was found")] - NoThugProSavesDir, -} - -pub fn detect_thugpro_dir() -> Result { - // %localappdata%/THUG Pro/ - let config_dir = directories::BaseDirs::new() - .map(|user_dirs| { - let local_appdata = user_dirs.data_local_dir(); - - let mut config_dir = PathBuf::new(); - - config_dir.push(local_appdata); - config_dir.push("THUG Pro"); - - config_dir - }) - .ok_or(PathError::NoHomeDir)?; - - config_dir - .is_dir() - .then(|| config_dir) - .ok_or(PathError::NoThugProDir) -} - -pub fn detect_thugpro_saves_dir() -> Result { - // %localappdata%/THUG Pro/Save/ - let mut saves_dir = detect_thugpro_dir()?; - saves_dir.push("Save"); - - saves_dir - .is_dir() - .then(|| saves_dir) - .ok_or(PathError::NoThugProSavesDir) -} - -pub fn get_cascade_dir() -> Result { - // %localappdata%/THUG Pro/.cascade/ - let mut cascade_dir = detect_thugpro_dir()?; - cascade_dir.push(".cascade"); - - if !cascade_dir.is_dir() { - fs::create_dir_all(&cascade_dir)?; - } - - Ok(cascade_dir) -} - -pub fn get_config_path() -> Result { - // %localappdata%/THUG Pro/.cascade/config.toml - let mut config_path = get_cascade_dir()?; - config_path.push("cascade.toml"); - - Ok(config_path) -} - -pub fn default_backup_path() -> Result { - // %localappdata%/THUG Pro/.cascade/backup/ - let mut backup_path = get_cascade_dir()?; - backup_path.push("backup"); - - if !backup_path.is_dir() { - fs::create_dir_all(&backup_path)?; - } - - Ok(backup_path) -} - -pub fn default_trickset_path() -> Result { - // %localappdata%/THUG Pro/.cascade/trickset.SKA - let mut trickset_path = get_cascade_dir()?; - trickset_path.push("trickset.SKA"); - - Ok(trickset_path) -} diff --git a/cascade_gui/src/resources.rs b/cascade_gui/src/resources.rs deleted file mode 100644 index 80a0d29..0000000 --- a/cascade_gui/src/resources.rs +++ /dev/null @@ -1,17 +0,0 @@ -use iced::{ - alignment, - widget::{text, Text}, - Font, -}; -use iced_aw::{graphics::icons::icon_to_char, Icon}; - -pub const WINDOW_ICON_BYTES: &[u8] = - include_bytes!("../../resources/cascade.ico"); -pub const ICONS_FONT: Font = Font::with_name("bootstrap-icons"); - -pub fn icon(_icon: Icon) -> Text<'static> { - text(icon_to_char(_icon).to_string()) - .font(ICONS_FONT) - .width(20) - .horizontal_alignment(alignment::Horizontal::Center) -} diff --git a/cascade_gui/src/theming.rs b/cascade_gui/src/theming.rs deleted file mode 100644 index e5a9b4b..0000000 --- a/cascade_gui/src/theming.rs +++ /dev/null @@ -1,34 +0,0 @@ -use crate::config::{CascadeTheme, RgbColor}; - -// TODO: move this to config -pub fn config_to_iced_color(config_color: RgbColor) -> iced::Color { - iced::Color::from_rgb8( - config_color.red, - config_color.green, - config_color.blue, - ) -} - -pub fn config_to_primary_color(theme: &CascadeTheme) -> iced::Color { - let palette = theme.background.get_palette(); - let color = palette.get_color(theme.color); - config_to_iced_color(color) -} - -pub fn config_to_subtext_color(theme: &CascadeTheme) -> iced::Color { - let palette = theme.background.get_palette(); - config_to_iced_color(palette.subtext) -} - -pub fn config_to_iced_theme(theme: &CascadeTheme) -> iced::Theme { - let palette = theme.background.get_palette(); - let primary = palette.get_color(theme.color); - - iced::Theme::custom(iced::theme::Palette { - background: config_to_iced_color(palette.background), - text: config_to_iced_color(palette.text), - primary: config_to_iced_color(primary), - danger: config_to_iced_color(palette.red), - success: config_to_iced_color(palette.green), - }) -} diff --git a/cascade_gui/src/views/config/mod.rs b/cascade_gui/src/views/config/mod.rs deleted file mode 100644 index ed4b53f..0000000 --- a/cascade_gui/src/views/config/mod.rs +++ /dev/null @@ -1,123 +0,0 @@ -use iced::{ - widget::{column, scrollable, text}, - Element, Renderer, -}; - -use self::{paths::PathsComponent, theme::ThemeComponent}; -use crate::{ - config::CascadeConfig, - theming::config_to_primary_color, - views::{ - config::{paths::PathsMessage, theme::ThemeMessage}, - View, - }, -}; - -mod paths; -mod theme; - -#[derive(Debug, Clone)] -pub enum ConfigMessage { - Paths(PathsMessage), - Theme(ThemeMessage), - - ConfigChanged(CascadeConfig), -} - -pub struct ConfigView { - config: CascadeConfig, - - paths: PathsComponent, - theme: ThemeComponent, - - primary_color: iced::Color, -} - -impl ConfigView { - pub fn new(config: CascadeConfig) -> Self { - let primary_color = config_to_primary_color(&config.theme); - - let paths = config.paths.clone(); - let theme = config.theme.clone(); - - Self { - config, - paths: PathsComponent::new(paths), - theme: ThemeComponent::new(theme), - primary_color, - } - } -} - -impl View for ConfigView { - type Message = ConfigMessage; - - fn title(&self) -> String { - "config".to_string() - } - - fn set_config(&mut self, config: CascadeConfig) { - self.primary_color = config_to_primary_color(&config.theme); - - self.paths.set_paths(config.paths.clone()); - self.theme.set_theme(config.theme.clone()); - - self.config = config; - } - - fn update(&mut self, message: ConfigMessage) -> Option { - match message { - ConfigMessage::Paths(message) => { - self.paths.update(message).map(|message| match message { - PathsMessage::PathsChanged(paths) => { - self.config.paths = paths; - ConfigMessage::ConfigChanged(self.config.clone()) - } - _ => ConfigMessage::Paths(message), - }) - } - - ConfigMessage::Theme(message) => { - self.theme.update(message).map(|message| match message { - ThemeMessage::ThemeChanged(theme) => { - self.config.theme = theme; - ConfigMessage::ConfigChanged(self.config.clone()) - } - _ => ConfigMessage::Theme(message), - }) - } - - _ => None, - } - } - - fn view<'a>(&'a self) -> Element { - let paths: Element<_> = column![ - text("paths") - .size(50) - .style(iced::theme::Text::Color(self.primary_color)), - self.paths - .view() - .map(|message| ConfigMessage::Paths(message)), - ] - .spacing(10) - .into(); - - let theme: Element<_> = column![ - text("theme") - .size(50) - .style(iced::theme::Text::Color(self.primary_color)), - self.theme - .view() - .map(|message| ConfigMessage::Theme(message)), - ] - .spacing(10) - .into(); - - let component: Element<_> = - scrollable(column![paths, theme].padding([20, 50]).spacing(20)) - .into(); - - component - } -} diff --git a/cascade_gui/src/views/config/paths.rs b/cascade_gui/src/views/config/paths.rs deleted file mode 100644 index 19ace2b..0000000 --- a/cascade_gui/src/views/config/paths.rs +++ /dev/null @@ -1,157 +0,0 @@ -use std::{fmt, path::PathBuf}; - -use enum_iterator::{all, Sequence}; -use iced::{ - alignment, - widget::{button, column, row, text}, - Element, Length, Renderer, -}; -use iced_aw::Icon; -use rfd::FileDialog; - -use crate::{ - config::{self, CascadePaths}, - resources, -}; - -#[derive(Debug, Clone)] -enum FileDialogType { - Directory, - File(String), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Sequence)] -pub enum FileDialogTarget { - Saves, - Backup, - Trickset, -} - -impl fmt::Display for FileDialogTarget { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - FileDialogTarget::Saves => "thugpro saves", - FileDialogTarget::Backup => "backup", - FileDialogTarget::Trickset => "trickset", - } - ) - } -} - -impl FileDialogTarget { - fn get_from_paths(&self, paths: &config::CascadePaths) -> Option { - match self { - FileDialogTarget::Saves => paths.saves_dir.clone(), - FileDialogTarget::Backup => paths.backup_dir.clone(), - FileDialogTarget::Trickset => paths.trickset_path.clone(), - } - } - - fn set_in_paths(&self, paths: &mut config::CascadePaths, path: PathBuf) { - match self { - FileDialogTarget::Saves => paths.saves_dir = Some(path), - FileDialogTarget::Backup => paths.backup_dir = Some(path), - FileDialogTarget::Trickset => paths.trickset_path = Some(path), - } - } - - fn dialog_type(&self) -> FileDialogType { - match self { - FileDialogTarget::Saves | FileDialogTarget::Backup => { - FileDialogType::Directory - } - FileDialogTarget::Trickset => { - FileDialogType::File("SKA".to_string()) - } - } - } -} - -#[derive(Debug, Clone)] -pub enum PathsMessage { - OpenFileDialog(FileDialogTarget), - - PathsChanged(CascadePaths), -} - -pub struct PathsComponent { - paths: CascadePaths, -} - -impl PathsComponent { - pub fn new(paths: CascadePaths) -> Self { - Self { paths } - } - - pub fn set_paths(&mut self, paths: CascadePaths) { - self.paths = paths; - } - - pub fn update(&mut self, message: PathsMessage) -> Option { - match message { - // this is a message we only send to the parent component - PathsMessage::PathsChanged(_) => None, - - PathsMessage::OpenFileDialog(target) => { - let dialog = FileDialog::new(); - - let selected_path = match target.dialog_type() { - FileDialogType::Directory => dialog.pick_folder(), - FileDialogType::File(extension) => dialog - .add_filter(extension.clone(), &[extension]) - .pick_file(), - }; - - match selected_path { - // if the user didn't click cancel - Some(path) => { - // TODO: lol dont do it this way - target.set_in_paths(&mut self.paths, path); - - Some(PathsMessage::PathsChanged(self.paths.clone())) - } - None => None, - } - } - } - } - - pub fn view(&self) -> Element { - column( - all::() - .map(move |target| { - column![ - text(format!("{}", target)), - row![ - button(resources::icon(Icon::Folder2Open)) - .on_press(PathsMessage::OpenFileDialog(target)) - .style(iced::theme::Button::Secondary), - button(text( - target - .get_from_paths(&self.paths) - .map(|path| path - .into_os_string() - .into_string() - .unwrap_or( - "".to_string() - )) - .unwrap_or("not set...".to_string()) - )) - .style(iced::theme::Button::Secondary) - .width(Length::Fill) - ] - .spacing(10) - .align_items(alignment::Alignment::Center) - ] - .spacing(5) - .into() - }) - .collect(), - ) - .spacing(10) - .into() - } -} diff --git a/cascade_gui/src/views/config/theme.rs b/cascade_gui/src/views/config/theme.rs deleted file mode 100644 index 133caf1..0000000 --- a/cascade_gui/src/views/config/theme.rs +++ /dev/null @@ -1,71 +0,0 @@ -use enum_iterator::all; -use iced::{ - widget::{column, pick_list, row, text}, - Element, Renderer, -}; - -use crate::config::{CascadeBackground, CascadeColor, CascadeTheme}; - -#[derive(Clone, Copy, Debug)] -pub enum ThemeMessage { - ColorChanged(CascadeColor), - BackgroundChanged(CascadeBackground), - - ThemeChanged(CascadeTheme), -} - -pub struct ThemeComponent { - theme: CascadeTheme, -} - -impl ThemeComponent { - pub fn new(theme: CascadeTheme) -> Self { - Self { theme } - } - - pub fn set_theme(&mut self, theme: CascadeTheme) { - self.theme = theme; - } - - pub fn update(&mut self, message: ThemeMessage) -> Option { - match message { - // this is a message we only send to the parent component - ThemeMessage::ThemeChanged(_) => None, - - ThemeMessage::ColorChanged(color) => { - self.theme.color = color; - Some(ThemeMessage::ThemeChanged(self.theme.clone())) - } - - ThemeMessage::BackgroundChanged(background) => { - self.theme.background = background; - Some(ThemeMessage::ThemeChanged(self.theme.clone())) - } - } - } - - pub fn view(&self) -> Element { - row![ - column![ - text("background"), - pick_list( - all::().collect::>(), - Some(self.theme.background), - ThemeMessage::BackgroundChanged - ), - ] - .spacing(5), - column![ - text("color"), - pick_list( - all::().collect::>(), - Some(self.theme.color), - ThemeMessage::ColorChanged - ), - ] - .spacing(5) - ] - .spacing(10) - .into() - } -} diff --git a/cascade_gui/src/views/dashboard.rs b/cascade_gui/src/views/dashboard.rs deleted file mode 100644 index 053c7da..0000000 --- a/cascade_gui/src/views/dashboard.rs +++ /dev/null @@ -1,208 +0,0 @@ -use std::{backtrace::Backtrace, io, path::PathBuf}; - -use cascade::actions::{self, ActionError}; -use iced::{ - alignment::{self, Alignment}, - widget::{button, column, row, text}, - Element, Length, Renderer, -}; -use rfd::FileDialog; -use thiserror::Error; - -use crate::{ - about, - config::CascadeConfig, - theming::{config_to_primary_color, config_to_subtext_color}, - views::View, -}; - -#[derive(Error, Debug)] -pub enum DashboardError { - #[error("saves path is not set!")] - SavesDirNotSet, - #[error("trickset path is not set!")] - TricksetNotSet, - #[error("backups path is not set!")] - BackupsDirNotSet, - #[error("an io error occurred: {source}")] - Io { - #[from] - source: io::Error, - backtrace: Backtrace, - }, - #[error("an action error occurred: {source}")] - Action { - #[from] - source: ActionError, - backtrace: Backtrace, - }, -} - -#[derive(Debug, Clone)] -pub enum DashboardMessage { - SetTrickset, - CopyTrickset, -} - -#[derive(Debug, Clone)] -pub struct DashboardView { - config: CascadeConfig, - - status_text: String, - - primary_color: iced::Color, - subtext_color: iced::Color, -} - -impl DashboardView { - pub fn new(config: CascadeConfig) -> Self { - // TODO: code reuse - let primary_color = config_to_primary_color(&config.theme); - let subtext_color = config_to_subtext_color(&config.theme); - - let status_text = "".to_string(); - - Self { - config, - primary_color, - subtext_color, - status_text, - } - } - - fn backup_dir(&self) -> Result { - self.config - .paths - .backup_dir - .clone() - .ok_or(DashboardError::BackupsDirNotSet) - } - - fn saves_dir(&self) -> Result { - self.config - .paths - .saves_dir - .clone() - .ok_or(DashboardError::SavesDirNotSet) - } - - fn trickset_path(&self) -> Result { - self.config - .paths - .trickset_path - .clone() - .ok_or(DashboardError::TricksetNotSet) - } - - fn set_trickset(&mut self) -> Result<(), DashboardError> { - let trickset_path = self.trickset_path()?; - - let dialog = - FileDialog::new().add_filter("SKA", &["SKA"]).set_directory( - self.config - .paths - .saves_dir - .clone() - .ok_or(DashboardError::SavesDirNotSet)?, - ); - - if let Some(selected_path) = dialog.pick_file() { - actions::set_trickset(trickset_path, selected_path)?; - self.status_text = "successfully set trickset".to_string(); - } - - Ok(()) - } - - fn copy_trickset(&mut self) -> Result<(), DashboardError> { - let saves_dir = self.saves_dir()?; - let backup_dir = self.backup_dir()?; - let trickset_path = self.trickset_path()?; - - let (num_successful_saves, num_all_saves) = - actions::copy_trickset(trickset_path, backup_dir, saves_dir)?; - - log::info!("return back to dashboard"); - - self.status_text = format!( - "successfully copied trickset to {}/{} saves", - num_successful_saves, num_all_saves - ); - - Ok(()) - } -} - -impl View for DashboardView { - type Message = DashboardMessage; - - fn title(&self) -> String { - "dashboard".to_string() - } - - fn set_config(&mut self, config: CascadeConfig) { - self.primary_color = config_to_primary_color(&config.theme); - self.subtext_color = config_to_subtext_color(&config.theme); - - self.config = config - } - - fn update(&mut self, message: Self::Message) -> Option { - match message { - DashboardMessage::SetTrickset => match self.set_trickset() { - Err(err) => self.status_text = err.to_string(), - Ok(_) => (), - }, - DashboardMessage::CopyTrickset => match self.copy_trickset() { - Err(err) => self.status_text = err.to_string(), - Ok(_) => {} - }, - } - None - } - - fn view(&self) -> Element { - row![column![ - // - column![ - row![ - text("cascade") - .size(100) - .style(iced::theme::Text::Color(self.primary_color)), - text(format!("v{}", about::VERSION)) - .size(30) - .style(iced::theme::Text::Color(self.subtext_color)) - ] - .align_items(Alignment::Center) - .spacing(10), - // - row![ - iced::widget::horizontal_space(5), - text("by borgy") - .style(iced::theme::Text::Color(self.subtext_color)), - ] - ], - // - row![ - button(text("set trickset").size(22)) - .padding([10, 10]) - .on_press(DashboardMessage::SetTrickset), - button(text("copy trickset to saves").size(22)) - .padding([10, 10]) - .on_press(DashboardMessage::CopyTrickset) - ] - .spacing(10), - text(self.status_text.clone()) - .style(iced::theme::Text::Color(self.subtext_color)) - .horizontal_alignment(alignment::Horizontal::Center), - ] - .width(Length::Fill) - .align_items(Alignment::Center) - .spacing(30),] - .padding(50) - .width(Length::Fill) - .height(Length::Fill) - .align_items(Alignment::Center) - .into() - } -} diff --git a/cascade_gui/src/views/mod.rs b/cascade_gui/src/views/mod.rs deleted file mode 100644 index a7ee232..0000000 --- a/cascade_gui/src/views/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub mod config; -pub mod dashboard; -use iced::Element; - -use crate::config::CascadeConfig; - -pub trait View { - type Message; - - fn title(&self) -> String; - - fn set_config(&mut self, config: CascadeConfig); - - fn update(&mut self, message: Self::Message) -> Option; - - fn view<'a>(&'a self) -> Element<'_, Self::Message>; -} diff --git a/resources/icons.ttf b/resources/icons.ttf new file mode 100644 index 0000000..9aef0b5 Binary files /dev/null and b/resources/icons.ttf differ diff --git a/resources/checksum_lookup.yaml b/resources/lut/checksum.yaml similarity index 100% rename from resources/checksum_lookup.yaml rename to resources/lut/checksum.yaml diff --git a/resources/lut/compressed16.yaml b/resources/lut/compressed16.yaml new file mode 100644 index 0000000..5b7f800 --- /dev/null +++ b/resources/lut/compressed16.yaml @@ -0,0 +1,30 @@ +- Classic +- texture_id +- texture_name +- string +- canvas_id +- font_id +- pos_x +- pos_y +- rot +- scale +- flip_h +- flip_s +- flip_v +- hsva +- layer_id +- recordtable +- bestcombos +- arcadescores +- highscores +- longestcombo +- longestgrind +- longestliptrick +- longestmanual +- voice +- select_icon +- file_name +- has_custom_tag +- tag_texture +- sticker_texture +- skater_family diff --git a/resources/compressed_lookup.yaml b/resources/lut/compressed8.yaml similarity index 91% rename from resources/compressed_lookup.yaml rename to resources/lut/compressed8.yaml index c4b7ec4..a25a304 100644 --- a/resources/compressed_lookup.yaml +++ b/resources/lut/compressed8.yaml @@ -1,4 +1,3 @@ -byte: - skater_m_lower_legs - skater_m_head - skater_m_torso @@ -251,34 +250,3 @@ byte: - number - initials - win_record -word: -- Classic -- texture_id -- texture_name -- string -- canvas_id -- font_id -- pos_x -- pos_y -- rot -- scale -- flip_h -- flip_s -- flip_v -- hsva -- layer_id -- recordtable -- bestcombos -- arcadescores -- highscores -- longestcombo -- longestgrind -- longestliptrick -- longestmanual -- voice -- select_icon -- file_name -- has_custom_tag -- tag_texture -- sticker_texture -- skater_family diff --git a/resources/lut/thug_pro.ron b/resources/lut/thug_pro.ron new file mode 100644 index 0000000..44be52e --- /dev/null +++ b/resources/lut/thug_pro.ron @@ -0,0 +1 @@ +(checksum:{"no_way_string":2741878497,"net":219539968,"LevelFlags":3237623119,"time":2422958010,"horse_time_limit":3242595374,"Hand":3634216304,"mechbullrider":2344981686,"game_type":2553595130,"Weeman":2480132835,"trickattack":4180164327,"careertotalscore":71582106,"profile_list":234850419,"classictotalscore":1813667385,"paulie":1971727027,"GapChecklists":2648171686,"eyes":1491454825,"target_score":1909281698,"Gene":4042274156,"body_shape":2166785263,"SoundOptions":2313918264,"voodoo":2614615828,"Muska":4002799131,"GlobalFlags":2827550258,"classicstats":1440178639,"is_hidden":669752221,"lockout_flags":2441547555,"Nigel":1223104115,"unlock_flag":4112613819,"SplitScreenPreferences":4151446591,"levelnum":441245190,"Jester":980666961,"Pros":3382209455,"PlayListForbiddenTrackFlags1":1499278485,"CASFileName":4083947640,"shrimpvendor":3706947751,"total_score":236215964,"difficulty_levels":2973604051,"currentChapter":4169430844,"MainVolume":1862364667,"chainsawskater":878693979,"Sparrow":2854551253,"rowley":2512645649,"ui_string":2525453327,"career":1302631291,"other":648530655,"vehicle_params":3656380639,"checksum":563093605,"DisplayName":954348377,"thomas":3782010945,"layer_infos":2589978787,"can_spin":1384844604,"Burnquist":1257604289,"get_some_string":1160097213,"viewport_type":1870841630,"skatestyle":1996305384,"Vallely":2906402656,"total_goals_possible":2841615987,"singlesession":480480092,"total_goals_complete":2510147438,"segwayrider":2003713345,"rodriguez":1070072276,"Glifberg":612411926,"Mullen":1238926373,"axis":2062579973,"reynolds":1293687592,"animations":1796865167,"playlistforbiddentrackflags4":691525658,"CustomSkater":314551426,"teammate_pro_appearance_name":3857530029,"LastGameMode":750808926,"graffititagger":1970163537,"ControllerPreferences":935180371,"Taunts":3861603718,"DeckFlags":2503510624,"islocked":3555276930,"last_name":938952101,"GameRecords":3882151410,"bullfighter":1456993520,"ped_m_accessories":3856410554,"Iron_Man":4104355737,"gaps":3614185278,"tricks":505871678,"left_sleeve_tattoo":1037744690,"no_edit_groups":3137970671,"found_flag":2784568407,"body":609743949,"LastLevelLoadScript":3811794223,"Campbell":4139713359,"StorySkater":234026056,"Margera":119139903,"CurrentSkater":1974215663,"cod_soldier":3708297833,"skater_shrek":4231695743,"VisitedLevels":1896269503,"GoalFlags":3085929468,"benfranklin":78206962,"Hawk":3542223353,"PlayListForbiddenTrackFlags2":3226762543,"PaulieSkater":2559636233,"Extra_SquareSquareL":2157603314,"deg":2218536456,"Caballero":485673895,"Filename":3287553690,"GoalManager_Params":601102090,"playlistforbiddentrackflags3":3075689913,"Goals":953934288,"info":880201384,"props_string":241161049,"hasseen":570864103,"Options":801768824,"careertotalscorepotential":2509536097,"Story":346684359,"statgoals":1152805535,"time_limit":3306944227,"MusicVolume":2882839925,"horse_word":1856351709,"maul":1219366634,"Koston":1433515408,"THPS_Hawk":1639128258,"total_score_potential":2233141508,"JesseJamesSkater":4286619878,"score_reset":372149306,"SteveoSkater":3710929526,"DefaultInitials":619387304,"Lasek":1926820851,"PED":102396958,"classictotalscorepotential":2053896466,"appearance":1431076207,"Steamer":459676218,"upper_leg_bone_group":3191687513,"ped_f_accessories":34969625,"creature":3583218955,"your_daddy_string":3203020056,"Sheckler":2375805607,"GoalManagerParams":2893818722,"version_number":419612912,"saari":1682399492,"full":528493407,"classicscores":3901283586,"score":3446065326,"rotations":778211046,"right_sleeve_tattoo":2633440348,"minikartdriver":1108765592,"GlobalInfo":4116495635},compressed_8:["skater_m_lower_legs","skater_m_head","skater_m_torso","skater_m_legs","skater_m_hair","skater_m_backpack","skater_m_jaw","kneepads","socks","elbowpads","skater_f_head","skater_f_torso","skater_f_legs","skater_f_hair","skater_f_backpack","skater_m_lower_legs","skater_m_hat_hair","part","skater_m_hands","chest_tattoo","back_tattoo","left_forearm_tattoo","right_forearm_tattoo","left_bicep_tattoo","left_leg_tattoo","right_leg_tattoo","use_default_scale","right_bicep_tattoo","skin","value","desc_id","h","s","v","use_default_hsv","skater_f_lower_legs","skater_f_hat_hair","scale","skater_f_hands","display_name","stance","pushstyle","profile","name","trickstyle","age","is_pro","hometown","is_locked","is_head_locked","points_available","air","run","ollie","speed","spin","#\"switch\"","rail_balance","lip_balance","manual_balance","sponsors","trick_mapping","default_trick_mapping","max_specials","specials","Air_CircleD","Air_CircleDL","Air_CircleDR","Air_CircleL","Air_CircleR","Air_CircleU","Air_CircleUL","Air_CircleUR","Air_D_D_Circle","Air_D_D_Square","Air_L_L_Circle","Air_L_L_Square","Air_R_R_Circle","Air_R_R_Square","Air_SquareD","Air_SquareDL","Air_SquareDR","Air_SquareL","Air_SquareR","Air_SquareU","Air_SquareUL","Air_SquareUR","Air_U_U_Circle","Air_U_U_Square","ExtraSlot1","ExtraSlot2","JumpSlot","Lip_TriangleD","Lip_TriangleDL","Lip_TriangleDL","Lip_TriangleDR","Lip_TriangleDR","Lip_TriangleL","Lip_TriangleR","Lip_TriangleU","Lip_TriangleUL","Lip_TriangleUL","Lip_TriangleUR","Lip_TriangleUR","SpAir_D_L_Circle","SpAir_D_L_Square","SpAir_D_R_Circle","SpAir_D_R_Square","SpAir_D_U_Circle","SpAir_D_U_Square","SpAir_L_D_Circle","SpAir_L_D_Square","SpAir_L_R_Circle","SpAir_L_R_Square","SpAir_L_U_Circle","SpAir_L_U_Square","SpAir_R_D_Circle","SpAir_R_D_Square","SpAir_R_L_Circle","SpAir_R_L_Square","SpAir_R_U_Circle","SpAir_R_U_Square","SpAir_U_D_Circle","SpAir_U_D_Square","SpAir_U_L_Circle","SpAir_U_L_Square","SpAir_U_R_Circle","SpAir_U_R_Square","SpGrind_D_L_Triangle","SpGrind_D_R_Triangle","SpGrind_D_U_Triangle","SpGrind_L_D_Triangle","SpGrind_L_R_Triangle","SpGrind_L_U_Triangle","SpGrind_R_D_Triangle","SpGrind_R_L_Triangle","SpGrind_R_U_Triangle","SpGrind_U_D_Triangle","SpGrind_U_L_Triangle","SpGrind_U_R_Triangle","SpLip_D_L_Triangle","SpLip_D_R_Triangle","SpLip_D_U_Triangle","SpLip_L_D_Triangle","SpLip_L_R_Triangle","SpLip_L_U_Triangle","SpLip_R_D_Triangle","SpLip_R_L_Triangle","SpLip_R_U_Triangle","SpLip_U_D_Triangle","SpLip_U_L_Triangle","SpLip_U_R_Triangle","SpLip_U_U_Triangle","SpMan_D_L_Triangle","SpMan_D_R_Triangle","SpMan_D_U_Triangle","SpMan_L_D_Triangle","SpMan_L_R_Triangle","SpMan_L_U_Triangle","SpMan_R_D_Triangle","SpMan_R_L_Triangle","SpMan_R_U_Triangle","SpMan_U_D_Triangle","SpMan_U_L_Triangle","SpMan_U_R_Triangle","X","Y","Z","trickSlot","trickName","unassigned","uv_scale","uv_rot","uv_u","uv_v","sleeves","shoes","front_logo","back_logo","glasses","hat","helmet","accessories","hat_logo","helmet_logo","board","griptape","deck_graphic","accessory1","accessory2","accessory3","shoe_laces","bare_torso","hasBeaten","head_tattoo","custom","headtop_bone_group","jaw_bone_group","nose_bone_group","head_bone_group","torso_bone_group","stomach_bone_group","upper_arm_bone_group","lower_arm_bone_group","hands_bone_group","upper_left_bone_group","lower_leg_bone_group","feet_bone_group","board_bone_group","object_scaling","regular","goofy","never_mongo","mongo_when_switch","always_mongo","street","vert","first_name","skater_index","default_appearance","is_male","flip_speed","on","trick","dur","Blend","from","percent","trickType","idletime","start","order","hold","backwards","deg_dir","left_eye","right_eye","nose","lips","width","height","GapName","GapCount","GapScore","SkaterStartPos","SkaterStartDir","level","valid","value","number","initials","win_record"],compressed_16:["Classic","texture_id","texture_name","string","canvas_id","font_id","pos_x","pos_y","rot","scale","flip_h","flip_s","flip_v","hsva","layer_id","recordtable","bestcombos","arcadescores","highscores","longestcombo","longestgrind","longestliptrick","longestmanual","voice","select_icon","file_name","has_custom_tag","tag_texture","sticker_texture","skater_family"])