initial commit

This commit is contained in:
beerpiss 2023-11-14 23:57:11 +07:00
commit c970b2cbb8
15 changed files with 965 additions and 0 deletions

2
.cargo/config.toml Normal file
View File

@ -0,0 +1,2 @@
[build]
target = "i686-pc-windows-msvc"

20
.gitignore vendored Normal file
View File

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

34
Cargo.toml Normal file
View File

@ -0,0 +1,34 @@
[package]
name = "saekawa"
version = "0.1.0"
authors = ["beerpsi <lacvtg.a1.2023@gmail.com>"]
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"

14
LICENSE Normal file
View File

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

24
README.md Normal file
View File

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

24
res/saekawa.toml Normal file
View File

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

2
rust-toolchain.toml Normal file
View File

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

57
src/configuration.rs Normal file
View File

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

141
src/helpers.rs Normal file
View File

@ -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<T>(
method: impl AsRef<str>,
url: impl AsRef<str>,
body: Option<T>,
) -> Result<ureq::Response>
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<T>(method: impl AsRef<str>, url: impl AsRef<str>, body: Option<T>) -> 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<T, R>(
method: impl AsRef<str>,
url: impl AsRef<str>,
body: Option<T>,
) -> Result<R>
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<String> {
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<String> {
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)
}

107
src/lib.rs Normal file
View File

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

73
src/log.rs Normal file
View File

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

198
src/saekawa.rs Normal file
View File

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

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

@ -0,0 +1,95 @@
use serde::{de, Deserialize, Serialize};
use serde_aux::prelude::*;
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
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<UserData>,
pub user_playlog_list: Vec<UserPlaylog>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpsertUserAllRequest {
pub user_id: String,
pub upsert_user_all: UpsertUserAllBody,
}

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

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

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

@ -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<ImportClasses>,
pub scores: Vec<ImportScore>,
}
#[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<ClassEmblem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub emblem: Option<ClassEmblem>,
}
#[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<UserPlaylog> for ImportScore {
type Error = anyhow::Error;
fn try_from(p: UserPlaylog) -> Result<ImportScore, Self::Error> {
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,
},
})
}
}