mirror of
https://github.com/beerpiss/saekawa.git
synced 2024-11-23 23:00:58 +01:00
initial commit
This commit is contained in:
commit
c970b2cbb8
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[build]
|
||||
target = "i686-pc-windows-msvc"
|
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
34
Cargo.toml
Normal 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
14
LICENSE
Normal 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
24
README.md
Normal 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
24
res/saekawa.toml
Normal 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
2
rust-toolchain.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
57
src/configuration.rs
Normal file
57
src/configuration.rs
Normal 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
141
src/helpers.rs
Normal 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
107
src/lib.rs
Normal 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
73
src/log.rs
Normal 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
198
src/saekawa.rs
Normal 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
95
src/types/game.rs
Normal 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
2
src/types/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod game;
|
||||
pub mod tachi;
|
172
src/types/tachi.rs
Normal file
172
src/types/tachi.rs
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user