From dfba8af45bf2dddbb8ae527dfe8d8cbc4e2a72de Mon Sep 17 00:00:00 2001 From: Adamaq01 Date: Sun, 16 Jul 2023 03:17:44 +0200 Subject: [PATCH] feat: implement cloud link scores hijack to display Tachi PBs instead --- Cargo.toml | 5 ++ mikado.toml | 11 ++- src/cloudlink/ext.rs | 27 ++++++++ src/cloudlink/mod.rs | 130 ++++++++++++++++++++++++++++++++++++ src/configuration.rs | 16 +++++ src/lib.rs | 1 + src/mikado.rs | 148 ++++++++++++++++++++++++++++++++++++++++- src/types/cloudlink.rs | 43 ++++++++++++ src/types/mod.rs | 1 + 9 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 src/cloudlink/ext.rs create mode 100644 src/cloudlink/mod.rs create mode 100644 src/types/cloudlink.rs diff --git a/Cargo.toml b/Cargo.toml index b4571b4..ae2a194 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,8 @@ url = "2.3" either = { version = "1.8", features = ["serde"] } num_enum = "0.6" chrono = "0.4" +kbinxml = { git = "https://github.com/mbilker/kbinxml-rs.git", version = "3.1.0" } +psmap = { git = "https://github.com/mbilker/kbinxml-rs.git", version = "2.0.0" } +psmap_derive = { git = "https://github.com/mbilker/kbinxml-rs.git", version = "2.0.0" } +bytes = "1.4" +dynfmt = { version = "0.1", default-features = false, features = ["curly"] } diff --git a/mikado.toml b/mikado.toml index cc9505c..cd0e5b1 100644 --- a/mikado.toml +++ b/mikado.toml @@ -3,21 +3,26 @@ enable = true # Whether the hook should export your class (skill level) or not export_class = true +# Whether the hook should should inject your Tachi PBs in place of Cloud PBs +inject_cloud_pbs = true +# Timeout for web requests, in milliseconds +timeout = 3000 [cards] # Card numbers that should be whitelisted # If this is empty, all cards will be whitelisted +# E000 format, should be in single quotes and separated by commas +# Example: whitelist = ['E000000000', 'E000000001'] 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' - +# Tachi pbs endpoint +pbs = '/api/v1/users/{}/games/sdvx/Single/pbs/all' # Your Tachi API key api_key = 'your-key-here' diff --git a/src/cloudlink/ext.rs b/src/cloudlink/ext.rs new file mode 100644 index 0000000..ad5c437 --- /dev/null +++ b/src/cloudlink/ext.rs @@ -0,0 +1,27 @@ +use crate::types::cloudlink::{Chart, Score}; +use kbinxml::{Node, Value, ValueArray}; +use std::collections::HashMap; + +pub(crate) trait HashMapExt { + fn to_properties(self) -> Vec; +} + +impl HashMapExt for HashMap { + fn to_properties(self) -> Vec { + self.into_iter() + .map(|(chart, score)| { + let mut property = score.to_property(); + property[0] = chart.song_id; + property[1] = chart.difficulty as u32; + + Node::with_nodes( + "info", + vec![Node::with_value( + "param", + Value::Array(ValueArray::U32(property)), + )], + ) + }) + .collect() + } +} diff --git a/src/cloudlink/mod.rs b/src/cloudlink/mod.rs new file mode 100644 index 0000000..e7a007c --- /dev/null +++ b/src/cloudlink/mod.rs @@ -0,0 +1,130 @@ +mod ext; + +use crate::types::cloudlink::{Chart, Score}; +use crate::{helpers, TACHI_PBS_URL}; +use anyhow::Result; +use dynfmt::Format; +use ext::HashMapExt; +use kbinxml::{CompressionType, EncodingType, Node, Options, Value, ValueArray}; +use log::info; +use std::collections::hash_map::Entry; +use std::collections::HashMap; + +fn build_response_base(scores: Vec) -> Node { + Node::with_nodes( + "response", + vec![Node::with_nodes( + "game", + vec![Node::with_nodes("music", scores)], + )], + ) +} + +pub fn process_pbs(user: &str, music: &Node, encoding: EncodingType) -> Result> { + let url = dynfmt::SimpleCurlyFormat.format(TACHI_PBS_URL.as_str(), [user])?; + let response: serde_json::Value = helpers::request_tachi("GET", url, None::<()>)?; + let body = response["body"].as_object().ok_or(anyhow::anyhow!( + "Could not parse response body from Tachi PBs API" + ))?; + let pbs = body["pbs"] + .as_array() + .ok_or(anyhow::anyhow!("Could not parse PBs from Tachi PBs API"))?; + let charts = body["charts"] + .as_array() + .ok_or(anyhow::anyhow!("Could not parse charts from Tachi PBs API"))?; + let charts = charts + .iter() + .map(|chart| { + let chart_id = chart["chartID"].as_str().ok_or(anyhow::anyhow!( + "Could not parse chart ID from Tachi PBs API" + ))?; + let song_id = chart["data"]["inGameID"].as_u64().ok_or(anyhow::anyhow!( + "Could not parse ingame ID from Tachi PBs API" + ))? as u32; + let difficulty = match chart["difficulty"].as_str().ok_or(anyhow::anyhow!( + "Could not parse difficulty from Tachi PBs API" + ))? { + "NOV" => 0, + "ADV" => 1, + "EXH" => 2, + "MXM" => 4, + _ => 3, + }; + Ok(( + chart_id, + Chart { + song_id, + difficulty, + }, + )) + }) + .collect::>>()?; + + let mut scores = HashMap::with_capacity(music.children().len() + pbs.len()); + for pb in music.children() { + let score = pb + .children() + .first() + .ok_or(anyhow::anyhow!("Could not find param node"))?; + if let Value::Array(ValueArray::U32(value)) = score + .value() + .ok_or(anyhow::anyhow!("Could not find value in param node"))? + { + let song_id = value[0]; + let difficulty = value[1] as u8; + let chart = Chart { + song_id, + difficulty, + }; + let score = Score::from_property(value.as_slice().try_into()?); + scores.insert(chart, score); + } + } + + for pb in pbs { + let chart_id = pb["chartID"].as_str().ok_or(anyhow::anyhow!( + "Could not parse chart ID from Tachi PBs API" + ))?; + let chart = charts + .get(chart_id) + .ok_or(anyhow::anyhow!("Could not find chart"))?; + let score = pb["scoreData"]["score"].as_u64().ok_or(anyhow::anyhow!( + "Could not parse PB score from Tachi PBs API" + ))?; + let lamp = pb["scoreData"]["enumIndexes"]["lamp"] + .as_u64() + .ok_or(anyhow::anyhow!( + "Could not parse PB lamp from Tachi PBs API" + ))? + + 1; + let grade = pb["scoreData"]["enumIndexes"]["grade"] + .as_u64() + .ok_or(anyhow::anyhow!( + "Could not parse PB grade from Tachi PBs API" + ))? + + 1; + + let entry = scores.entry(*chart); + match entry { + Entry::Occupied(mut entry) => { + let base_score = entry.get_mut(); + *base_score.cloud_score_mut() = score as u32; + *base_score.cloud_clear_mut() = lamp as u32; + *base_score.cloud_grade_mut() = grade as u32; + } + Entry::Vacant(entry) => { + let score = Score::from_cloud(score as u32, lamp as u8, grade as u8); + entry.insert(score); + } + } + } + + let response = build_response_base(scores.to_properties()); + let bytes = kbinxml::to_binary_with_options( + Options::new(CompressionType::Uncompressed, encoding), + &response, + )?; + info!("Successfully injected Tachi PBs as Cloud scores"); + + Ok(bytes) +} diff --git a/src/configuration.rs b/src/configuration.rs index d93d524..1db6a39 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -26,12 +26,27 @@ impl Configuration { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GeneralConfiguration { + #[serde(default = "default_true")] pub enable: bool, + #[serde(default)] pub export_class: bool, + #[serde(default)] + pub inject_cloud_pbs: 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, } @@ -40,5 +55,6 @@ pub struct TachiConfiguration { pub base_url: String, pub status: String, pub import: String, + pub pbs: String, pub api_key: String, } diff --git a/src/lib.rs b/src/lib.rs index e015181..8c4d2e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +mod cloudlink; mod configuration; mod handlers; mod helpers; diff --git a/src/mikado.rs b/src/mikado.rs index 09b54c4..1056323 100644 --- a/src/mikado.rs +++ b/src/mikado.rs @@ -7,7 +7,8 @@ use crate::sys::{ use crate::types::game::Property; use crate::{helpers, CONFIGURATION, TACHI_STATUS_URL}; use anyhow::Result; -use lazy_static::lazy_static; +use bytes::Bytes; +use kbinxml::{CompressionType, Node, Options, Value}; use log::{debug, error, info}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -30,6 +31,11 @@ pub fn hook_init() -> Result<()> { // Initializing function detours crochet::enable!(property_destroy_hook) .map_err(|err| anyhow::anyhow!("Could not enable function detour: {:#}", err))?; + if CONFIGURATION.general.inject_cloud_pbs { + debug!("PBs injection enabled"); + crochet::enable!(property_mem_read_hook) + .map_err(|err| anyhow::anyhow!("Could not enable function detour: {:#}", err))?; + } info!("Hook successfully initialized"); @@ -46,9 +52,138 @@ pub fn hook_release() -> Result<()> { .map_err(|err| anyhow::anyhow!("Could not disable function detour: {:#}", err))?; } + if crochet::is_enabled!(property_mem_read_hook) { + crochet::disable!(property_mem_read_hook) + .map_err(|err| anyhow::anyhow!("Could not disable function detour: {:#}", err))?; + } + Ok(()) } +static LOAD: AtomicBool = AtomicBool::new(false); +static LOAD_M: AtomicBool = AtomicBool::new(false); +static COMMON: AtomicBool = AtomicBool::new(false); + +#[crochet::hook("avs2-core.dll", "XCgsqzn00000b7")] +pub unsafe fn property_mem_read_hook( + ptr: *const (), + something: i32, + flags: i32, + data: *const u8, + size: u32, +) -> *const () { + let load = LOAD.load(Ordering::SeqCst); + let load_m = LOAD_M.load(Ordering::SeqCst); + let common = COMMON.load(Ordering::SeqCst); + if !load && !load_m && !common { + return call_original!(ptr, something, flags, data, size); + } + + let bytes = std::slice::from_raw_parts(ptr as *const u8, something as usize).to_vec(); + match property_mem_read_hook_wrapped(bytes, load, load_m, common) { + Some(Ok(response)) => { + call_original!( + response.as_ptr() as *const (), + response.len() as i32, + flags, + data, + size + ) + } + Some(Err(err)) => { + error!( + "Error while processing an important e-amusement response node: {:#}", + err + ); + call_original!(ptr, something, flags, data, size) + } + None => call_original!(ptr, something, flags, data, size), + } +} + +#[allow(clippy::manual_map)] +pub unsafe fn property_mem_read_hook_wrapped( + response: Vec, + load: bool, + load_m: bool, + common: bool, +) -> Option>> { + let (mut root, encoding) = kbinxml::from_bytes(Bytes::from(response)) + .and_then(|(node, encoding)| node.as_node().map(|node| (node, encoding))) + .ok()?; + + if common + .then(|| root.pointer(&["game", "event"])) + .flatten() + .is_some() + { + Some((|| { + let events = root + .pointer_mut(&["game", "event"]) + .expect("Could not find events node"); + + events.children_mut().retain(|info| { + if let Some(Value::String(event_id)) = info + .pointer(&["event_id"]) + .and_then(|event_id| event_id.value()) + { + event_id != "CLOUD_LINK_ENABLE" + } else { + true + } + }); + events.children_mut().push(Node::with_nodes( + "info", + vec![Node::with_value( + "event_id", + Value::String("CLOUD_LINK_ENABLE".to_string()), + )], + )); + let response = kbinxml::to_binary_with_options( + Options::new(CompressionType::Uncompressed, encoding), + &root, + )?; + COMMON.store(false, Ordering::Relaxed); + + Ok(response) + })()) + } else if load + .then(|| root.pointer(&["game", "code"])) + .flatten() + .is_some() + { + Some((|| { + let game = root + .pointer_mut(&["game"]) + .expect("Could not find game node"); + game.children_mut().retain(|node| { + return node.key() != "cloud"; + }); + game.children_mut().push(Node::with_nodes( + "cloud", + vec![Node::with_value("relation", Value::S8(1))], + )); + let response = kbinxml::to_binary_with_options( + Options::new(CompressionType::Uncompressed, encoding), + &root, + )?; + LOAD.store(false, Ordering::Relaxed); + + Ok(response) + })()) + } else if let Some(music) = load_m.then(|| root.pointer(&["game", "music"])).flatten() { + Some((|| { + let user = USER.load(Ordering::SeqCst).to_string(); + let response = crate::cloudlink::process_pbs(user.as_str(), music, encoding)?; + LOAD_M.store(false, Ordering::Relaxed); + + Ok(response) + })()) + } else { + None + } +} + #[crochet::hook("avs2-core.dll", "XCgsqzn0000091")] pub unsafe fn property_destroy_hook(property: *mut ()) -> i32 { if property.is_null() { @@ -102,6 +237,17 @@ pub unsafe fn property_destroy_hook(property: *mut ()) -> i32 { result.unwrap().replace('\0', "") }; debug!("Intercepted Game Method: {}", method); + + if CONFIGURATION.general.inject_cloud_pbs { + if method == "sv6_load_m" { + LOAD_M.store(true, Ordering::Relaxed); + } else if method == "sv6_common" { + COMMON.store(true, Ordering::Relaxed); + } else if method == "sv6_load" { + LOAD.store(true, Ordering::Relaxed); + } + } + if method != "sv6_save_m" && (!CONFIGURATION.general.export_class || method != "sv6_save") { return call_original!(property); } diff --git a/src/types/cloudlink.rs b/src/types/cloudlink.rs new file mode 100644 index 0000000..ada643c --- /dev/null +++ b/src/types/cloudlink.rs @@ -0,0 +1,43 @@ +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct Chart { + pub song_id: u32, + pub difficulty: u8, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Score { + property: [u32; 21], +} + +impl Score { + pub fn from_cloud(score: u32, clear: u8, grade: u8) -> Self { + let mut ret = Self::default(); + ret.property[17] = score; + ret.property[18] = clear as u32; + ret.property[19] = grade as u32; + + ret + } + + pub fn from_property(property: &[u32; 21]) -> Self { + Self { + property: *property, + } + } + + pub fn cloud_score_mut(&mut self) -> &mut u32 { + &mut self.property[17] + } + + pub fn cloud_clear_mut(&mut self) -> &mut u32 { + &mut self.property[18] + } + + pub fn cloud_grade_mut(&mut self) -> &mut u32 { + &mut self.property[19] + } + + pub fn to_property(self) -> Vec { + self.property.to_vec() + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index 80ec264..3310037 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -1,2 +1,3 @@ +pub mod cloudlink; pub mod game; pub mod tachi;