commit ca593823777f83642a6e641a2e778519edae6ba1 Author: Adamaq01 Date: Sun Apr 16 16:59:26 2023 +0200 feat: release first version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eb581d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/Cargo.lock +/.idea diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..af5258f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "mikado" +version = "0.1.0" +authors = ["Adam Thibert "] +edition = "2021" +license = "MIT" +readme = "README.md" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +strip = true # Automatically strip symbols from the binary. +lto = true # Enable link-time optimization. +codegen-units = 1 +panic = "abort" + +[dependencies] +winapi = { version = "0.3", features = ["minwindef", "windef", "winuser", "libloaderapi", "processthreadsapi", "winbase", "consoleapi"] } +crochet = "0.2" +log = "0.4" +env_logger = "0.10" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +confy = "0.5" +anyhow = "1.0" +lazy_static = "1.4" +ureq = { version = "2.6", features = ["json"] } +url = "2.3" +either = { version = "1.8", features = ["serde"] } +num_enum = "0.6" +chrono = "0.4" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..111281f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Adam Thibert + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1adf2bf --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Mikado + +Little SDVX hook to submit your scores to a Tachi instance while you are playing. + +## Installation + +- Download the latest release from the [releases page](https://github.com/adamaq01/mikado/releases/latest) +- Extract the zip file into your SDVX installation folder +- When you start the game, inject the DLL into the process + +## Tips + +- The configuration file will be created in the same folder as the DLL at startup if it doesn't already exist +- You can configure some options (like the Tachi URL) by editing the `mikado.toml` file +- If you are using Spicetools, you can add the `-k mikado.dll` option or specify the DLL in the configuration tool to + automatically inject it at startup + +## License + +MIT diff --git a/mikado.toml b/mikado.toml new file mode 100644 index 0000000..cc9505c --- /dev/null +++ b/mikado.toml @@ -0,0 +1,23 @@ +[general] +# Set to 'false' to disable the hook +enable = true +# Whether the hook should export your class (skill level) or not +export_class = true + +[cards] +# Card numbers that should be whitelisted +# If this is empty, all cards will be whitelisted +whitelist = [] + +[tachi] +# Tachi instance base URL +base_url = 'https://kamaitachi.xyz/' + +# Tachi status endpoint +status = '/api/v1/status' + +# Tachi score import endpoint +import = '/ir/direct-manual/import' + +# Your Tachi API key +api_key = 'your-key-here' diff --git a/src/configuration.rs b/src/configuration.rs new file mode 100644 index 0000000..d93d524 --- /dev/null +++ b/src/configuration.rs @@ -0,0 +1,44 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::Write; +use std::path::Path; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Configuration { + pub general: GeneralConfiguration, + pub cards: CardsConfiguration, + pub tachi: TachiConfiguration, +} + +impl Configuration { + pub fn load() -> Result { + if !Path::new("mikado.toml").exists() { + File::create("mikado.toml") + .and_then(|mut file| file.write_all(include_bytes!("../mikado.toml"))) + .map_err(|err| anyhow::anyhow!("Could not create default config file: {}", err))?; + } + + confy::load_path("mikado.toml") + .map_err(|err| anyhow::anyhow!("Could not load config: {}", err)) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GeneralConfiguration { + pub enable: bool, + pub export_class: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CardsConfiguration { + pub whitelist: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TachiConfiguration { + pub base_url: String, + pub status: String, + pub import: String, + pub api_key: String, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9ce8d0b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,34 @@ +mod configuration; +mod log; +mod mikado; +mod save; +mod scores; +mod sys; +mod types; + +use crate::mikado::{hook_init, hook_release}; +use ::log::error; +use winapi::shared::minwindef::{BOOL, DWORD, HINSTANCE, LPVOID, TRUE}; +use winapi::um::consoleapi::AllocConsole; +use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH}; + +#[no_mangle] +#[allow(non_snake_case, unused_variables)] +extern "system" fn DllMain(dll_module: HINSTANCE, call_reason: DWORD, reserved: LPVOID) -> BOOL { + match call_reason { + DLL_PROCESS_ATTACH => { + unsafe { AllocConsole() }; + if let Err(err) = hook_init() { + error!("{:#}", err); + } + } + DLL_PROCESS_DETACH => { + if let Err(err) = hook_release() { + error!("{:#}", err); + } + } + _ => {} + } + + TRUE +} diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..123a198 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,71 @@ +use std::fmt; +use std::fs::File; +use std::io::Write; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Debug)] +pub struct Logger { + file: File, +} + +impl Logger { + pub fn new() -> Self { + Self { + file: File::create("mikado.log").unwrap(), + } + } +} + +impl Write for Logger { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // Ignore the result of the write to stdout, since it's not really important + let _ = std::io::stdout().write(buf); + self.file.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + // Ignore the result of the write to stdout, since it's not really important + let _ = std::io::stdout().flush(); + self.file.flush() + } +} + +pub(crate) struct Padded { + pub(crate) value: T, + pub(crate) width: usize, +} + +impl fmt::Display for Padded { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{: usize { + let max_width = MAX_MODULE_WIDTH.load(Ordering::Relaxed); + if max_width < target.len() { + MAX_MODULE_WIDTH.store(target.len(), Ordering::Relaxed); + target.len() + } else { + max_width + } +} + +pub(crate) fn colored_level( + style: &mut env_logger::fmt::Style, + level: log::Level, +) -> env_logger::fmt::StyledValue<&'static str> { + match level { + log::Level::Trace => style + .set_color(env_logger::fmt::Color::Magenta) + .value("TRACE"), + log::Level::Debug => style.set_color(env_logger::fmt::Color::Blue).value("DEBUG"), + log::Level::Info => style.set_color(env_logger::fmt::Color::Green).value("INFO"), + log::Level::Warn => style + .set_color(env_logger::fmt::Color::Yellow) + .value("WARN"), + log::Level::Error => style.set_color(env_logger::fmt::Color::Red).value("ERROR"), + } +} diff --git a/src/mikado.rs b/src/mikado.rs new file mode 100644 index 0000000..88d10a0 --- /dev/null +++ b/src/mikado.rs @@ -0,0 +1,233 @@ +use crate::configuration::Configuration; +use crate::log::Logger; +use crate::save::process_course; +use crate::scores::process_scores; +use crate::sys::{ + property_clear_error, property_mem_write, property_node_name, property_node_refer, + property_query_size, property_search, property_set_flag, NodeType, +}; +use crate::types::game::Property; +use crate::types::tachi::Import; +use anyhow::Result; +use lazy_static::lazy_static; +use log::{debug, error, info}; +use url::Url; + +lazy_static! { + pub static ref CONFIGURATION: Configuration = { + let result = Configuration::load(); + if let Err(err) = result { + error!("{:#}", err); + std::process::exit(1); + } + + result.unwrap() + }; + pub static ref TACHI_STATUS_URL: Url = { + let result = Url::parse(&CONFIGURATION.tachi.base_url) + .and_then(|url| url.join(&CONFIGURATION.tachi.status)); + if let Err(err) = result { + error!("Could not parse Tachi status URL: {:#}", err); + std::process::exit(1); + } + + result.unwrap() + }; + pub static ref TACHI_IMPORT_URL: Url = { + let result = Url::parse(&CONFIGURATION.tachi.base_url) + .and_then(|url| url.join(&CONFIGURATION.tachi.import)); + if let Err(err) = result { + error!("Could not parse Tachi import URL: {:#}", err); + std::process::exit(1); + } + + result.unwrap() + }; +} + +pub fn send_import(import: Import) -> Result<()> { + debug!("Trying to import to Tachi: {:#?}", import); + let authorization = format!("Bearer {}", CONFIGURATION.tachi.api_key); + let response = ureq::post(TACHI_IMPORT_URL.as_str()) + .set("Authorization", authorization.as_str()) + .send_json(import) + .map_err(|err| anyhow::anyhow!("Could not reach Tachi API: {:#}", err))?; + debug!("Tachi API status response: {:#?}", response.into_string()?); + + Ok(()) +} + +pub fn hook_init() -> Result<()> { + if !CONFIGURATION.general.enable { + return Ok(()); + } + + // Configuring logger + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .parse_default_env() + .target(env_logger::Target::Pipe(Box::new(Logger::new()))) + .format(|f, record| { + use crate::log::{colored_level, max_target_width, Padded}; + use std::io::Write; + + let target = record.target(); + let max_width = max_target_width(target); + + let mut style = f.style(); + let level = colored_level(&mut style, record.level()); + + let mut style = f.style(); + let target = style.set_bold(true).value(Padded { + value: target, + width: max_width, + }); + + let time = chrono::Local::now().format("%d/%m/%Y %H:%M:%S"); + + writeln!(f, "[{}] {} {} -> {}", time, level, target, record.args()) + }) + .init(); + + // Trying to reach Tachi API + debug!("Trying to reach Tachi API at {}", TACHI_STATUS_URL.as_str()); + let authorization = format!("Bearer {}", CONFIGURATION.tachi.api_key); + let response = ureq::get(TACHI_STATUS_URL.as_str()) + .set("Authorization", authorization.as_str()) + .call() + .map_err(|err| anyhow::anyhow!("Could not reach Tachi API: {:#}", err))?; + debug!("Tachi API status response: {:#?}", response.into_string()?); + info!("Tachi API successfully reached"); + + // Initializing function detours + crochet::enable!(property_destroy_hook) + .map_err(|err| anyhow::anyhow!("Could not enable function detour: {:#}", err))?; + + info!("Hook successfully initialized"); + + Ok(()) +} + +pub fn hook_release() -> Result<()> { + if !CONFIGURATION.general.enable { + return Ok(()); + } + + crochet::disable!(property_destroy_hook) + .map_err(|err| anyhow::anyhow!("Could not disable function detour: {:#}", err))?; + + Ok(()) +} + +#[crochet::hook("avs2-core.dll", "XCgsqzn0000091")] +pub unsafe fn property_destroy_hook(property: *mut ()) -> i32 { + if property.is_null() { + return 0; + } + + let game_node = property_search(property, std::ptr::null(), b"/call/game\0".as_ptr()); + if game_node.is_null() { + property_clear_error(property); + return call_original!(property); + } + + let mut buffer = [0u8; 256]; + let result = property_node_name(game_node, buffer.as_mut_ptr(), buffer.len() as u32); + if result < 0 { + return call_original!(property); + } + + let name = { + let result = std::str::from_utf8(&buffer[0..4]); + if let Err(err) = result { + error!("Could not convert buffer to string: {:#}", err); + return call_original!(property); + } + + result.unwrap() + }; + if name != "game" { + return call_original!(property); + } + + let result = property_node_refer( + property, + game_node, + b"method@\0".as_ptr(), + NodeType::NodeAttr, + buffer.as_mut_ptr() as *mut (), + 256, + ); + if result < 0 { + return call_original!(property); + } + + let method = { + let result = std::str::from_utf8(&buffer[0..11]); + if let Err(err) = result { + error!("Could not convert buffer to string: {:#}", err); + return call_original!(property); + } + + result.unwrap().replace('\0', "") + }; + debug!("Intercepted Game Method: {}", method); + if method != "sv6_save_m" && (!CONFIGURATION.general.export_class || method != "sv6_save") { + return call_original!(property); + } + + property_set_flag(property, 0x800, 0x008); + + let size = property_query_size(property); + if size < 0 { + property_set_flag(property, 0x008, 0x800); + return call_original!(property); + } + + let buffer = vec![0u8; size as usize]; + let result = property_mem_write(property, buffer.as_ptr() as *mut u8, buffer.len() as u32); + property_set_flag(property, 0x008, 0x800); + if result < 0 { + return call_original!(property); + } + + // Read buf to string + let property_str = { + let result = std::str::from_utf8(&buffer); + if let Err(err) = result { + error!("Could not convert buffer to string: {:#}", err); + return call_original!(property); + } + + result.unwrap() + }; + + debug!("Processing property: {}", property_str); + if let Err(err) = match method.as_str() { + "sv6_save_m" => serde_json::from_str::(property_str) + .map_err(|err| anyhow::anyhow!("Could not parse property: {:#}", err)) + .and_then(|prop| { + process_scores( + prop.call + .game + .left() + .ok_or(anyhow::anyhow!("Could not process scores property"))?, + ) + }), + "sv6_save" => serde_json::from_str::(property_str) + .map_err(|err| anyhow::anyhow!("Could not parse property: {:#}", err)) + .and_then(|prop| { + process_course( + prop.call + .game + .right() + .ok_or(anyhow::anyhow!("Could not process course property"))?, + ) + }), + _ => unreachable!(), + } { + error!("{:#}", err); + } + + call_original!(property) +} diff --git a/src/save.rs b/src/save.rs new file mode 100644 index 0000000..b82ccf6 --- /dev/null +++ b/src/save.rs @@ -0,0 +1,26 @@ +use crate::mikado::{send_import, CONFIGURATION}; +use crate::types::game::GameSave; +use crate::types::tachi::{Import, ImportClasses, SkillLevel}; +use anyhow::Result; +use log::info; + +pub fn process_course(course: GameSave) -> Result<()> { + let card = course.ref_id; + if !CONFIGURATION.cards.whitelist.is_empty() && !CONFIGURATION.cards.whitelist.contains(&card) { + info!("Card {} is not whitelisted, skipping class update", card); + return Ok(()); + } + + let import = Import { + meta: Default::default(), + classes: Some(ImportClasses { + dan: SkillLevel::from(course.skill_level), + }), + scores: vec![], + }; + + send_import(import)?; + info!("Successfully updated class for card {}", card); + + Ok(()) +} diff --git a/src/scores.rs b/src/scores.rs new file mode 100644 index 0000000..5cf8b98 --- /dev/null +++ b/src/scores.rs @@ -0,0 +1,59 @@ +use crate::mikado::{send_import, CONFIGURATION}; +use crate::types::game::GameScores; +use crate::types::tachi::{Difficulty, HitMeta, Import, ImportScore, Judgements, TachiLamp}; +use anyhow::Result; +use either::Either; +use log::info; + +pub fn process_scores(scores: GameScores) -> Result<()> { + let card = scores.ref_id; + if !CONFIGURATION.cards.whitelist.is_empty() && !CONFIGURATION.cards.whitelist.contains(&card) { + info!("Card {} is not whitelisted, skipping score(s)", card); + return Ok(()); + } + + let tracks = match scores.tracks { + Either::Left(track) => vec![track], + Either::Right(tracks) => tracks, + }; + + let time_achieved = std::time::UNIX_EPOCH + .elapsed() + .map(|duration| duration.as_millis()) + .map_err(|err| anyhow::anyhow!("Could not get time from System {:#}", err))?; + + let scores = tracks + .into_iter() + .map(|track| ImportScore { + score: track.score, + lamp: TachiLamp::from(track.clear_type), + match_type: "sdvxInGameID".to_string(), + identifier: track.music_id.to_string(), + difficulty: Difficulty::from(track.music_type), + time_achieved, + judgements: Judgements { + critical: track.critical, + near: track.near, + miss: track.error, + }, + hit_meta: HitMeta { + fast: track.judge[0], + slow: track.judge[6], + max_combo: track.max_chain, + ex_score: track.ex_score, + gauge: track.effective_rate as f32 / 100.0, + }, + }) + .collect(); + + let import = Import { + meta: Default::default(), + classes: None, + scores, + }; + + send_import(import)?; + info!("Successfully imported score(s) for card {}", card); + + Ok(()) +} diff --git a/src/sys.rs b/src/sys.rs new file mode 100644 index 0000000..adcaf55 --- /dev/null +++ b/src/sys.rs @@ -0,0 +1,96 @@ +#![allow(dead_code)] + +#[crochet::load("avs2-core.dll")] +extern "C" { + #[symbol("XCgsqzn0000091")] + pub fn property_destroy(property: *mut ()) -> i32; + #[symbol("XCgsqzn000009a")] + pub fn property_set_flag(property: *mut (), set_flags: u32, clear_flags: u32) -> u32; + #[symbol("XCgsqzn000009d")] + pub fn property_clear_error(property: *mut ()) -> *mut (); + #[symbol("XCgsqzn000009f")] + pub fn property_query_size(property: *const ()) -> i32; + #[symbol("XCgsqzn00000a1")] + pub fn property_search(property: *const (), node: *const (), path: *const u8) -> *mut (); + #[symbol("XCgsqzn00000a7")] + pub fn property_node_name(node: *const (), buffer: *mut u8, size: u32) -> i32; + #[symbol("XCgsqzn00000ab")] + pub fn property_node_read( + node: *const (), + node_type: NodeType, + data: *mut (), + size: u32, + ) -> i32; + #[symbol("XCgsqzn00000af")] + pub fn property_node_refer( + property: *const (), + node: *const (), + path: *const u8, + node_type: NodeType, + data: *mut (), + size: u32, + ) -> i32; + #[symbol("XCgsqzn00000b8")] + pub fn property_mem_write(property: *mut (), data: *mut u8, size: u32) -> i32; +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[repr(u32)] +pub enum NodeType { + NodeNode = 1, + NodeS8 = 2, + NodeU8 = 3, + NodeS16 = 4, + NodeU16 = 5, + NodeS32 = 6, + NodeU32 = 7, + NodeS64 = 8, + NodeU64 = 9, + NodeBin = 10, + NodeStr = 11, + NodeIp4 = 12, + NodeTime = 13, + NodeFloat = 14, + NodeDouble = 15, + Node2s8 = 16, + Node2u8 = 17, + Node2s16 = 18, + Node2u16 = 19, + Node2s32 = 20, + Node2u32 = 21, + Node2s64 = 22, + Node2u64 = 23, + Node2f = 24, + Node2d = 25, + Node3s8 = 26, + Node3u8 = 27, + Node3s16 = 28, + Node3u16 = 29, + Node3s32 = 30, + Node3u32 = 31, + Node3s64 = 32, + Node3u64 = 33, + Node3f = 34, + Node3d = 35, + Node4s8 = 36, + Node4u8 = 37, + Node4s16 = 38, + Node4u16 = 39, + Node4s32 = 40, + Node4u32 = 41, + Node4s64 = 42, + Node4u64 = 43, + Node4f = 44, + Node4d = 45, + NodeAttr = 46, + NodeAttrAndNode = 47, + NodeVs8 = 48, + NodeVu8 = 49, + NodeVs16 = 50, + NodeVu16 = 51, + NodeBool = 52, + Node2b = 53, + Node3b = 54, + Node4b = 55, + NodeVb = 56, +} diff --git a/src/types/game.rs b/src/types/game.rs new file mode 100644 index 0000000..0b34355 --- /dev/null +++ b/src/types/game.rs @@ -0,0 +1,45 @@ +use either::Either; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Property { + pub call: CallStruct, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallStruct { + #[serde(with = "either::serde_untagged")] + pub game: Either, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameScores { + #[serde(rename = "refid")] + pub ref_id: String, + #[serde(with = "either::serde_untagged", rename = "track")] + pub tracks: Either>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Track { + pub music_id: u32, + pub music_type: u32, + pub score: u32, + #[serde(rename = "exscore")] + pub ex_score: u32, + pub clear_type: u32, + pub max_chain: u32, + pub critical: u32, + pub near: u32, + pub error: u32, + pub effective_rate: u32, + pub gauge_type: u32, + pub judge: [u32; 7], +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GameSave { + #[serde(rename = "refid")] + pub ref_id: String, + pub skill_level: u32, +} diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 0000000..80ec264 --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,2 @@ +pub mod game; +pub mod tachi; diff --git a/src/types/tachi.rs b/src/types/tachi.rs new file mode 100644 index 0000000..222956a --- /dev/null +++ b/src/types/tachi.rs @@ -0,0 +1,128 @@ +use num_enum::FromPrimitive; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Import { + pub meta: ImportMeta, + #[serde(skip_serializing_if = "Option::is_none")] + pub classes: Option, + pub scores: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportMeta { + pub game: String, + #[serde(rename = "playtype")] + pub play_type: String, + pub service: String, +} + +impl Default for ImportMeta { + fn default() -> Self { + Self { + game: "sdvx".to_string(), + play_type: "Single".to_string(), + service: "Mikado".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportClasses { + pub dan: SkillLevel, +} + +#[derive(Debug, Clone, Eq, PartialEq, FromPrimitive, Serialize, Deserialize)] +#[repr(u32)] +pub enum SkillLevel { + #[num_enum(default)] + #[serde(rename = "DAN_1")] + First = 1, + #[serde(rename = "DAN_2")] + Second = 2, + #[serde(rename = "DAN_3")] + Third = 3, + #[serde(rename = "DAN_4")] + Fourth = 4, + #[serde(rename = "DAN_5")] + Fifth = 5, + #[serde(rename = "DAN_6")] + Sixth = 6, + #[serde(rename = "DAN_7")] + Seventh = 7, + #[serde(rename = "DAN_8")] + Eighth = 8, + #[serde(rename = "DAN_9")] + Ninth = 9, + #[serde(rename = "DAN_10")] + Tenth = 10, + #[serde(rename = "DAN_11")] + Eleventh = 11, + #[serde(rename = "INF")] + Infinite = 12, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportScore { + pub score: u32, + pub lamp: TachiLamp, + #[serde(rename = "matchType")] + pub match_type: String, + pub identifier: String, + pub difficulty: Difficulty, + #[serde(rename = "timeAchieved")] + pub time_achieved: u128, + pub judgements: Judgements, + #[serde(rename = "hitMeta")] + pub hit_meta: HitMeta, +} + +#[derive(Debug, Clone, Eq, PartialEq, FromPrimitive, Serialize, Deserialize)] +#[repr(u32)] +pub enum TachiLamp { + #[num_enum(default)] + #[serde(rename = "FAILED")] + Failed = 1, + #[serde(rename = "CLEAR")] + Clear = 2, + #[serde(rename = "EXCESSIVE CLEAR")] + ExcessiveClear = 3, + #[serde(rename = "ULTIMATE CHAIN")] + UltimateChain = 4, + #[serde(rename = "PERFECT ULTIMATE CHAIN")] + PerfectUltimateChain = 5, +} + +#[derive(Debug, Clone, Eq, PartialEq, FromPrimitive, Serialize, Deserialize)] +#[repr(u32)] +pub enum Difficulty { + #[num_enum(default)] + #[serde(rename = "NOV")] + Novice = 0, + #[serde(rename = "ADV")] + Advanced = 1, + #[serde(rename = "EXH")] + Exhaust = 2, + #[serde(rename = "ANY_INF")] + AnyInfinite = 3, + #[serde(rename = "MXM")] + Maximum = 4, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Judgements { + pub critical: u32, + pub near: u32, + pub miss: u32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HitMeta { + pub fast: u32, + pub slow: u32, + #[serde(rename = "maxCombo")] + pub max_combo: u32, + #[serde(rename = "exScore")] + pub ex_score: u32, + pub gauge: f32, +}