feat: release first version

This commit is contained in:
Adamaq01 2023-04-16 16:59:26 +02:00 committed by Adam Thibert
commit ca59382377
15 changed files with 837 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
/Cargo.lock
/.idea

32
Cargo.toml Normal file
View File

@ -0,0 +1,32 @@
[package]
name = "mikado"
version = "0.1.0"
authors = ["Adam Thibert <adamthibert01@gmail.com>"]
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"

21
LICENSE Normal file
View File

@ -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.

20
README.md Normal file
View File

@ -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

23
mikado.toml Normal file
View File

@ -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'

44
src/configuration.rs Normal file
View File

@ -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<Self> {
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<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TachiConfiguration {
pub base_url: String,
pub status: String,
pub import: String,
pub api_key: String,
}

34
src/lib.rs Normal file
View File

@ -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
}

71
src/log.rs Normal file
View File

@ -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<usize> {
// 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<T> {
pub(crate) value: T,
pub(crate) width: usize,
}
impl<T: fmt::Display> fmt::Display for Padded<T> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{: <width$}", self.value, width = self.width)
}
}
pub(crate) static MAX_MODULE_WIDTH: AtomicUsize = AtomicUsize::new(0);
pub(crate) fn max_target_width(target: &str) -> 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"),
}
}

233
src/mikado.rs Normal file
View File

@ -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>(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>(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)
}

26
src/save.rs Normal file
View File

@ -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(())
}

59
src/scores.rs Normal file
View File

@ -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(())
}

96
src/sys.rs Normal file
View File

@ -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,
}

45
src/types/game.rs Normal file
View File

@ -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<GameScores, GameSave>,
}
#[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<Track, Vec<Track>>,
}
#[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,
}

2
src/types/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod game;
pub mod tachi;

128
src/types/tachi.rs Normal file
View File

@ -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<ImportClasses>,
pub scores: Vec<ImportScore>,
}
#[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,
}