mirror of
https://github.com/beerpiss/saekawa.git
synced 2024-11-27 17:00:50 +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