commit c970b2cbb853b8473beafc36daa9058bff85b15e Author: beerpiss Date: Tue Nov 14 23:57:11 2023 +0700 initial commit diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..3a249ae --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "i686-pc-windows-msvc" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..389159a --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Created by https://www.toptal.com/developers/gitignore/api/rust +# Edit at https://www.toptal.com/developers/gitignore?templates=rust + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# End of https://www.toptal.com/developers/gitignore/api/rust diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..651822d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "saekawa" +version = "0.1.0" +authors = ["beerpsi "] +edition = "2021" +license = "0BSD" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +strip = true # Automatically strip symbols from the binary. +opt-level = "z" # Optimize for size. +lto = true +codegen-units = 1 +panic = "abort" + +[dependencies] +winapi = { version = "0.3.9", features = ["winhttp", "minwindef", "consoleapi"] } +serde = { version = "1.0.192", features = ["derive"] } +serde_json = "1.0.108" +anyhow = "1.0.75" +confy = "0.5.1" +lazy_static = "1.4.0" +log = "0.4.20" +url = "2.4.1" +chrono = "0.4.31" +env_logger = "0.10.1" +retour = { version = "0.3.1", features = ["static-detour"] } +widestring = "1.0.2" +flate2 = "1.0.28" +ureq = { version = "2.8.0", features = ["json"] } +num_enum = "0.7.1" +serde-aux = "4.2.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a4f0fcb --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +BSD Zero Clause License + +Copyright (c) 2023 beerpsi + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3c4118 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +## Saekawa +CHUNITHM hook to submit your scores to Tachi every credit. + +### Features +- Submit scores to Tachi after each credit. +- Submit dan and emblem classes to Tachi. + +### Installation +- Put it in your game installation root directory +- Create and edit the config file to set your API key (optional, if it doesn't exist it +will be created automatically) +- When you start the game, inject the DLL into the game process. For example, +edit your segatools game.bat to look like the green line: +```diff +- inject_x86.exe -d -k chusanhook.dll chusanApp.exe ++ inject_x86.exe -d -k saekawa.dll -k chusanhook.dll chusanApp.exe +``` + +### Credits +- Adam Thibert ([adamaq01](https://github.com/adamaq01)). A lot of the code was shamelessly lifted from his +[Mikado](https://github.com/adamaq01/Mikado), a similar hook for SDVX. + +### License +0BSD \ No newline at end of file diff --git a/res/saekawa.toml b/res/saekawa.toml new file mode 100644 index 0000000..300f32b --- /dev/null +++ b/res/saekawa.toml @@ -0,0 +1,24 @@ +[general] +# Set to 'false' to disable the hook +enable = true +# Whether the hook should export your class medals and emblems or not. +export_class = true +# Timeout for web requests, in milliseconds +timeout = 3000 + +[cards] +# Access codes that should be whitelisted +# If this is empty, all cards will be whitelisted +# There should be no whitespace between the digits +# example: whitelist = ["00001111222233334444"] +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' \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..271800c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/src/configuration.rs b/src/configuration.rs new file mode 100644 index 0000000..702e7f9 --- /dev/null +++ b/src/configuration.rs @@ -0,0 +1,57 @@ +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("saekawa.toml").exists() { + File::create("saekawa.toml") + .and_then(|mut file| file.write_all(include_bytes!("../res/saekawa.toml"))) + .map_err(|err| anyhow::anyhow!("Could not create default config file: {}", err))?; + } + + confy::load_path("saekawa.toml") + .map_err(|err| anyhow::anyhow!("Could not load config: {}", err)) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GeneralConfiguration { + #[serde(default = "default_true")] + pub enable: bool, + #[serde(default)] + pub export_class: bool, + #[serde(default = "default_timeout")] + pub timeout: u64, +} + +fn default_true() -> bool { + true +} + +fn default_timeout() -> u64 { + 3000 +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CardsConfiguration { + #[serde(default)] + 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/helpers.rs b/src/helpers.rs new file mode 100644 index 0000000..87ac19c --- /dev/null +++ b/src/helpers.rs @@ -0,0 +1,141 @@ +use std::{fmt::Debug, io::Read}; + +use anyhow::{anyhow, Result}; +use flate2::read::ZlibDecoder; +use log::debug; +use serde::{Deserialize, Serialize}; +use widestring::U16CString; +use winapi::{ + ctypes::c_void, + shared::{minwindef::TRUE, winerror::ERROR_INSUFFICIENT_BUFFER}, + um::{ + errhandlingapi::GetLastError, + winhttp::{WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL}, + }, +}; + +use crate::CONFIGURATION; + +pub fn request_agent() -> ureq::Agent { + let timeout = CONFIGURATION.general.timeout; + let timeout = if timeout > 10000 { 10000 } else { timeout }; + + ureq::builder() + .timeout(std::time::Duration::from_millis(timeout)) + .build() +} + +fn request( + method: impl AsRef, + url: impl AsRef, + body: Option, +) -> Result +where + T: Serialize + Debug, +{ + let agent = request_agent(); + + let method = method.as_ref(); + let url = url.as_ref(); + debug!("{} request to {} with body: {:#?}", method, url, body); + + let authorization = format!("Bearer {}", CONFIGURATION.tachi.api_key); + let request = agent + .request(method, url) + .set("Authorization", authorization.as_str()); + let response = match body { + Some(body) => request.send_json(body), + None => request.call(), + } + .map_err(|err| anyhow::anyhow!("Could not reach Tachi API: {:#}", err))?; + + Ok(response) +} + +pub fn call_tachi(method: impl AsRef, url: impl AsRef, body: Option) -> Result<()> +where + T: Serialize + Debug, +{ + let response = request(method, url, body)?; + let response: serde_json::Value = response.into_json()?; + debug!("Tachi API response: {:#?}", response); + + Ok(()) +} + +pub fn request_tachi( + method: impl AsRef, + url: impl AsRef, + body: Option, +) -> Result +where + T: Serialize + Debug, + R: for<'de> Deserialize<'de> + Debug, +{ + let response = request(method, url, body)?; + let response = response.into_json()?; + debug!("Tachi API response: {:#?}", response); + + Ok(response) +} + +pub fn read_hinternet_url(handle: HINTERNET) -> Result { + let mut buf_length = 255; + let mut buffer = [0u16; 255]; + let result = unsafe { + WinHttpQueryOption( + handle, + WINHTTP_OPTION_URL, + buffer.as_mut_ptr() as *mut c_void, + &mut buf_length, + ) + }; + + if result == TRUE { + let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]); + return url_str + .to_string() + .map_err(|err| anyhow!("Could not decode wide string: {:#}", err)); + } + + let ec = unsafe { GetLastError() }; + if ec == ERROR_INSUFFICIENT_BUFFER { + let mut buffer = vec![0u16; buf_length as usize]; + let result = unsafe { + WinHttpQueryOption( + handle, + WINHTTP_OPTION_URL, + buffer.as_mut_ptr() as *mut c_void, + &mut buf_length, + ) + }; + + if result != TRUE { + let ec = unsafe { GetLastError() }; + return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}")); + } + + let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]); + return url_str + .to_string() + .map_err(|err| anyhow!("Could not decode wide string: {:#}", err)); + } + + let ec = unsafe { GetLastError() }; + return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}")); +} + +pub unsafe fn read_potentially_deflated_buffer(buf: *const u8, len: usize) -> Result { + let mut slice = std::slice::from_raw_parts(buf, len); + let mut ret = String::new(); + + let _ = if slice[0] == 120 && slice[1] == 156 { + // Just a really dumb check if the request is sent over zlib or not + let mut decoder = ZlibDecoder::new(slice); + decoder.read_to_string(&mut ret) + } else { + slice.read_to_string(&mut ret) + }?; + + Ok(ret) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5f4c96d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,107 @@ +mod configuration; +mod helpers; +mod log; +mod saekawa; +mod types; + +use ::log::error; +use lazy_static::lazy_static; +use url::Url; +use winapi::shared::minwindef::{BOOL, DWORD, HINSTANCE, LPVOID, TRUE}; +use winapi::um::consoleapi::AllocConsole; +use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH}; + +use crate::configuration::Configuration; +use crate::log::Logger; +use crate::saekawa::{hook_init, hook_release}; + +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: String = { + 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().to_string() + }; + pub static ref TACHI_IMPORT_URL: String = { + 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().to_string() + }; +} + +fn init_logger() { + env_logger::builder() + .filter_level(::log::LevelFilter::Error) + .filter_module( + "saekawa", + if cfg!(debug_assertions) { + ::log::LevelFilter::Debug + } else { + ::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(); +} + +#[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() }; + init_logger(); + + 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..c2d6fe7 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,73 @@ +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("saekawa.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/saekawa.rs b/src/saekawa.rs new file mode 100644 index 0000000..575d911 --- /dev/null +++ b/src/saekawa.rs @@ -0,0 +1,198 @@ +use std::{ffi::CString, ptr}; + +use ::log::{debug, error, info}; +use anyhow::{anyhow, Result}; +use retour::static_detour; +use winapi::{ + ctypes::c_void, + shared::minwindef::{__some_function, BOOL, DWORD, LPCVOID, LPDWORD}, + um::{ + errhandlingapi::GetLastError, + libloaderapi::{GetModuleHandleA, GetProcAddress}, + winhttp::HINTERNET, + }, +}; + +use crate::{ + helpers::{call_tachi, read_hinternet_url, read_potentially_deflated_buffer}, + types::{ + game::UpsertUserAllRequest, + tachi::{ClassEmblem, Import, ImportClasses, ImportScore}, + }, + CONFIGURATION, TACHI_IMPORT_URL, +}; + +type WinHttpWriteDataFunc = unsafe extern "system" fn(HINTERNET, LPCVOID, DWORD, LPDWORD) -> BOOL; + +static_detour! { + static DetourWriteData: unsafe extern "system" fn (HINTERNET, LPCVOID, DWORD, LPDWORD) -> BOOL; +} + +pub fn hook_init() -> Result<()> { + if !CONFIGURATION.general.enable { + return Ok(()); + } + + let winhttpwritedata = unsafe { + let addr = get_proc_address("winhttp.dll", "WinHttpWriteData") + .map_err(|err| anyhow!("{:#}", err))?; + std::mem::transmute::<_, WinHttpWriteDataFunc>(addr) + }; + + unsafe { + DetourWriteData.initialize(winhttpwritedata, move |a, b, c, d| { + winhttpwritedata_hook(a, b, c, d) + })?; + + DetourWriteData.enable()?; + }; + + info!("Hook successfully initialized"); + Ok(()) +} + +pub fn hook_release() -> Result<()> { + if !CONFIGURATION.general.enable { + return Ok(()); + } + + unsafe { DetourWriteData.disable()? }; + + Ok(()) +} + +unsafe fn winhttpwritedata_hook( + h_request: HINTERNET, + lp_buffer: LPCVOID, + dw_number_of_bytes_to_write: DWORD, + lpdw_number_of_bytes_written: LPDWORD, +) -> BOOL { + debug!("hit winhttpwritedata"); + + let url = match read_hinternet_url(h_request) { + Ok(url) => url, + Err(err) => { + error!("There was an error reading the request URL: {:#}", err); + return DetourWriteData.call( + h_request, + lp_buffer, + dw_number_of_bytes_to_write, + lpdw_number_of_bytes_written, + ); + } + }; + debug!("winhttpwritedata URL: {url}"); + + let request_body = match read_potentially_deflated_buffer( + lp_buffer as *const u8, + dw_number_of_bytes_to_write as usize, + ) { + Ok(data) => data, + Err(err) => { + error!("There was an error reading the request body: {:#}", err); + return DetourWriteData.call( + h_request, + lp_buffer, + dw_number_of_bytes_to_write, + lpdw_number_of_bytes_written, + ); + } + }; + debug!("winhttpwritedata request body: {request_body}"); + + if !url.contains("UpsertUserAllApi") { + return DetourWriteData.call( + h_request, + lp_buffer, + dw_number_of_bytes_to_write, + lpdw_number_of_bytes_written, + ); + } + + let upsert_req = match serde_json::from_str::(&request_body) { + Ok(req) => req, + Err(err) => { + error!("Could not parse request body: {:#}", err); + return DetourWriteData.call( + h_request, + lp_buffer, + dw_number_of_bytes_to_write, + lpdw_number_of_bytes_written, + ); + } + }; + + debug!("Parsed request body: {:#?}", upsert_req); + + let user_data = &upsert_req.upsert_user_all.user_data[0]; + let access_code = &user_data.access_code; + if !CONFIGURATION.cards.whitelist.is_empty() + && !CONFIGURATION.cards.whitelist.contains(access_code) + { + info!("Card {access_code} is not whitelisted, skipping score submission"); + return DetourWriteData.call( + h_request, + lp_buffer, + dw_number_of_bytes_to_write, + lpdw_number_of_bytes_written, + ); + } + + let import = Import { + classes: if CONFIGURATION.general.export_class { + Some(ImportClasses { + dan: ClassEmblem::try_from(user_data.class_emblem_medal).ok(), + emblem: ClassEmblem::try_from(user_data.class_emblem_base).ok(), + }) + } else { + None + }, + scores: upsert_req + .upsert_user_all + .user_playlog_list + .into_iter() + .filter_map(|playlog| { + if let Ok(score) = ImportScore::try_from(playlog) { + if score.difficulty.as_str() == "WORLD'S END" { + return None; + } + Some(score) + } else { + None + } + }) + .collect(), + ..Default::default() + }; + + match call_tachi("POST", TACHI_IMPORT_URL.as_str(), Some(import)) { + Ok(_) => info!("Successfully imported scores for card {access_code}"), + Err(err) => error!("Could not import scores for card {access_code}: {:#}", err), + }; + + DetourWriteData.call( + h_request, + lp_buffer, + dw_number_of_bytes_to_write, + lpdw_number_of_bytes_written, + ) +} + +fn get_proc_address(module: &str, function: &str) -> Result<*mut __some_function> { + let module_name = CString::new(module).unwrap(); + let fun_name = CString::new(function).unwrap(); + + let module = unsafe { GetModuleHandleA(module_name.as_ptr()) }; + if (module as *const c_void) == ptr::null() { + let ec = unsafe { GetLastError() }; + return Err(anyhow!("could not get module handle, error code {ec}")); + } + + let addr = unsafe { GetProcAddress(module, fun_name.as_ptr()) }; + if (addr as *const c_void) == ptr::null() { + let ec = unsafe { GetLastError() }; + return Err(anyhow!("could not get function address, error code {ec}")); + } + + Ok(addr) +} diff --git a/src/types/game.rs b/src/types/game.rs new file mode 100644 index 0000000..3eb37c7 --- /dev/null +++ b/src/types/game.rs @@ -0,0 +1,95 @@ +use serde::{de, Deserialize, Serialize}; +use serde_aux::prelude::*; + +fn deserialize_bool<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let s: &str = de::Deserialize::deserialize(deserializer)?; + + match s { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(de::Error::unknown_variant(s, &["true", "false"])), + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserData { + pub access_code: String, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub class_emblem_base: u32, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub class_emblem_medal: u32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserPlaylog { + // This decides what `level` indices mean. + // rom version 1.xx.yy: 0->4 for BASIC/ADVANCED/EXPERT/MASTER/WORLD'S END + // rom version 2.xx.yy: 0->5 for BASIC/ADVANCED/EXPERT/MASTER/ULTIMA/WORLD'S END + pub rom_version: String, + + pub music_id: String, + + // This is in UTC+9 + pub user_play_date: String, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub level: u32, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub score: u32, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub max_combo: u32, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub judge_guilty: u32, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub judge_attack: u32, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub judge_justice: u32, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub judge_critical: u32, + // Only introduced in CHUNITHM NEW, thus needing a default value. + #[serde( + default = "default_judge_heaven", + deserialize_with = "deserialize_number_from_string" + )] + pub judge_heaven: u32, + + #[serde(deserialize_with = "deserialize_bool")] + pub is_all_justice: bool, + + #[serde(deserialize_with = "deserialize_bool")] + pub is_full_combo: bool, + + #[serde(deserialize_with = "deserialize_bool")] + pub is_clear: bool, +} + +fn default_judge_heaven<'a>() -> u32 { + 0 +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertUserAllBody { + pub user_data: Vec, + pub user_playlog_list: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertUserAllRequest { + pub user_id: String, + pub upsert_user_all: UpsertUserAllBody, +} 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..aa55c71 --- /dev/null +++ b/src/types/tachi.rs @@ -0,0 +1,172 @@ +use anyhow::anyhow; +use chrono::{FixedOffset, NaiveDateTime, TimeZone}; +use num_enum::TryFromPrimitive; +use serde::{Deserialize, Serialize}; + +use super::game::UserPlaylog; + +#[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, + pub playtype: String, + pub service: String, +} + +impl Default for ImportMeta { + fn default() -> Self { + Self { + game: "chunithm".to_string(), + playtype: "Single".to_string(), + service: "Saekawa".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ImportClasses { + #[serde(skip_serializing_if = "Option::is_none")] + pub dan: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub emblem: Option, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)] +#[repr(u32)] +pub enum ClassEmblem { + #[serde(rename = "DAN_I")] + First = 1, + + #[serde(rename = "DAN_II")] + Second = 2, + + #[serde(rename = "DAN_III")] + Third = 3, + + #[serde(rename = "DAN_IV")] + Fourth = 4, + + #[serde(rename = "DAN_V")] + Fifth = 5, + + #[serde(rename = "DAN_INFINITE")] + Infinite = 6, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportScore { + pub score: u32, + pub lamp: TachiLamp, + pub match_type: String, + pub identifier: String, + pub difficulty: String, + pub time_achieved: u128, + pub judgements: Judgements, + pub optional: OptionalMetrics, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)] +#[repr(u32)] +pub enum TachiLamp { + #[serde(rename = "FAILED")] + Failed = 0, + + #[serde(rename = "CLEAR")] + Clear = 1, + + #[serde(rename = "FULL COMBO")] + FullCombo = 2, + + #[serde(rename = "ALL JUSTICE")] + AllJustice = 3, + + #[serde(rename = "ALL JUSTICE CRITICAL")] + AllJusticeCritical = 4, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Judgements { + pub jcrit: u32, + pub justice: u32, + pub attack: u32, + pub miss: u32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OptionalMetrics { + pub max_combo: u32, +} + +impl TryFrom for ImportScore { + type Error = anyhow::Error; + + fn try_from(p: UserPlaylog) -> Result { + let lamp = if p.is_all_justice { + if p.judge_justice + p.judge_attack + p.judge_guilty == 0 { + TachiLamp::AllJusticeCritical + } else { + TachiLamp::AllJustice + } + } else if p.is_full_combo { + TachiLamp::FullCombo + } else if p.is_clear { + TachiLamp::Clear + } else { + TachiLamp::Failed + }; + + let judgements = Judgements { + jcrit: p.judge_heaven + p.judge_critical, + justice: p.judge_justice, + attack: p.judge_attack, + miss: p.judge_guilty, + }; + + let rom_major_version = p.rom_version.split('.').next().unwrap_or("2"); + let difficulty = match p.level { + 0 => "BASIC", + 1 => "ADVANCED", + 2 => "EXPERT", + 3 => "MASTER", + 4 => if rom_major_version == "2" { + "ULTIMA" + } else { + "WORLD'S END" + }, + 5 => if rom_major_version == "2" { + "WORLD'S END" + } else { + return Err(anyhow!("difficulty index '5' should not be possible on rom_version {rom_major_version}.")); + }, + _ => return Err(anyhow!("unknown difficulty index {level} on major version {rom_major_version}", level=p.level)), + }.to_string(); + + let datetime = NaiveDateTime::parse_from_str(&p.user_play_date, "%Y-%m-%d %H:%M:%S")?; + let jst_offset = + FixedOffset::east_opt(9 * 3600).expect("chrono should be able to parse JST timezone"); + let jst_time = jst_offset.from_local_datetime(&datetime).unwrap(); + + Ok(ImportScore { + score: p.score, + lamp, + match_type: "inGameID".to_string(), + identifier: p.music_id, + difficulty, + time_achieved: jst_time.timestamp_millis() as u128, + judgements, + optional: OptionalMetrics { + max_combo: p.max_combo, + }, + }) + } +}