feat: implement cloud link scores hijack to display Tachi PBs instead

This commit is contained in:
Adamaq01 2023-07-16 03:17:44 +02:00
parent 99de91cc43
commit dfba8af45b
9 changed files with 378 additions and 4 deletions

View File

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

View File

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

27
src/cloudlink/ext.rs Normal file
View File

@ -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<Node>;
}
impl HashMapExt for HashMap<Chart, Score> {
fn to_properties(self) -> Vec<Node> {
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()
}
}

130
src/cloudlink/mod.rs Normal file
View File

@ -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 {
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<Vec<u8>> {
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::<Result<HashMap<&str, Chart>>>()?;
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)
}

View File

@ -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<String>,
}
@ -40,5 +55,6 @@ pub struct TachiConfiguration {
pub base_url: String,
pub status: String,
pub import: String,
pub pbs: String,
pub api_key: String,
}

View File

@ -1,3 +1,4 @@
mod cloudlink;
mod configuration;
mod handlers;
mod helpers;

View File

@ -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<u8>,
load: bool,
load_m: bool,
common: bool,
) -> Option<Result<Vec<u8>>> {
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);
}

43
src/types/cloudlink.rs Normal file
View File

@ -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<u32> {
self.property.to_vec()
}
}

View File

@ -1,2 +1,3 @@
pub mod cloudlink;
pub mod game;
pub mod tachi;