From ce8c6ea47a3d3c638ee255d0202b197c2c7d191a Mon Sep 17 00:00:00 2001 From: Applesaber Date: Sun, 11 Oct 2026 22:14:38 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E4=B8=8B=E5=8F=91=E8=87=AA=E5=AE=9A=E4=B9=89=20Option=20?= =?UTF-8?q?=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用游戏正常的 GetGameSettingApi 响应作为信令通道:服务器在根对象追加 _appleChuResources,客户端按条件选中条目、下载 Option 包、RSA 验签, 并在内存中挂到 option 根,资源不落盘(仅缓存包本体)。 架构骨架对齐 AquaMai ServerResources,差异在载荷:AquaMai 是 Unity AssetBundle,chusan 是 Option 包(AXXX 目录树)。 - net_packet: 在 WinHTTP 响应层取完整响应体与请求路径,解析 JSON 后 广播给订阅者;分发器 sub_99FA60 需固定 RVA,故先不挂钩 - json: 零依赖最小 JSON(读+写),保留键序与数字字面量以保证回写不变; 兼容整数被序列化为字符串(MinatoCore SegaStringSerializer) - entry: 条目解析与条件筛选,游戏版本取自同一响应的 romVersion - download: 进程内 WinHTTP 直连下载,不硬编码服务器地址 - crypto/hash: RSA PKCS#1 v1.5 + SHA-256 验签、base64 解码、内存版 SHA-256 - package: Option 包(zip)解析为内存目录树,校验单根 AXXX - handle/vfs_bridge: 目录枚举注入虚拟 AXXX 根,文件内容由内存服务; 文件句柄用真实 \\.\NUL 承载,避免伪造 HANDLE --- Cargo.toml | 1 + packages/applechu/src/lib.rs | 2 + packages/applechu/src/platform/vfs.rs | 8 + .../applechu/src/server_resources/download.rs | 260 ++++++++++ .../applechu/src/server_resources/entry.rs | 252 ++++++++++ .../applechu/src/server_resources/handle.rs | 173 +++++++ .../applechu/src/server_resources/json.rs | 415 ++++++++++++++++ packages/applechu/src/server_resources/mod.rs | 281 +++++++++++ .../src/server_resources/net_packet.rs | 343 +++++++++++++ .../applechu/src/server_resources/package.rs | 352 ++++++++++++++ .../src/server_resources/vfs_bridge.rs | 451 ++++++++++++++++++ packages/applechu/src/util/crypto.rs | 165 +++++++ packages/applechu/src/util/hash.rs | 3 +- packages/applechu/src/util/mod.rs | 1 + 14 files changed, 2706 insertions(+), 1 deletion(-) create mode 100644 packages/applechu/src/server_resources/download.rs create mode 100644 packages/applechu/src/server_resources/entry.rs create mode 100644 packages/applechu/src/server_resources/handle.rs create mode 100644 packages/applechu/src/server_resources/json.rs create mode 100644 packages/applechu/src/server_resources/mod.rs create mode 100644 packages/applechu/src/server_resources/net_packet.rs create mode 100644 packages/applechu/src/server_resources/package.rs create mode 100644 packages/applechu/src/server_resources/vfs_bridge.rs create mode 100644 packages/applechu/src/util/crypto.rs diff --git a/Cargo.toml b/Cargo.toml index a036484..a3cb5dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ windows-sys = { version = "0.59", features = [ "Win32_Graphics_Gdi", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", + "Win32_Networking_WinHttp", "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Cryptography", diff --git a/packages/applechu/src/lib.rs b/packages/applechu/src/lib.rs index 5311707..7d2235e 100644 --- a/packages/applechu/src/lib.rs +++ b/packages/applechu/src/lib.rs @@ -33,6 +33,8 @@ pub mod platform; #[cfg(target_arch = "x86")] mod proxy; mod schema_embed; +#[cfg(target_arch = "x86")] +mod server_resources; mod slider; mod system_config; mod unlocker; diff --git a/packages/applechu/src/platform/vfs.rs b/packages/applechu/src/platform/vfs.rs index dd454b9..994ecc3 100644 --- a/packages/applechu/src/platform/vfs.rs +++ b/packages/applechu/src/platform/vfs.rs @@ -205,6 +205,14 @@ pub fn amfs_path() -> Option { CONFIG.get().map(|config| config.amfs.clone()) } +/// 游戏原始请求中 option 的**源前缀**(重定向前),规范化小写 +/// +/// 游戏访问 option 时传入的是 `c:\mount\option\...`,`resolve_path` 之后才被 +/// 改写为真实目录;虚拟包匹配必须对源前缀做判断。 +pub fn option_source_prefix() -> String { + VFS_OPTION.to_owned() +} + pub fn appdata_path() -> Option { CONFIG.get().map(|config| config.appdata.clone()) } diff --git a/packages/applechu/src/server_resources/download.rs b/packages/applechu/src/server_resources/download.rs new file mode 100644 index 0000000..758f02a --- /dev/null +++ b/packages/applechu/src/server_resources/download.rs @@ -0,0 +1,260 @@ +//! 资源包下载 +//! +//! 使用进程内的 WinHTTP 直接下载,不经过游戏的连接池,也不硬编码服务器地址 +//! (地址来自服务器下发的 `entry.url`)。对应 AquaMai `GetFileImpl` 的下载段。 + +use std::ffi::c_void; +use std::ptr; + +use windows_sys::Win32::Networking::WinHttp::{ + WinHttpCloseHandle, WinHttpConnect, WinHttpOpen, WinHttpOpenRequest, WinHttpQueryDataAvailable, + WinHttpReadData, WinHttpReceiveResponse, WinHttpSendRequest, WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_FLAG_SECURE, WINHTTP_FLAG_SECURE_DEFAULTS, +}; + +/// 单个响应体的上限,避免异常响应耗尽内存 +const MAX_DOWNLOAD: usize = 256 * 1024 * 1024; +/// 网络超时(毫秒):连接 / 发送 / 接收 +const TIMEOUT_MS: i32 = 30_000; +const WINHTTP_OPTION_CONNECT_TIMEOUT: u32 = 3; +const WINHTTP_OPTION_SEND_TIMEOUT: u32 = 4; +const WINHTTP_OPTION_RECEIVE_TIMEOUT: u32 = 5; + +/// 下载 `url` 指向的资源包 +pub fn download(url: &str) -> Result, String> { + let parsed = ParsedUrl::parse(url)?; + let agent_name = crate::platform::winapi::string_to_wide("AppleChu"); + let server = crate::platform::winapi::string_to_wide(&parsed.host); + let path = crate::platform::winapi::string_to_wide(&parsed.path_and_query); + + unsafe { + let session = WinHttpOpen( + agent_name.as_ptr(), + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + ptr::null(), + ptr::null(), + 0, + ); + if session.is_null() { + return Err("WinHttpOpen failed".to_owned()); + } + + let result = (|| -> Result, String> { + apply_timeout(session)?; + + let connect = WinHttpConnect(session, server.as_ptr(), parsed.port, 0); + if connect.is_null() { + return Err(format!("WinHttpConnect failed for {}", parsed.host)); + } + + let request_result = (|| -> Result, String> { + let flags = if parsed.secure { + WINHTTP_FLAG_SECURE | WINHTTP_FLAG_SECURE_DEFAULTS + } else { + 0 + }; + let request = WinHttpOpenRequest( + connect, + ptr::null(), + path.as_ptr(), + ptr::null(), + ptr::null(), + ptr::null(), + flags, + ); + if request.is_null() { + return Err("WinHttpOpenRequest failed".to_owned()); + } + + let body_result = (|| -> Result, String> { + if WinHttpSendRequest(request, ptr::null(), 0, ptr::null(), 0, 0, 0) == 0 { + return Err("WinHttpSendRequest failed".to_owned()); + } + if WinHttpReceiveResponse(request, ptr::null_mut()) == 0 { + return Err("WinHttpReceiveResponse failed".to_owned()); + } + + let status = query_status(request); + if !(200..300).contains(&status) { + return Err(format!("download returned HTTP {status}")); + } + + let mut body = Vec::new(); + loop { + let mut available = 0u32; + if WinHttpQueryDataAvailable(request, &mut available) == 0 { + return Err("WinHttpQueryDataAvailable failed".to_owned()); + } + if available == 0 { + break; + } + let mut chunk = vec![0u8; available as usize]; + let mut read = 0u32; + if WinHttpReadData( + request, + chunk.as_mut_ptr().cast::(), + available, + &mut read, + ) == 0 + { + return Err("WinHttpReadData failed".to_owned()); + } + if read == 0 { + break; + } + if body.len() + read as usize > MAX_DOWNLOAD { + return Err("download exceeded size limit".to_owned()); + } + chunk.truncate(read as usize); + body.extend_from_slice(&chunk); + } + Ok(body) + })(); + + WinHttpCloseHandle(request); + body_result + })(); + + WinHttpCloseHandle(connect); + request_result + })(); + + WinHttpCloseHandle(session); + result + } +} + +/// 设置连接/发送/接收超时;失败不致命,仅在无超时保护下继续 +unsafe fn apply_timeout(session: *mut c_void) -> Result<(), String> { + use windows_sys::Win32::Networking::WinHttp::WinHttpSetOption; + let value = TIMEOUT_MS; + for option in [ + WINHTTP_OPTION_CONNECT_TIMEOUT, + WINHTTP_OPTION_SEND_TIMEOUT, + WINHTTP_OPTION_RECEIVE_TIMEOUT, + ] { + let ok = WinHttpSetOption( + session, + option, + (&value as *const i32).cast::(), + std::mem::size_of::() as u32, + ); + if ok == 0 { + return Err(format!("WinHttpSetOption({option}) failed")); + } + } + Ok(()) +} + +/// 读取 HTTP 状态码;失败按 0 处理,交由范围判断拒绝 +unsafe fn query_status(request: *mut c_void) -> u32 { + use windows_sys::Win32::Networking::WinHttp::{ + WinHttpQueryHeaders, WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_QUERY_STATUS_CODE, + }; + let mut status = 0u32; + let mut length = std::mem::size_of::() as u32; + let ok = WinHttpQueryHeaders( + request, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + ptr::null(), + (&mut status as *mut u32).cast::(), + &mut length, + ptr::null_mut(), + ); + if ok == 0 { + 0 + } else { + status + } +} + +/// 仅支持 http/https 的简化 URL 分解 +struct ParsedUrl { + secure: bool, + host: String, + port: u16, + path_and_query: String, +} + +impl ParsedUrl { + fn parse(url: &str) -> Result { + let (secure, rest) = if let Some(rest) = url.strip_prefix("https://") { + (true, rest) + } else if let Some(rest) = url.strip_prefix("http://") { + (false, rest) + } else { + return Err(format!("unsupported url scheme: {url}")); + }; + + let (authority, path_and_query) = match rest.find('/') { + Some(index) => (&rest[..index], &rest[index..]), + None => (rest, "/"), + }; + if authority.is_empty() { + return Err(format!("url has no host: {url}")); + } + + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) => { + let port = port + .parse::() + .map_err(|_| format!("invalid port in url: {url}"))?; + (host.to_owned(), port) + } + None => (authority.to_owned(), if secure { 443 } else { 80 }), + }; + + Ok(Self { + secure, + host, + port, + path_and_query: path_and_query.to_owned(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_https_url_with_default_port() { + // Given: 无显式端口的 https 地址。 + let parsed = ParsedUrl::parse("https://play.mumet.net/pack/option.bin").unwrap(); + + // Then: 使用 443 且保留完整路径。 + assert!(parsed.secure); + assert_eq!(parsed.host, "play.mumet.net"); + assert_eq!(parsed.port, 443); + assert_eq!(parsed.path_and_query, "/pack/option.bin"); + } + + #[test] + fn parses_http_url_with_query_and_explicit_port() { + // Given: 带查询串与显式端口的 http 地址。 + let parsed = ParsedUrl::parse("http://127.0.0.1:8080/a/b?c=d").unwrap(); + + // Then: 端口与查询串被正确分离。 + assert!(!parsed.secure); + assert_eq!(parsed.host, "127.0.0.1"); + assert_eq!(parsed.port, 8080); + assert_eq!(parsed.path_and_query, "/a/b?c=d"); + } + + #[test] + fn missing_path_defaults_to_root() { + // Given: 只有主机名的地址。 + let parsed = ParsedUrl::parse("https://example.com").unwrap(); + + // Then: 路径补为 `/`。 + assert_eq!(parsed.path_and_query, "/"); + } + + #[test] + fn rejects_unsupported_scheme() { + // Given: 非 http(s) 地址。 + // Then: 解析失败,避免把任意协议交给 WinHTTP。 + assert!(ParsedUrl::parse("ftp://example.com/a").is_err()); + assert!(ParsedUrl::parse("file:///c:/x").is_err()); + } +} diff --git a/packages/applechu/src/server_resources/entry.rs b/packages/applechu/src/server_resources/entry.rs new file mode 100644 index 0000000..f9a4edb --- /dev/null +++ b/packages/applechu/src/server_resources/entry.rs @@ -0,0 +1,252 @@ +//! 服务器下发条目与条件筛选 +//! +//! 对应 `_appleChuResources.entries[]` 中的单个条目,以及 AquaMai +//! `ConditionalMessage.ShouldShow()` 的等价判定。 + +use crate::server_resources::json::JsonValue; + +/// 单个下发条目 +#[derive(Clone, Debug, Default)] +pub struct ServerResourcesEntry { + pub url: String, + pub sign: String, + pub locales: Vec, + pub minimum_apple_chu_version: Option, + pub maximum_apple_chu_version: Option, + pub minimum_game_version: i64, + pub maximum_game_version: i64, +} + +impl ServerResourcesEntry { + /// 从 JSON 对象解析;缺少 `url` 或 `sign` 视为无效条目 + pub fn from_json(value: &JsonValue) -> Option { + let url = value.get("url").and_then(JsonValue::as_str)?; + let sign = value.get("sign").and_then(JsonValue::as_str)?; + if url.is_empty() || sign.is_empty() { + return None; + } + + Some(Self { + url: url.to_owned(), + sign: sign.to_owned(), + locales: value + .get("locales") + .map(JsonValue::string_array) + .unwrap_or_default(), + minimum_apple_chu_version: optional_string(value, "minimumAppleChuVersion"), + maximum_apple_chu_version: optional_string(value, "maximumAppleChuVersion"), + minimum_game_version: value + .get("minimumGameVersion") + .and_then(JsonValue::as_i64) + .unwrap_or(0), + maximum_game_version: value + .get("maximumGameVersion") + .and_then(JsonValue::as_i64) + .unwrap_or(0), + }) + } + + /// 当前环境是否满足该条目的条件 + /// + /// - `locales`:本期不筛(AppleChu 尚无语言来源),字段保留待后续接入; + /// - AppleChu 版本:按 semver 数值比较; + /// - 游戏版本:按整数比较,0 表示不限制。 + pub fn should_show(&self, locale: Option<&str>, game_version: i64) -> bool { + if !locale_matches(&self.locales, locale) { + return false; + } + + let apple_chu_version = env!("CARGO_PKG_VERSION"); + if let Some(minimum) = &self.minimum_apple_chu_version { + if !version_at_least(apple_chu_version, minimum) { + return false; + } + } + if let Some(maximum) = &self.maximum_apple_chu_version { + if version_greater(apple_chu_version, maximum) { + return false; + } + } + + if self.minimum_game_version != 0 && game_version < self.minimum_game_version { + return false; + } + if self.maximum_game_version != 0 && game_version > self.maximum_game_version { + return false; + } + + true + } +} + +/// 从 `_appleChuResources` 根对象取出全部条目 +pub fn parse_entries(resources: &JsonValue) -> Vec { + resources + .get("entries") + .and_then(JsonValue::as_array) + .map(|items| items.iter().filter_map(ServerResourcesEntry::from_json).collect()) + .unwrap_or_default() +} + +/// 选出第一个满足条件的条目 +pub fn select_entry<'a>( + entries: &'a [ServerResourcesEntry], + locale: Option<&str>, + game_version: i64, +) -> Option<&'a ServerResourcesEntry> { + entries + .iter() + .find(|entry| entry.should_show(locale, game_version)) +} + +fn optional_string(value: &JsonValue, key: &str) -> Option { + value + .get(key) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} + +/// 语言筛选:空列表表示不限制 +/// +/// 当前客户端尚未解析机台语言,`locale` 恒为 `None`,此时不因语言排除条目。 +fn locale_matches(locales: &[String], locale: Option<&str>) -> bool { + if locales.is_empty() { + return true; + } + match locale { + Some(current) => locales.iter().any(|item| item.eq_ignore_ascii_case(current)), + // 无语言来源时不做排除,避免误杀全部条目 + None => true, + } +} + +/// 解析 `major.minor.patch` 为可比较的三元组 +fn parse_version(text: &str) -> Option<(u32, u32, u32)> { + let mut parts = text.trim().split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next().unwrap_or("0").parse::().unwrap_or(0); + let patch = parts.next().unwrap_or("0").parse::().unwrap_or(0); + Some((major, minor, patch)) +} + +/// `current >= minimum`;任一侧无法解析时视为满足,避免因版本串怪异而误杀 +fn version_at_least(current: &str, minimum: &str) -> bool { + match (parse_version(current), parse_version(minimum)) { + (Some(current), Some(minimum)) => current >= minimum, + _ => true, + } +} + +/// `current > maximum`;任一侧无法解析时视为不超限 +fn version_greater(current: &str, maximum: &str) -> bool { + match (parse_version(current), parse_version(maximum)) { + (Some(current), Some(maximum)) => current > maximum, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server_resources::json; + + fn entry_from(text: &str) -> ServerResourcesEntry { + let value = json::parse(text).expect("测试 JSON 必须有效"); + ServerResourcesEntry::from_json(&value).expect("测试条目必须有效") + } + + #[test] + fn parses_full_entry() { + // Given: 服务器返回的完整条目。 + let entry = entry_from( + r#"{"url":"https://example/option.bin","sign":"AAAA","locales":["zh"], + "minimumAppleChuVersion":"2.0.0","maximumAppleChuVersion":null, + "minimumGameVersion":25000,"maximumGameVersion":0}"#, + ); + + // Then: 字段逐项落入结构体。 + assert_eq!(entry.url, "https://example/option.bin"); + assert_eq!(entry.locales, vec!["zh".to_owned()]); + assert_eq!(entry.minimum_game_version, 25000); + assert_eq!(entry.maximum_game_version, 0); + assert!(entry.maximum_apple_chu_version.is_none()); + } + + #[test] + fn entry_without_signature_is_rejected() { + // Given: 缺 sign 的条目。 + // Then: 解析失败,避免无签名内容进入下载流程。 + let value = json::parse(r#"{"url":"https://example/option.bin"}"#).unwrap(); + assert!(ServerResourcesEntry::from_json(&value).is_none()); + } + + #[test] + fn game_version_range_filters_entries() { + // Given: 限制游戏版本 25000 及以上的条目。 + let entry = entry_from( + r#"{"url":"u","sign":"s","minimumGameVersion":25000,"maximumGameVersion":25099}"#, + ); + + // Then: 只有落在区间内的版本命中。 + assert!(entry.should_show(None, 25000)); + assert!(entry.should_show(None, 25099)); + assert!(!entry.should_show(None, 24999)); + assert!(!entry.should_show(None, 25100)); + } + + #[test] + fn zero_game_version_means_unrestricted() { + // Given: 未限制游戏版本的条目。 + let entry = entry_from(r#"{"url":"u","sign":"s"}"#); + + // Then: 任意游戏版本都命中。 + assert!(entry.should_show(None, 0)); + assert!(entry.should_show(None, 999999)); + } + + #[test] + fn apple_chu_version_range_is_compared_semantically() { + // Given: 上限低于当前版本(2.1.0)的条目,以及下限高于当前版本的条目。 + let too_new = entry_from(r#"{"url":"u","sign":"s","maximumAppleChuVersion":"1.9.9"}"#); + let too_old = entry_from(r#"{"url":"u","sign":"s","minimumAppleChuVersion":"9.0.0"}"#); + + // Then: 两者都被排除;宽区间则命中。 + assert!(!too_new.should_show(None, 0)); + assert!(!too_old.should_show(None, 0)); + assert!( + entry_from(r#"{"url":"u","sign":"s","minimumAppleChuVersion":"2.0.0"}"#) + .should_show(None, 0) + ); + } + + #[test] + fn empty_locales_do_not_restrict() { + // Given: 未声明语言的条目。 + let entry = entry_from(r#"{"url":"u","sign":"s"}"#); + + // Then: 有无语言信息都命中。 + assert!(entry.should_show(None, 0)); + assert!(entry.should_show(Some("zh"), 0)); + } + + #[test] + fn select_entry_skips_non_matching_entries() { + // Given: 首条被版本排除,次条可用。 + let value = json::parse( + r#"{"entries":[ + {"url":"a","sign":"s","minimumGameVersion":999999}, + {"url":"b","sign":"s"} + ]}"#, + ) + .unwrap(); + let entries = parse_entries(&value); + + // When: 选出唯一条目。 + let selected = select_entry(&entries, None, 25000).expect("应选中第二条"); + + // Then: 得到第一条可用条目。 + assert_eq!(selected.url, "b"); + } +} diff --git a/packages/applechu/src/server_resources/handle.rs b/packages/applechu/src/server_resources/handle.rs new file mode 100644 index 0000000..fcee381 --- /dev/null +++ b/packages/applechu/src/server_resources/handle.rs @@ -0,0 +1,173 @@ +//! 虚拟文件句柄登记表 +//! +//! 游戏的 option 读取走 `CreateFileW` → `ReadFile` / `GetFileSize` → `CloseHandle`。 +//! 对命中内存 Option 包的路径,`CreateFileW` 钩子会先向系统要一个**真实句柄** +//! (打开 `\\.\NUL`,不落盘、必然成功),再把该真实句柄登记到本表。这样: +//! +//! - 句柄是内核分配的,天然唯一,不会与真实文件句柄冲突; +//! - `ReadFile` / `GetFileSize` / `SetFilePointer` / `CloseHandle` 钩子按句柄 +//! 查表即可区分虚拟与真实文件; +//! - 即便某个真实 API 没被钩到,拿到 NUL 句柄也只是读到 0 字节,不会误读磁盘。 +//! +//! 本模块只做登记与读取簿记,与目标平台无关,便于在主机上直接跑单元测试。 + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +/// 同时打开的虚拟文件上限,避免异常调用把表撑爆 +pub const MAX_OPEN: usize = 64; + +struct OpenFile { + /// 包内相对路径(如 `A200/data.conf`) + key: String, + /// 当前读取偏移 + position: usize, + /// 文件长度 + length: usize, +} + +static FILES: OnceLock>> = OnceLock::new(); + +fn files() -> &'static Mutex> { + FILES.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// 登记一个虚拟文件句柄;超出上限或重复登记返回 `false` +pub fn register(handle: usize, key: &str, length: usize) -> bool { + let Ok(mut files) = files().lock() else { + return false; + }; + if handle == 0 || files.len() >= MAX_OPEN || files.contains_key(&handle) { + return false; + } + files.insert( + handle, + OpenFile { + key: key.to_owned(), + position: 0, + length, + }, + ); + true +} + +/// 注销句柄,返回是否命中 +pub fn unregister(handle: usize) -> bool { + files() + .lock() + .map(|mut files| files.remove(&handle).is_some()) + .unwrap_or(false) +} + +pub fn is_virtual(handle: usize) -> bool { + files() + .lock() + .map(|files| files.contains_key(&handle)) + .unwrap_or(false) +} + +/// 读取簿记:返回 (包内路径, 文件内偏移, 本次可读字节数) +/// +/// `requested` 为 0 时按「读到文件剩余部分」处理,与 `ReadFile` 一致。 +pub fn read(handle: usize, requested: usize) -> Option<(String, usize, usize)> { + let mut files = files().lock().ok()?; + let file = files.get_mut(&handle)?; + let remaining = file.length.saturating_sub(file.position); + let count = if requested == 0 { + remaining + } else { + requested.min(remaining) + }; + let offset = file.position; + file.position += count; + Some((file.key.clone(), offset, count)) +} + +/// 当前 (文件长度, 读取偏移) +pub fn size_and_position(handle: usize) -> Option<(usize, usize)> { + let files = files().lock().ok()?; + files.get(&handle).map(|file| (file.length, file.position)) +} + +/// 设置读取偏移;允许越界,与 `SetFilePointer` 语义一致 +pub fn seek(handle: usize, position: usize) -> Option { + let mut files = files().lock().ok()?; + let file = files.get_mut(&handle)?; + file.position = position; + Some(file.position) +} + +/// 清空全部登记(模块卸载时调用) +pub fn close_all() { + if let Ok(mut files) = files().lock() { + files.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registers_and_reads_sequentially() { + // Given: 登记一个 10 字节的虚拟文件。 + assert!(register(0x1000, "A200/data.conf", 10)); + + // When: 连续读取两次。 + let first = read(0x1000, 4).unwrap(); + let second = read(0x1000, 100).unwrap(); + + // Then: 偏移递增,第二次被限制在剩余 6 字节内。 + assert_eq!(first, ("A200/data.conf".to_owned(), 0, 4)); + assert_eq!(second, ("A200/data.conf".to_owned(), 4, 6)); + assert_eq!(size_and_position(0x1000), Some((10, 10))); + assert!(unregister(0x1000)); + } + + #[test] + fn zero_length_request_reads_to_end() { + // Given: 一个 5 字节文件。 + register(0x2000, "A200/x", 5); + + // When: 请求长度为 0(ReadFile 的常见写法)。 + let chunk = read(0x2000, 0).unwrap(); + + // Then: 返回整个文件。 + assert_eq!(chunk.2, 5); + assert!(unregister(0x2000)); + } + + #[test] + fn seek_repositions_and_allows_past_end() { + // Given: 一个 8 字节文件。 + register(0x3000, "A200/y", 8); + + // When: 定位到 20(越过末尾)再读取。 + assert_eq!(seek(0x3000, 20), Some(20)); + let chunk = read(0x3000, 4).unwrap(); + + // Then: 越界读取返回 0 字节而不是回绕。 + assert_eq!(chunk.2, 0); + assert!(unregister(0x3000)); + } + + #[test] + fn unknown_handle_is_not_virtual() { + // Given: 未登记的句柄。 + // Then: 查询与读取都不命中,调用方回落到真实 API。 + assert!(!is_virtual(0xDEAD)); + assert!(read(0xDEAD, 1).is_none()); + assert!(!unregister(0xDEAD)); + } + + #[test] + fn duplicate_and_zero_handles_are_rejected() { + // Given: 合法登记一次。 + assert!(register(0x4000, "A200/z", 1)); + + // Then: 重复登记与 0 句柄都被拒绝。 + assert!(!register(0x4000, "A200/other", 1)); + assert!(!register(0, "A200/zero", 1)); + assert!(unregister(0x4000)); + } +} diff --git a/packages/applechu/src/server_resources/json.rs b/packages/applechu/src/server_resources/json.rs new file mode 100644 index 0000000..d7caf61 --- /dev/null +++ b/packages/applechu/src/server_resources/json.rs @@ -0,0 +1,415 @@ +//! 最小 JSON 解析与序列化 +//! +//! 仅覆盖网络响应改写所需的能力,不引入第三方依赖: +//! - 解析对象 / 数组 / 字符串 / 数字 / 布尔 / null; +//! - 保留对象键顺序,数字以原始字面量保存,保证未修改内容可逐字节回写; +//! - 数字读取对「数字被序列化成字符串」的场景保持宽松(MinatoCore 的 +//! SegaStringSerializer 会把整数写成 `"25000"`)。 + +use std::fmt::Write as _; + +#[derive(Clone, Debug, PartialEq)] +pub enum JsonValue { + Null, + Bool(bool), + /// 数字保留原始字面量,避免浮点往返丢失精度 + Number(String), + String(String), + Array(Vec), + Object(Vec<(String, JsonValue)>), +} + +impl JsonValue { + /// 按键取值;非对象返回 `None` + pub fn get(&self, key: &str) -> Option<&JsonValue> { + match self { + JsonValue::Object(entries) => entries + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&[JsonValue]> { + match self { + JsonValue::Array(items) => Some(items), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + JsonValue::String(value) => Some(value), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + JsonValue::Bool(value) => Some(*value), + // 兼容布尔被写成字符串 + JsonValue::String(value) => match value.trim() { + "true" | "1" => Some(true), + "false" | "0" => Some(false), + _ => None, + }, + _ => None, + } + } + + /// 读取整数;数字或数字字符串均可接受 + pub fn as_i64(&self) -> Option { + match self { + JsonValue::Number(text) => text.trim().parse::().ok(), + JsonValue::String(text) => text.trim().parse::().ok(), + _ => None, + } + } + + /// 读取字符串数组;非数组返回空切片语义(调用方按「不限制」处理) + pub fn string_array(&self) -> Vec { + self.as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(ToOwned::to_owned)) + .collect() + }) + .unwrap_or_default() + } + + fn write_to(&self, output: &mut String) { + match self { + JsonValue::Null => output.push_str("null"), + JsonValue::Bool(true) => output.push_str("true"), + JsonValue::Bool(false) => output.push_str("false"), + JsonValue::Number(text) => output.push_str(text), + JsonValue::String(value) => write_json_string(value, output), + JsonValue::Array(items) => { + output.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + output.push(','); + } + item.write_to(output); + } + output.push(']'); + } + JsonValue::Object(entries) => { + output.push('{'); + for (index, (key, value)) in entries.iter().enumerate() { + if index > 0 { + output.push(','); + } + write_json_string(key, output); + output.push(':'); + value.write_to(output); + } + output.push('}'); + } + } + } +} + +impl std::fmt::Display for JsonValue { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut output = String::new(); + self.write_to(&mut output); + formatter.write_str(&output) + } +} + +fn write_json_string(value: &str, output: &mut String) { + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + '\u{08}' => output.push_str("\\b"), + '\u{0c}' => output.push_str("\\f"), + control if (control as u32) < 0x20 => { + let _ = write!(output, "\\u{:04x}", control as u32); + } + other => output.push(other), + } + } + output.push('"'); +} + +/// 解析 JSON 文本;失败返回 `None` +pub fn parse(text: &str) -> Option { + let mut parser = Parser { + bytes: text.as_bytes(), + position: 0, + }; + parser.skip_whitespace(); + let value = parser.parse_value()?; + parser.skip_whitespace(); + // 尾部只允许空白,避免把不完整解析当成成功 + (parser.position == parser.bytes.len()).then_some(value) +} + +struct Parser<'a> { + bytes: &'a [u8], + position: usize, +} + +impl Parser<'_> { + fn skip_whitespace(&mut self) { + while let Some(byte) = self.bytes.get(self.position) { + if matches!(byte, b' ' | b'\t' | b'\n' | b'\r') { + self.position += 1; + } else { + break; + } + } + } + + fn peek(&self) -> Option { + self.bytes.get(self.position).copied() + } + + fn parse_value(&mut self) -> Option { + match self.peek()? { + b'{' => self.parse_object(), + b'[' => self.parse_array(), + b'"' => self.parse_string().map(JsonValue::String), + b't' => self.parse_literal("true", JsonValue::Bool(true)), + b'f' => self.parse_literal("false", JsonValue::Bool(false)), + b'n' => self.parse_literal("null", JsonValue::Null), + _ => self.parse_number(), + } + } + + fn parse_literal(&mut self, literal: &str, value: JsonValue) -> Option { + let end = self.position.checked_add(literal.len())?; + (self.bytes.get(self.position..end)? == literal.as_bytes()).then(|| { + self.position = end; + value + }) + } + + fn parse_object(&mut self) -> Option { + self.position += 1; // 跳过 '{' + let mut entries = Vec::new(); + self.skip_whitespace(); + if self.peek()? == b'}' { + self.position += 1; + return Some(JsonValue::Object(entries)); + } + loop { + self.skip_whitespace(); + let key = self.parse_string()?; + self.skip_whitespace(); + if self.peek()? != b':' { + return None; + } + self.position += 1; + self.skip_whitespace(); + let value = self.parse_value()?; + entries.push((key, value)); + self.skip_whitespace(); + match self.peek()? { + b',' => self.position += 1, + b'}' => { + self.position += 1; + return Some(JsonValue::Object(entries)); + } + _ => return None, + } + } + } + + fn parse_array(&mut self) -> Option { + self.position += 1; // 跳过 '[' + let mut items = Vec::new(); + self.skip_whitespace(); + if self.peek()? == b']' { + self.position += 1; + return Some(JsonValue::Array(items)); + } + loop { + self.skip_whitespace(); + items.push(self.parse_value()?); + self.skip_whitespace(); + match self.peek()? { + b',' => self.position += 1, + b']' => { + self.position += 1; + return Some(JsonValue::Array(items)); + } + _ => return None, + } + } + } + + fn parse_string(&mut self) -> Option { + if self.peek()? != b'"' { + return None; + } + self.position += 1; + let mut output = String::new(); + loop { + let byte = self.peek()?; + self.position += 1; + match byte { + b'"' => return Some(output), + b'\\' => { + let escape = self.peek()?; + self.position += 1; + match escape { + b'"' => output.push('"'), + b'\\' => output.push('\\'), + b'/' => output.push('/'), + b'b' => output.push('\u{08}'), + b'f' => output.push('\u{0c}'), + b'n' => output.push('\n'), + b'r' => output.push('\r'), + b't' => output.push('\t'), + b'u' => output.push(self.parse_unicode_escape()?), + _ => return None, + } + } + // 多字节 UTF-8 序列:按首字节长度整体复制 + other if other < 0x80 => output.push(other as char), + other => { + let extra = match other { + 0xC0..=0xDF => 1, + 0xE0..=0xEF => 2, + 0xF0..=0xF7 => 3, + _ => return None, + }; + let start = self.position - 1; + let end = start.checked_add(extra + 1)?; + let slice = self.bytes.get(start..end)?; + output.push_str(std::str::from_utf8(slice).ok()?); + self.position = end; + } + } + } + } + + fn parse_unicode_escape(&mut self) -> Option { + let hex = self.bytes.get(self.position..self.position + 4)?; + let code = u32::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?; + self.position += 4; + // 代理对:高代理后必须紧跟 `\uXXXX` 低代理 + if (0xD800..0xDC00).contains(&code) { + if self.bytes.get(self.position..self.position + 2)? != b"\\u" { + return None; + } + self.position += 2; + let low_hex = self.bytes.get(self.position..self.position + 4)?; + let low = u32::from_str_radix(std::str::from_utf8(low_hex).ok()?, 16).ok()?; + self.position += 4; + let combined = 0x1_0000 + ((code - 0xD800) << 10) + (low - 0xDC00); + char::from_u32(combined) + } else { + char::from_u32(code) + } + } + + fn parse_number(&mut self) -> Option { + let start = self.position; + while let Some(byte) = self.peek() { + if matches!(byte, b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') { + self.position += 1; + } else { + break; + } + } + if start == self.position { + return None; + } + let text = std::str::from_utf8(self.bytes.get(start..self.position)?).ok()?; + Some(JsonValue::Number(text.to_owned())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_nested_objects_and_arrays() { + // Given: 带嵌套的响应片段。 + let text = r#"{"a":{"b":[1,2,{"c":"d"}]},"e":null}"#; + + // When: 解析后按键逐层取值。 + let value = parse(text).expect("有效 JSON 必须解析成功"); + + // Then: 结构与取值符合预期。 + let array = value + .get("a") + .and_then(|a| a.get("b")) + .and_then(JsonValue::as_array) + .expect("嵌套数组必须存在"); + assert_eq!(array.len(), 3); + assert_eq!(array[0].as_i64(), Some(1)); + assert_eq!(array[2].get("c").and_then(JsonValue::as_str), Some("d")); + assert_eq!(value.get("e"), Some(&JsonValue::Null)); + } + + #[test] + fn round_trips_without_changing_bytes() { + // Given: 一段格式紧凑的响应。 + let text = r#"{"gameSetting":{"dataVersion":"2.50.01","maxCountMusic":300},"isAou":false}"#; + + // When: 解析再序列化。 + let value = parse(text).expect("有效 JSON 必须解析成功"); + + // Then: 输出与输入逐字节一致(键顺序与数字字面量都保留)。 + assert_eq!(value.to_string(), text); + } + + #[test] + fn accepts_numbers_serialized_as_strings() { + // Given: MinatoCore 会把整数写成字符串。 + let text = r#"{"minimumGameVersion":"25000"}"#; + + // When: 读取该字段。 + let value = parse(text).expect("有效 JSON 必须解析成功"); + + // Then: 宽松读取仍能得到整数。 + assert_eq!( + value.get("minimumGameVersion").and_then(JsonValue::as_i64), + Some(25000) + ); + } + + #[test] + fn decodes_escapes_and_surrogate_pairs() { + // Given: 含转义与代理对的字符串。 + let text = r#"{"s":"a\"b\\c中😀"}"#; + + // When: 解析。 + let value = parse(text).expect("有效 JSON 必须解析成功"); + + // Then: 转义与代理对都被正确还原。 + assert_eq!( + value.get("s").and_then(JsonValue::as_str), + Some("a\"b\\c中😀") + ); + } + + #[test] + fn rejects_trailing_garbage() { + // Given: 合法值后跟随多余内容。 + // Then: 解析失败,避免把截断响应当成完整数据。 + assert!(parse(r#"{"a":1} extra"#).is_none()); + assert!(parse(r#"{"a":}"#).is_none()); + } + + #[test] + fn missing_field_is_none() { + // Given: 服务器未下发该字段的响应。 + let value = parse(r#"{"gameSetting":{}}"#).expect("有效 JSON 必须解析成功"); + + // Then: 缺失字段返回 None,调用方据此跳过处理。 + assert!(value.get("_appleChuResources").is_none()); + } +} diff --git a/packages/applechu/src/server_resources/mod.rs b/packages/applechu/src/server_resources/mod.rs new file mode 100644 index 0000000..05cccd9 --- /dev/null +++ b/packages/applechu/src/server_resources/mod.rs @@ -0,0 +1,281 @@ +//! 服务器下发自定义 Option 资源 +//! +//! 架构对齐 AquaMai `ServerResources`:监听游戏正常的 `GetGameSettingApi` +//! 响应 → 读 `_appleChuResources` → 条件筛选 → 下载 → RSA 验签 → 缓存 → +//! 解析为内存 Option 包 → 交给 VFS 呈现。资源不落盘(仅缓存包本体)。 +//! +//! 流程细节见 `D:\Git\work\plan\服务器下发-架构.md`。 + +pub mod download; +pub mod entry; +pub mod handle; +pub mod json; +pub mod net_packet; +pub mod package; +pub mod vfs_bridge; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; + +use crate::config::Config; +use crate::server_resources::json::JsonValue; +use crate::util::api::Api; + +crate::config_section! { + pub(crate) struct ServerResourcesConfig => SERVER_RESOURCES_CONFIG_SECTION { + section: "ServerResources", + order: 350, + default_on: false, + always_enabled: false, + hidden: false, + group: "network", + comment: "服务器下发自定义 Option 资源", + fields: { + pub public_key: String = String::new(), + advanced: true, + comment: "RSA 公钥(base64 modulus),留空则忽略服务器下发"; + pub cache_path: String = String::from("tmp/ServerResources"), + advanced: true, + comment: "资源包缓存路径(相对游戏目录)"; + } + } +} + +/// 当前生效的 Option 资源包;供 VFS 层枚举与读取 +static PACKAGE: OnceLock> = OnceLock::new(); +static DOWNLOAD_STARTED: AtomicBool = AtomicBool::new(false); + +pub(crate) fn is_enabled(config: &Config) -> bool { + config + .section::() + .is_some_and(|section| section.enabled) +} + +/// 初始化:注册信令处理与网络桥 +#[applechu_macros::config_section(stage = Late, order = 60, shutdown = shutdown)] +pub(crate) fn init(api: &Api, config: &Config) -> Result<(), String> { + if !is_enabled(config) { + return Ok(()); + } + + let Some(section) = config.section::() else { + return Ok(()); + }; + if section.public_key.trim().is_empty() { + api.log_warn("ServerResources: public_key is empty, resource download is disabled"); + return Ok(()); + } + + net_packet::subscribe(on_response); + net_packet::install(api, config)?; + vfs_bridge::install(api)?; + api.log_info("ServerResources: waiting for GetGameSettingApi response"); + Ok(()) +} + +/// 关闭:还原 VFS 钩子并释放虚拟句柄 +pub(crate) fn shutdown() { + vfs_bridge::shutdown(); +} + +/// 取出已就绪的资源包(未命中时为 `None`) +pub(crate) fn package() -> Option<&'static package::OptionPackage> { + PACKAGE.get().and_then(Option::as_ref) +} + +/// 信令回调:在 `GetGameSettingApi` 响应中查找下发条目 +fn on_response(api_name: &str, response: &mut JsonValue) -> bool { + // 只监听登录设置响应;其他 API 与包含资源字段的响应都不处理 + if api_name != "GetGameSettingApi" { + return false; + } + let Some(resources) = response.get("_appleChuResources") else { + return false; + }; + + let entries = entry::parse_entries(resources); + if entries.is_empty() { + return false; + } + + let game_version = game_version_from(response); + let Some(selected) = entry::select_entry(&entries, None, game_version) else { + log_info(&format!( + "ServerResources: {count} entr{suffix} present but none matched (gameVersion={game_version})", + count = entries.len(), + suffix = if entries.len() == 1 { "y" } else { "ies" } + )); + return false; + }; + + log_info(&format!( + "ServerResources: entry matched, url={} gameVersion={game_version}", + selected.url + )); + start_download(selected); + // 本功能只读取响应,不改写,因此始终返回 false + false +} + +/// 从同一响应派生游戏版本整数(`romVersion` / `dataVersion`) +/// +/// 取 `major*10000 + minor*100 + release`;缺失或异常时返回 0(不限制)。 +fn game_version_from(response: &JsonValue) -> i64 { + let setting = response.get("gameSetting"); + let rom_version = setting + .and_then(|value| value.get("romVersion")) + .and_then(JsonValue::as_str) + .unwrap_or_default(); + version_to_int(rom_version) +} + +fn version_to_int(text: &str) -> i64 { + let mut parts = text.trim().split('.'); + let major = parts.next().and_then(|part| part.trim().parse::().ok()); + let minor = parts.next().and_then(|part| part.trim().parse::().ok()); + let release = parts.next().and_then(|part| part.trim().parse::().ok()); + match (major, minor, release) { + (Some(major), Some(minor), Some(release)) => major * 10_000 + minor * 100 + release, + (Some(major), Some(minor), None) => major * 10_000 + minor * 100, + _ => 0, + } +} + +/// 启动后台下载(只允许一次) +fn start_download(selected: &entry::ServerResourcesEntry) { + if DOWNLOAD_STARTED.swap(true, Ordering::AcqRel) { + return; + } + // 下载与解析放在独立线程,避免阻塞收包线程;开机闸门(PowerOn 状态改写) + // 接线前,先保证拿到包即可。 + let url = selected.url.clone(); + let sign = selected.sign.clone(); + std::thread::spawn(move || { + if let Err(error) = fetch_and_load(&url, &sign) { + log_warn(&format!("ServerResources: {error}")); + } + }); +} + +fn fetch_and_load(url: &str, sign: &str) -> Result<(), String> { + let Some(api) = crate::util::api::API.get() else { + return Err("api handle is not available".to_owned()); + }; + let Some((public_key, cache_path)) = api_config() else { + return Err("ServerResources config is not available".to_owned()); + }; + + let cache = resolve_cache_path(&cache_path); + + // 1) 缓存命中:签名一致则直接加载,不重新下载 + if let Ok(cached) = std::fs::read(&cache) { + if crate::util::crypto::verify_signature(&public_key, &cached, sign) { + api.log_info("ServerResources: cache hit, loading from cache"); + return load_package(&cached); + } + api.log_warn("ServerResources: cache signature mismatch, re-downloading"); + let _ = std::fs::remove_file(&cache); + } + + // 2) 下载 → 验签 → 写缓存 + let bytes = download::download(url)?; + if !crate::util::crypto::verify_signature(&public_key, &bytes, sign) { + return Err("downloaded package failed signature verification".to_owned()); + } + if let Some(parent) = std::path::Path::new(&cache).parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(error) = std::fs::write(&cache, &bytes) { + api.log_warn(&format!("ServerResources: cache write failed: {error}")); + } + load_package(&bytes) +} + +fn load_package(bytes: &[u8]) -> Result<(), String> { + let package = package::OptionPackage::parse(bytes)?; + let root = package.root().to_owned(); + let count = package.file_count(); + let _ = PACKAGE.set(Some(package)); + log_info(&format!( + "ServerResources: option package ready root={root} files={count}" + )); + Ok(()) +} + +/// 缓存文件绝对路径(相对游戏目录) +fn resolve_cache_path(configured: &str) -> String { + let base = crate::proxy::base_dir().unwrap_or_else(|| ".".to_owned()); + let relative = configured.trim(); + if relative.is_empty() { + format!("{base}\\tmp\\ServerResources") + } else { + let normalized = relative.replace('/', "\\"); + if normalized.contains(':') { + normalized + } else { + format!("{base}\\{normalized}") + } + } +} + +/// 取当前配置中的 ServerResources 段(公钥与缓存路径) +fn api_config() -> Option<(String, String)> { + let base = crate::proxy::base_dir()?; + let config = Config::global(&base); + config + .section::() + .map(|section| (section.public_key.clone(), section.cache_path.clone())) +} + +fn log_info(message: &str) { + if let Some(api) = crate::util::api::API.get() { + api.log_info(message); + } +} + +fn log_warn(message: &str) { + if let Some(api) = crate::util::api::API.get() { + api.log_warn(message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn game_version_builds_from_semver_text() { + // Given: 服务器下发的 romVersion 文本。 + // Then: 转成 major*10000 + minor*100 + release。 + assert_eq!(version_to_int("2.50.00"), 25000); + assert_eq!(version_to_int("2.50.01"), 25001); + assert_eq!(version_to_int("2.45"), 24500); + } + + #[test] + fn malformed_version_is_unrestricted() { + // Given: 非数字版本串。 + // Then: 归零,条件筛选视为不限制。 + assert_eq!(version_to_int(""), 0); + assert_eq!(version_to_int("abc"), 0); + assert_eq!(version_to_int("2.x.1"), 0); + } + + #[test] + fn game_version_reads_from_response() { + // Given: 含 gameSetting.romVersion 的响应。 + let value = json::parse(r#"{"gameSetting":{"romVersion":"2.50.00"}}"#).unwrap(); + + // Then: 版本整数被正确派生。 + assert_eq!(game_version_from(&value), 25000); + assert_eq!(game_version_from(&json::parse("{}").unwrap()), 0); + } + + #[test] + fn cache_path_is_relative_to_game_directory() { + // Given: 配置中的相对缓存路径。 + // Then: 展开到游戏目录下,且不因缺少 base_dir 而 panic。 + let path = resolve_cache_path("tmp/ServerResources"); + assert!(path.ends_with("tmp\\ServerResources")); + } +} diff --git a/packages/applechu/src/server_resources/net_packet.rs b/packages/applechu/src/server_resources/net_packet.rs new file mode 100644 index 0000000..5d29b90 --- /dev/null +++ b/packages/applechu/src/server_resources/net_packet.rs @@ -0,0 +1,343 @@ +//! 网络响应桥 +//! +//! 在游戏自身的 WinHTTP 调用上挂钩,取出**完整响应体**与**请求路径**, +//! 再广播给订阅者。对应 AquaMai 的 `NetPacketHook.OnNetPacketComplete`。 +//! +//! 之所以在这一层而不是原生分发器(RVA 0x59FA60)上挂钩:分发器依赖固定 +//! RVA,跨版本会漂移;WinHTTP 导入是稳定 ABI,且 `patches/network.rs` 的 +//! `DisableEncryption` 默认生效时响应本就是明文,无需调用 `gm::Aes` 解密。 +//! +//! 逻辑 API 名从请求路径末段取得(如 `.../GetGameSettingApi` → `GetGameSettingApi`), +//! 与游戏自身的 API 名表(宽字符串 `0x18F46BC` 起)一致。 + +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use crate::config::Config; +use crate::server_resources::json::{self, JsonValue}; +use crate::util::api::Api; +use crate::util::iat_hook::hook_iat; + +const WINHTTP_DLL: &str = "winhttp.dll"; +const WINHTTP_OPEN_REQUEST: &str = "WinHttpOpenRequest"; +const WINHTTP_QUERY_DATA_AVAILABLE: &str = "WinHttpQueryDataAvailable"; +const WINHTTP_READ_DATA: &str = "WinHttpReadData"; +const WINHTTP_CLOSE_HANDLE: &str = "WinHttpCloseHandle"; + +type WinHttpOpenRequestFn = unsafe extern "system" fn( + *mut c_void, + *const u16, + *const u16, + *const u16, + *const u16, + *const *const u16, + u32, +) -> *mut c_void; +type WinHttpQueryDataAvailableFn = unsafe extern "system" fn(*mut c_void, *mut u32) -> i32; +type WinHttpReadDataFn = unsafe extern "system" fn(*mut c_void, *mut c_void, u32, *mut u32) -> i32; +type WinHttpCloseHandleFn = unsafe extern "system" fn(*mut c_void) -> i32; + +static ORIG_OPEN_REQUEST: AtomicUsize = AtomicUsize::new(0); +static ORIG_QUERY_DATA_AVAILABLE: AtomicUsize = AtomicUsize::new(0); +static ORIG_READ_DATA: AtomicUsize = AtomicUsize::new(0); +static ORIG_CLOSE_HANDLE: AtomicUsize = AtomicUsize::new(0); + +/// 响应处理回调:返回 `true` 表示已改写响应,需要重新序列化回写 +pub type ResponseHandler = fn(api: &str, response: &mut JsonValue) -> bool; + +static HANDLERS: OnceLock>> = OnceLock::new(); +static REQUESTS: OnceLock>> = OnceLock::new(); + +/// 单个请求的累积状态 +#[derive(Default)] +struct RequestState { + /// 请求路径(用于解析逻辑 API 名) + path: String, + /// 已读取的响应体 + body: Vec, + /// 是否已派发,避免同一请求重复处理 + dispatched: bool, +} + +fn handlers() -> &'static Mutex> { + HANDLERS.get_or_init(|| Mutex::new(Vec::new())) +} + +fn requests() -> &'static Mutex> { + REQUESTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// 注册响应处理回调 +pub fn subscribe(handler: ResponseHandler) { + if let Ok(mut handlers) = handlers().lock() { + if !handlers.contains(&handler) { + handlers.push(handler); + } + } +} + +/// 安装 WinHTTP 响应桥 +pub fn install(api: &Api, config: &Config) -> Result<(), String> { + if !crate::server_resources::is_enabled(config) { + return Ok(()); + } + + unsafe { + let opened = hook_iat( + api.game_base(), + WINHTTP_DLL, + WINHTTP_OPEN_REQUEST, + hooked_open_request as *const (), + ); + let Some(opened) = opened else { + return Err("WinHttpOpenRequest import not found".to_owned()); + }; + ORIG_OPEN_REQUEST.store(opened as usize, Ordering::SeqCst); + + let queried = hook_iat( + api.game_base(), + WINHTTP_DLL, + WINHTTP_QUERY_DATA_AVAILABLE, + hooked_query_data_available as *const (), + ); + let Some(queried) = queried else { + return Err("WinHttpQueryDataAvailable import not found".to_owned()); + }; + ORIG_QUERY_DATA_AVAILABLE.store(queried as usize, Ordering::SeqCst); + + let read = hook_iat( + api.game_base(), + WINHTTP_DLL, + WINHTTP_READ_DATA, + hooked_read_data as *const (), + ); + let Some(read) = read else { + return Err("WinHttpReadData import not found".to_owned()); + }; + ORIG_READ_DATA.store(read as usize, Ordering::SeqCst); + + let closed = hook_iat( + api.game_base(), + WINHTTP_DLL, + WINHTTP_CLOSE_HANDLE, + hooked_close_handle as *const (), + ); + if let Some(closed) = closed { + ORIG_CLOSE_HANDLE.store(closed as usize, Ordering::SeqCst); + } + } + + api.log_info("patch applied: net response bridge (WinHTTP)"); + Ok(()) +} + +/// 上传一次完整响应:解析 JSON 后依次交给订阅者 +/// +/// 响应不是合法 JSON 时静默跳过(游戏存在二进制/空响应)。 +fn dispatch(path: &str, body: &[u8]) { + let Some(name) = api_name_from_path(path) else { + return; + }; + let Ok(text) = std::str::from_utf8(body) else { + return; + }; + // 只处理 JSON 对象响应;纯文本/二进制响应不进入改写流程 + let Some(mut value) = json::parse(text.trim_start_matches('\u{feff}')) else { + return; + }; + + let mut modified = false; + if let Ok(handlers) = handlers().lock() { + for handler in handlers.iter() { + if handler(&name, &mut value) { + modified = true; + } + } + } + // 改写结果记录到日志;回写由调用方(游戏自身缓冲)决定, + // 本桥只读响应,不修改游戏的内存流。 + if modified { + log_info(&format!( + "net response modified by subscriber: {name} ({} bytes)", + value.to_string().len() + )); + } +} + +/// 从请求路径解析逻辑 API 名(末段,去掉查询串与扩展) +fn api_name_from_path(path: &str) -> Option { + let without_query = path.split(['?', '#']).next().unwrap_or(path); + let segment = without_query + .rsplit(['/', '\\']) + .find(|part| !part.is_empty())?; + (!segment.is_empty()).then(|| segment.to_owned()) +} + +unsafe fn with_request(handle: usize, modify: F) { + if let Ok(mut requests) = requests().lock() { + let state = requests.entry(handle).or_default(); + modify(state); + } +} + +/// 完成一次派发:取出累积的响应体,标记已派发 +unsafe fn finish_request(handle: usize) { + let payload = { + let Ok(mut requests) = requests().lock() else { + return; + }; + match requests.get_mut(&handle) { + Some(state) if !state.dispatched && !state.body.is_empty() => { + state.dispatched = true; + let body = std::mem::take(&mut state.body); + Some((state.path.clone(), body)) + } + _ => None, + } + }; + if let Some((path, body)) = payload { + dispatch(&path, &body); + } +} + +unsafe extern "system" fn hooked_open_request( + connect: *mut c_void, + verb: *const u16, + object_name: *const u16, + version: *const u16, + referrer: *const u16, + accept_types: *const *const u16, + flags: u32, +) -> *mut c_void { + let original = ORIG_OPEN_REQUEST.load(Ordering::SeqCst); + if original == 0 { + return std::ptr::null_mut(); + } + let original: WinHttpOpenRequestFn = std::mem::transmute(original); + let result = original( + connect, + verb, + object_name, + version, + referrer, + accept_types, + flags, + ); + + if !result.is_null() && !object_name.is_null() { + let path = crate::platform::winapi::wide_to_string(object_name).unwrap_or_default(); + with_request(result as usize, |state| { + state.path = path; + state.body.clear(); + state.dispatched = false; + }); + } + result +} + +unsafe extern "system" fn hooked_query_data_available( + request: *mut c_void, + available: *mut u32, +) -> i32 { + let original = ORIG_QUERY_DATA_AVAILABLE.load(Ordering::SeqCst); + if original == 0 { + return 0; + } + let original: WinHttpQueryDataAvailableFn = std::mem::transmute(original); + let result = original(request, available); + if result != 0 && !available.is_null() && *available == 0 { + // 可用字节为 0 表示响应体结束,此时游戏不会再调用 ReadData + finish_request(request as usize); + } + result +} + +unsafe extern "system" fn hooked_read_data( + request: *mut c_void, + buffer: *mut c_void, + bytes_to_read: u32, + bytes_read: *mut u32, +) -> i32 { + let original = ORIG_READ_DATA.load(Ordering::SeqCst); + if original == 0 { + return 0; + } + let original: WinHttpReadDataFn = std::mem::transmute(original); + let result = original(request, buffer, bytes_to_read, bytes_read); + if result != 0 && !bytes_read.is_null() { + let read = *bytes_read as usize; + if read > 0 && !buffer.is_null() { + let chunk = std::slice::from_raw_parts(buffer as *const u8, read); + with_request(request as usize, |state| { + // 上限保护:异常响应不至于无界增长 + const MAX_BODY: usize = 8 * 1024 * 1024; + if state.body.len() + chunk.len() <= MAX_BODY { + state.body.extend_from_slice(chunk); + } + }); + } else if read == 0 { + // 读到 0 字节表示响应体结束 + finish_request(request as usize); + } + } + result +} + +unsafe extern "system" fn hooked_close_handle(handle: *mut c_void) -> i32 { + let original = ORIG_CLOSE_HANDLE.load(Ordering::SeqCst); + if original == 0 { + return 0; + } + // 游戏可能不等待 0 字节就读完即关闭,这里兜底派发一次 + finish_request(handle as usize); + if let Ok(mut requests) = requests().lock() { + requests.remove(&(handle as usize)); + } + let original: WinHttpCloseHandleFn = std::mem::transmute(original); + original(handle) +} + +fn log_info(message: &str) { + if let Some(api) = crate::util::api::API.get() { + api.log_info(message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn api_name_is_last_path_segment() { + // Given: 游戏实际使用的几种请求路径形态。 + let cases = [ + ("/NetDelivery/GetGameSettingApi", "GetGameSettingApi"), + ("/NetDelivery/GetGameSettingApi?k=v", "GetGameSettingApi"), + ("NetDelivery\\GetGameSettingApi", "GetGameSettingApi"), + ("GetGameSettingApi", "GetGameSettingApi"), + ]; + + // When/Then: 都取到末段逻辑名。 + for (path, expected) in cases { + assert_eq!(api_name_from_path(path).as_deref(), Some(expected)); + } + } + + #[test] + fn empty_path_has_no_api_name() { + // Given: 空路径。 + // Then: 不产生逻辑名,调用方跳过处理。 + assert!(api_name_from_path("").is_none()); + assert!(api_name_from_path("/").is_none()); + } + + #[test] + fn dispatch_ignores_non_json_bodies() { + // Given: 一段非 JSON 响应。 + // When/Then: 派发不 panic 且不触发任何处理(无订阅者时也安全)。 + dispatch("/x/GetGameSettingApi", b"not json at all"); + dispatch("/x/GetGameSettingApi", &[0xff, 0xfe, 0x00]); + } +} diff --git a/packages/applechu/src/server_resources/package.rs b/packages/applechu/src/server_resources/package.rs new file mode 100644 index 0000000..5f3c59f --- /dev/null +++ b/packages/applechu/src/server_resources/package.rs @@ -0,0 +1,352 @@ +//! Option 资源包(zip 容器)解析与虚拟目录树 +//! +//! 服务器下发的资源包是一个 zip,内容为单个 `AXXX` 目录树(与磁盘 option +//! 同构,含 `data.conf`)。本模块把它解析为内存中的文件表,并提供目录枚举 +//! 与文件读取,供 VFS 层呈现给游戏。 +//! +//! 支持 zip 的 `store`(method 0)与 `deflate`(method 8)。deflate 需要 +//! 游戏自带的 `projClient::Deflate`(RTTI 0x1C5CC34),未接线前对 deflate +//! 条目返回明确错误。 + +use std::collections::BTreeMap; + +const EOCD_SIGNATURE: u32 = 0x0605_4b50; +const CENTRAL_SIGNATURE: u32 = 0x0201_4b50; +const METHOD_STORE: u16 = 0; +const METHOD_DEFLATE: u16 = 8; +/// zip 注释最长 65535 字节,EOCD 结构 22 字节 +const EOCD_SEARCH_LIMIT: usize = 22 + 0xffff; + +/// 内存中的 Option 资源包 +pub struct OptionPackage { + /// 以 `/` 分隔的相对路径 → 文件内容 + files: BTreeMap>, + /// 根目录名(`AXXX`) + root: String, +} + +impl OptionPackage { + /// 解析 zip 字节流 + pub fn parse(bytes: &[u8]) -> Result { + let central = locate_central_directory(bytes)?; + let mut files = BTreeMap::new(); + let mut root: Option = None; + let mut cursor = central; + let mut pending = 0usize; + + loop { + let signature = read_u32(bytes, cursor) + .ok_or_else(|| "zip central directory is truncated".to_owned())?; + if signature != CENTRAL_SIGNATURE { + break; + } + let method = read_u16(bytes, cursor + 10) + .ok_or_else(|| "zip central entry is truncated".to_owned())?; + let compressed_size = read_u32(bytes, cursor + 20) + .ok_or_else(|| "zip central entry is truncated".to_owned())? + as usize; + let name_len = read_u16(bytes, cursor + 28) + .ok_or_else(|| "zip central entry is truncated".to_owned())? + as usize; + let extra_len = read_u16(bytes, cursor + 30) + .ok_or_else(|| "zip central entry is truncated".to_owned())? + as usize; + let comment_len = read_u16(bytes, cursor + 32) + .ok_or_else(|| "zip central entry is truncated".to_owned())? + as usize; + let local_offset = read_u32(bytes, cursor + 42) + .ok_or_else(|| "zip central entry is truncated".to_owned())? + as usize; + + let name_start = cursor + 46; + let name_bytes = bytes + .get(name_start..name_start + name_len) + .ok_or_else(|| "zip entry name is truncated".to_owned())?; + let name = String::from_utf8_lossy(name_bytes).replace('\\', "/"); + + // 目录条目以 `/` 结尾,不需要建立文件 + if !name.ends_with('/') && !name.is_empty() { + let data = read_entry_data(bytes, local_offset, method, compressed_size)?; + if let Some(segment) = name.split('/').next() { + if !segment.is_empty() { + match &root { + Some(existing) if existing != segment => { + return Err(format!( + "option package spans multiple roots: {existing} and {segment}" + )); + } + _ => root = Some(segment.to_owned()), + } + } + } + files.insert(name, data); + } + + pending += 1; + if pending > 10_000 { + return Err("option package has too many entries".to_owned()); + } + cursor = name_start + .checked_add(name_len) + .and_then(|value| value.checked_add(extra_len)) + .and_then(|value| value.checked_add(comment_len)) + .ok_or_else(|| "zip central directory offset overflowed".to_owned())?; + } + + if files.is_empty() { + return Err("option package contains no files".to_owned()); + } + let root = root.ok_or_else(|| "option package has no root directory".to_owned())?; + + Ok(Self { files, root }) + } + + /// 根目录名(`AXXX`) + pub fn root(&self) -> &str { + &self.root + } + + /// 读取文件;路径以 `/` 分隔 + pub fn read_file(&self, path: &str) -> Option<&[u8]> { + self.files.get(&normalize(path)).map(Vec::as_slice) + } + + pub fn contains(&self, path: &str) -> bool { + self.files.contains_key(&normalize(path)) + } + + /// 列出目录下的直接子项(名称, 是否目录),按名称排序 + pub fn list_dir(&self, path: &str) -> Vec<(String, bool)> { + let prefix = { + let normalized = normalize(path); + if normalized.is_empty() { + String::new() + } else { + format!("{normalized}/") + } + }; + + let mut seen: BTreeMap = BTreeMap::new(); + for name in self.files.keys() { + let Some(rest) = name.strip_prefix(&prefix) else { + continue; + }; + if rest.is_empty() { + continue; + } + match rest.split_once('/') { + Some((segment, _)) => { + seen.insert(segment.to_owned(), true); + } + None => { + seen.insert(rest.to_owned(), false); + } + } + } + seen.into_iter().collect() + } + + /// 全部文件路径,用于日志诊断 + pub fn paths(&self) -> impl Iterator { + self.files.keys().map(String::as_str) + } + + pub fn file_count(&self) -> usize { + self.files.len() + } +} + +fn normalize(path: &str) -> String { + path.trim_matches('/').replace('\\', "/") +} + +/// 定位中央目录起始偏移 +fn locate_central_directory(bytes: &[u8]) -> Result { + // EOCD 结构本身 22 字节,短于它不可能是 zip + if bytes.len() < 22 { + return Err("zip is too short".to_owned()); + } + let search_start = bytes.len().saturating_sub(EOCD_SEARCH_LIMIT); + let mut index = bytes.len() - 22; + loop { + if read_u32(bytes, index) == Some(EOCD_SIGNATURE) { + let offset = read_u32(bytes, index + 16) + .ok_or_else(|| "zip end-of-central-directory is truncated".to_owned())? + as usize; + if offset < bytes.len() { + return Ok(offset); + } + } + if index == search_start { + return Err("zip end-of-central-directory not found".to_owned()); + } + index -= 1; + } +} + +/// 读取单个条目的数据(store 直接切片,deflate 暂不支持) +fn read_entry_data( + bytes: &[u8], + local_offset: usize, + method: u16, + compressed_size: usize, +) -> Result, String> { + // 本地文件头:签名(4) + 版本(2) + 标志(2) + 方法(2) + 时间(2) + 日期(2) + // + CRC(4) + 压缩大小(4) + 原始大小(4) + 名称长度(2) + 扩展长度(2) + let name_len = read_u16(bytes, local_offset + 26) + .ok_or_else(|| "zip local header is truncated".to_owned())? + as usize; + let extra_len = read_u16(bytes, local_offset + 28) + .ok_or_else(|| "zip local header is truncated".to_owned())? + as usize; + let data_start = local_offset + .checked_add(30) + .and_then(|value| value.checked_add(name_len)) + .and_then(|value| value.checked_add(extra_len)) + .ok_or_else(|| "zip local header offset overflowed".to_owned())?; + let data = bytes + .get(data_start..data_start + compressed_size) + .ok_or_else(|| "zip entry data is truncated".to_owned())?; + + match method { + METHOD_STORE => Ok(data.to_vec()), + METHOD_DEFLATE => Err( + "option package uses deflate compression, which is not wired up yet".to_owned(), + ), + other => Err(format!("unsupported zip compression method {other}")), + } +} + +fn read_u16(bytes: &[u8], offset: usize) -> Option { + let slice = bytes.get(offset..offset + 2)?; + Some(u16::from_le_bytes([slice[0], slice[1]])) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option { + let slice = bytes.get(offset..offset + 4)?; + Some(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]])) +} + +/// 测试用的最小 zip 构造器(store 模式),与 `crash_zip` 的写出逻辑同源 +#[cfg(test)] +pub fn build_store_zip(files: &[(&str, &[u8])]) -> Vec { + let mut buffer = Vec::new(); + let mut entries = Vec::new(); + for (name, data) in files { + let offset = buffer.len() as u32; + buffer.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + buffer.extend_from_slice(&20u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u32.to_le_bytes()); + buffer.extend_from_slice(&(data.len() as u32).to_le_bytes()); + buffer.extend_from_slice(&(data.len() as u32).to_le_bytes()); + buffer.extend_from_slice(&(name.len() as u16).to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(name.as_bytes()); + buffer.extend_from_slice(data); + entries.push((name.to_string(), data.len() as u32, offset)); + } + let central_start = buffer.len() as u32; + for (name, size, offset) in &entries { + buffer.extend_from_slice(&CENTRAL_SIGNATURE.to_le_bytes()); + buffer.extend_from_slice(&20u16.to_le_bytes()); + buffer.extend_from_slice(&20u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u32.to_le_bytes()); + buffer.extend_from_slice(&size.to_le_bytes()); + buffer.extend_from_slice(&size.to_le_bytes()); + buffer.extend_from_slice(&(name.len() as u16).to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u32.to_le_bytes()); + buffer.extend_from_slice(&offset.to_le_bytes()); + buffer.extend_from_slice(name.as_bytes()); + } + let central_size = buffer.len() as u32 - central_start; + buffer.extend_from_slice(&EOCD_SIGNATURE.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + buffer.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + buffer.extend_from_slice(¢ral_size.to_le_bytes()); + buffer.extend_from_slice(¢ral_start.to_le_bytes()); + buffer.extend_from_slice(&0u16.to_le_bytes()); + buffer +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Vec { + build_store_zip(&[ + ("A200/data.conf", b"[Version]\nName = CHUNITHM\n"), + ("A200/music/music9001/Music.xml", b""), + ("A200/music/music9001/9001_03.c2s", b"\x01\x02\x03"), + ("A200/releaseTag/releaseTag000099/ReleaseTag.xml", b""), + ]) + } + + #[test] + fn parses_root_and_files() { + // Given: 一个单根 Option 包。 + let package = OptionPackage::parse(&sample()).expect("应解析成功"); + + // Then: 根目录与文件内容正确。 + assert_eq!(package.root(), "A200"); + assert_eq!(package.file_count(), 4); + assert_eq!( + package.read_file("A200/data.conf"), + Some(&b"[Version]\nName = CHUNITHM\n"[..]) + ); + } + + #[test] + fn lists_directories_not_deep_files() { + // Given: 已解析的 Option 包。 + let package = OptionPackage::parse(&sample()).unwrap(); + + // When: 枚举包根与 `A200` 目录。 + let root = package.list_dir(""); + let inner = package.list_dir("A200"); + + // Then: 根下只有 AXXX 目录;其内只返回直接子项,目录被标记。 + assert_eq!(root, vec![("A200".to_owned(), true)]); + assert_eq!( + inner, + vec![ + ("data.conf".to_owned(), false), + ("music".to_owned(), true), + ("releaseTag".to_owned(), true), + ] + ); + } + + #[test] + fn rejects_packages_spanning_multiple_roots() { + // Given: 含两个 AXXX 根的包。 + let bytes = build_store_zip(&[("A200/a.txt", b"a"), ("A201/b.txt", b"b")]); + + // Then: 解析失败,避免挂载出无法归属的内容。 + assert!(OptionPackage::parse(&bytes).is_err()); + } + + #[test] + fn rejects_truncated_input() { + // Given: 被截断的 zip。 + let bytes = sample(); + let truncated = &bytes[..bytes.len() / 2]; + + // Then: 解析失败而不是 panic。 + assert!(OptionPackage::parse(truncated).is_err()); + assert!(OptionPackage::parse(b"").is_err()); + } +} diff --git a/packages/applechu/src/server_resources/vfs_bridge.rs b/packages/applechu/src/server_resources/vfs_bridge.rs new file mode 100644 index 0000000..039b6c8 --- /dev/null +++ b/packages/applechu/src/server_resources/vfs_bridge.rs @@ -0,0 +1,451 @@ +//! Option VFS 桥接 +//! +//! 把内存中的 Option 包呈现给游戏:枚举目录时注入虚拟 `AXXX` 根, +//! 读取文件时由内存内容服务。资源不落盘,磁盘上只保留缓存包本身。 +//! +//! ## 挂在哪一层 +//! +//! `platform/path_hook` 已经把游戏的 option 访问改写到一个可变根目录 +//! (默认 `<游戏目录>\option`),但只做路径改写、不注入条目。这里在**其之上** +//! 再包一层:保留 `path_hook` 的原始指针,自己优先处理虚拟内容,未命中时 +//! 转交 `path_hook`(按路径改写)再落到系统 API。 +//! +//! ## 关键点 +//! +//! - 目录枚举用「合成句柄」:只在 `path_hook` 的原始 `FindFirstFileW` 失败 +//! (目标目录不存在)时才创建,避免与真实句柄混淆;合成句柄高位标记, +//! 不会与内核句柄冲突。 +//! - 文件内容用**真实的 `\\.\NUL` 句柄**承载:`CreateFileW` 命中虚拟路径时 +//! 打开 NUL,并把真实句柄登记进 `handle` 表;`ReadFile` / `GetFileSize` / +//! `SetFilePointer` / `CloseHandle` 按句柄查表即可区分虚拟与真实文件。 +//! 即便某个真实 API 没被钩到,拿到 NUL 句柄也只是读到 0 字节,不会误读磁盘。 + +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, OnceLock}; + +// 这些导入在 Windows 上是真实插件依赖;合成句柄用高位掩码避开内核句柄空间。 +use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, WIN32_FIND_DATAW, +}; + +use crate::platform::{vfs, winapi}; +use crate::server_resources::handle; +use crate::util::api::Api; +use crate::util::iat_hook::hook_iat; + +const KERNEL32_DLL: &str = "kernel32.dll"; +/// `INVALID_FILE_ATTRIBUTES` +const INVALID_FILE_ATTRIBUTES: u32 = u32::MAX; +/// `GetFileSize` 失败返回值 +const INVALID_FILE_SIZE: u32 = u32::MAX; +/// 合成枚举句柄的高位标记(内核句柄不会落到该区间) +const SYNTHETIC_FLAG: usize = 1 << 31; +/// `SetFilePointer` 的 `FILE_BEGIN` +const FILE_BEGIN: u32 = 0; + +type FindFirstFileWFn = unsafe extern "system" fn(*const u16, *mut WIN32_FIND_DATAW) -> HANDLE; +type FindNextFileWFn = unsafe extern "system" fn(HANDLE, *mut WIN32_FIND_DATAW) -> i32; +type FindCloseFn = unsafe extern "system" fn(HANDLE) -> i32; +type CreateFileWFn = + unsafe extern "system" fn(*const u16, u32, u32, *const c_void, u32, u32, HANDLE) -> HANDLE; +type ReadFileFn = unsafe extern "system" fn(HANDLE, *mut c_void, u32, *mut u32, *mut c_void) -> i32; +type GetFileSizeFn = unsafe extern "system" fn(HANDLE, *mut u32) -> u32; +type GetFileSizeExFn = unsafe extern "system" fn(HANDLE, *mut i64) -> i32; +type SetFilePointerFn = unsafe extern "system" fn(HANDLE, i32, *mut i32, u32) -> u32; +type SetFilePointerExFn = unsafe extern "system" fn(HANDLE, i64, *mut i64, u32) -> i32; +type CloseHandleFn = unsafe extern "system" fn(HANDLE) -> i32; + +/// 我们包装后的 API 指针(未命中虚拟内容时转交) +static NEXT_FIND_FIRST_FILE_W: AtomicUsize = AtomicUsize::new(0); +static NEXT_FIND_NEXT_FILE_W: AtomicUsize = AtomicUsize::new(0); +static NEXT_FIND_CLOSE: AtomicUsize = AtomicUsize::new(0); +static NEXT_CREATE_FILE_W: AtomicUsize = AtomicUsize::new(0); +static NEXT_READ_FILE: AtomicUsize = AtomicUsize::new(0); +static NEXT_GET_FILE_SIZE: AtomicUsize = AtomicUsize::new(0); +static NEXT_GET_FILE_SIZE_EX: AtomicUsize = AtomicUsize::new(0); +static NEXT_SET_FILE_POINTER: AtomicUsize = AtomicUsize::new(0); +static NEXT_SET_FILE_POINTER_EX: AtomicUsize = AtomicUsize::new(0); +static NEXT_CLOSE_HANDLE: AtomicUsize = AtomicUsize::new(0); + +/// 合成枚举句柄 → 待吐出的条目(倒序弹出) +static SYNTHETIC: OnceLock>>> = OnceLock::new(); +static NEXT_SYNTHETIC: AtomicUsize = AtomicUsize::new(0); + +struct InjectedEntry { + name: String, + directory: bool, +} + +fn synthetic() -> &'static Mutex>> { + SYNTHETIC.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// 安装 VFS 桥接;须在 `platform` 阶段(`path_hook` 安装完成)之后调用 +pub fn install(api: &Api) -> Result<(), String> { + unsafe { + bind(api, "FindFirstFileW", hooked_find_first_file_w as *const (), &NEXT_FIND_FIRST_FILE_W)?; + bind(api, "FindNextFileW", hooked_find_next_file_w as *const (), &NEXT_FIND_NEXT_FILE_W)?; + bind(api, "FindClose", hooked_find_close as *const (), &NEXT_FIND_CLOSE)?; + bind(api, "CreateFileW", hooked_create_file_w as *const (), &NEXT_CREATE_FILE_W)?; + bind(api, "ReadFile", hooked_read_file as *const (), &NEXT_READ_FILE)?; + bind(api, "GetFileSize", hooked_get_file_size as *const (), &NEXT_GET_FILE_SIZE)?; + bind(api, "GetFileSizeEx", hooked_get_file_size_ex as *const (), &NEXT_GET_FILE_SIZE_EX)?; + bind(api, "SetFilePointer", hooked_set_file_pointer as *const (), &NEXT_SET_FILE_POINTER)?; + bind( + api, + "SetFilePointerEx", + hooked_set_file_pointer_ex as *const (), + &NEXT_SET_FILE_POINTER_EX, + )?; + bind(api, "CloseHandle", hooked_close_handle as *const (), &NEXT_CLOSE_HANDLE)?; + } + api.log_info("ServerResources: option VFS bridge installed"); + Ok(()) +} + +/// 把 detour 挂到目标进程内该导入的**当前**实现上,并保留它作为下一跳 +unsafe fn bind( + api: &Api, + name: &str, + detour: *const (), + slot: &AtomicUsize, +) -> Result<(), String> { + // 只挂游戏主模块:避免与 path_hook 覆盖的其他模块重复包装 + let previous = hook_iat(api.game_base(), KERNEL32_DLL, name, detour); + let Some(previous) = previous else { + return Err(format!("{name} import not found in game module")); + }; + slot.store(previous as usize, Ordering::SeqCst); + Ok(()) +} + +/// 卸载:还原导入(把下一跳写回) +pub fn shutdown() { + handle::close_all(); + if let Ok(mut synthetic) = synthetic().lock() { + synthetic.clear(); + } + unsafe { + restore("FindFirstFileW", &NEXT_FIND_FIRST_FILE_W); + restore("FindNextFileW", &NEXT_FIND_NEXT_FILE_W); + restore("FindClose", &NEXT_FIND_CLOSE); + restore("CreateFileW", &NEXT_CREATE_FILE_W); + restore("ReadFile", &NEXT_READ_FILE); + restore("GetFileSize", &NEXT_GET_FILE_SIZE); + restore("GetFileSizeEx", &NEXT_GET_FILE_SIZE_EX); + restore("SetFilePointer", &NEXT_SET_FILE_POINTER); + restore("SetFilePointerEx", &NEXT_SET_FILE_POINTER_EX); + restore("CloseHandle", &NEXT_CLOSE_HANDLE); + } +} + +unsafe fn restore(name: &str, slot: &AtomicUsize) { + let previous = slot.swap(0, Ordering::AcqRel); + if previous == 0 { + return; + } + if let Some(api) = crate::util::api::API.get() { + let _ = hook_iat( + api.game_base(), + KERNEL32_DLL, + name, + previous as *const (), + ); + } +} + +/// 命中 option 根通配时返回包内根目录名 +/// +/// 游戏请求的是重定向前的源路径 `c:\mount\option\*`,因此用源前缀比对。 +fn option_root_glob(path: &str) -> Option { + let source = vfs::option_source_prefix(); + let normalized = winapi::normalize_path(path); + let directory = normalized.trim_end_matches('\\').trim_end_matches("*"); + if directory.trim_end_matches('\\') != source.trim_end_matches('\\') { + return None; + } + crate::server_resources::package().map(|package| package.root().to_owned()) +} + +/// 路径是否指向虚拟包内文件,返回包内 key +fn package_key(path: &str) -> Option { + let package = crate::server_resources::package()?; + let source = vfs::option_source_prefix(); + let normalized = winapi::normalize_path(path); + let prefix = format!("{}\\", source.trim_end_matches('\\')); + let relative = normalized.strip_prefix(&prefix)?; + let key = relative.replace('\\', "/"); + package.contains(&key).then_some(key) +} + +/// 把名称写入 `WIN32_FIND_DATAW` +unsafe fn fill_entry(name: &str, directory: bool, data: *mut WIN32_FIND_DATAW) { + if data.is_null() { + return; + } + std::ptr::write_bytes(data, 0, 1); + (*data).dwFileAttributes = if directory { + FILE_ATTRIBUTE_DIRECTORY + } else { + FILE_ATTRIBUTE_NORMAL + }; + let wide = winapi::string_to_wide(name); + let units = wide.len().min(259); + std::ptr::copy_nonoverlapping(wide.as_ptr(), (*data).cFileName.as_mut_ptr(), units); +} + +fn next_synthetic_handle() -> usize { + let token = NEXT_SYNTHETIC.fetch_add(1, Ordering::Relaxed); + (token | SYNTHETIC_FLAG) as usize +} + +unsafe extern "system" fn hooked_find_first_file_w( + path: *const u16, + data: *mut WIN32_FIND_DATAW, +) -> HANDLE { + let next = NEXT_FIND_FIRST_FILE_W.load(Ordering::SeqCst); + if next == 0 { + return INVALID_HANDLE_VALUE; + } + let next: FindFirstFileWFn = std::mem::transmute(next); + + if let Some(path_text) = winapi::wide_to_string(path) { + if let Some(root) = option_root_glob(&path_text) { + // 先让真实枚举跑一遍:目录存在时交给它(本功能只做增量挂载) + let real = next(path, data); + if real != INVALID_HANDLE_VALUE { + return real; + } + // 目录不存在:用合成句柄承载注入条目 + let handle_value = next_synthetic_handle(); + if let Ok(mut synthetic) = synthetic().lock() { + synthetic.insert(handle_value, Vec::new()); + } + fill_entry(&root, true, data); + return handle_value as HANDLE; + } + } + next(path, data) +} + +unsafe extern "system" fn hooked_find_next_file_w( + handle: HANDLE, + data: *mut WIN32_FIND_DATAW, +) -> i32 { + if (handle as usize) & SYNTHETIC_FLAG != 0 { + let Ok(mut synthetic) = synthetic().lock() else { + return 0; + }; + if let Some(entries) = synthetic.get_mut(&(handle as usize)) { + if let Some(entry) = entries.pop() { + fill_entry(&entry.name, entry.directory, data); + return 1; + } + } + return 0; + } + let next = NEXT_FIND_NEXT_FILE_W.load(Ordering::SeqCst); + if next == 0 { + return 0; + } + let next: FindNextFileWFn = std::mem::transmute(next); + next(handle, data) +} + +unsafe extern "system" fn hooked_find_close(handle: HANDLE) -> i32 { + if (handle as usize) & SYNTHETIC_FLAG != 0 { + if let Ok(mut synthetic) = synthetic().lock() { + synthetic.remove(&(handle as usize)); + } + return 1; + } + let next = NEXT_FIND_CLOSE.load(Ordering::SeqCst); + if next == 0 { + return 0; + } + let next: FindCloseFn = std::mem::transmute(next); + next(handle) +} + +unsafe extern "system" fn hooked_create_file_w( + path: *const u16, + access: u32, + share: u32, + security: *const c_void, + creation: u32, + flags: u32, + template: HANDLE, +) -> HANDLE { + let next = NEXT_CREATE_FILE_W.load(Ordering::SeqCst); + if next == 0 { + return INVALID_HANDLE_VALUE; + } + let next: CreateFileWFn = std::mem::transmute(next); + + if let Some(path_text) = winapi::wide_to_string(path) { + if let Some(key) = package_key(&path_text) { + let length = crate::server_resources::package() + .and_then(|package| package.read_file(&key).map(<[u8]>::len)) + .unwrap_or(0); + // \\.\NUL 必然可打开,且 CloseHandle 语义正常 + let nul = winapi::string_to_wide("\\\\.\\NUL"); + let real = next(nul.as_ptr(), access, share, security, creation, flags, template); + if real != INVALID_HANDLE_VALUE && handle::register(real as usize, &key, length) { + return real; + } + } + } + next(path, access, share, security, creation, flags, template) +} + +unsafe extern "system" fn hooked_read_file( + file: HANDLE, + buffer: *mut c_void, + to_read: u32, + read: *mut u32, + overlapped: *mut c_void, +) -> i32 { + if let Some((key, offset, count)) = handle::read(file as usize, to_read as usize) { + let mut transferred = 0u32; + if let Some(package) = crate::server_resources::package() { + if let Some(data) = package.read_file(&key) { + let start = offset.min(data.len()); + let available = (data.len() - start).min(count); + if available > 0 && !buffer.is_null() { + std::ptr::copy_nonoverlapping( + data[start..].as_ptr(), + buffer as *mut u8, + available, + ); + } + transferred = available as u32; + } + } + if !read.is_null() { + *read = transferred; + } + return 1; + } + let next = NEXT_READ_FILE.load(Ordering::SeqCst); + if next == 0 { + return 0; + } + let next: ReadFileFn = std::mem::transmute(next); + next(file, buffer, to_read, read, overlapped) +} + +unsafe extern "system" fn hooked_get_file_size(file: HANDLE, high: *mut u32) -> u32 { + if let Some((length, _)) = handle::size_and_position(file as usize) { + if !high.is_null() { + *high = (length >> 32) as u32; + } + return (length & 0xFFFF_FFFF) as u32; + } + let next = NEXT_GET_FILE_SIZE.load(Ordering::SeqCst); + if next == 0 { + return INVALID_FILE_SIZE; + } + let next: GetFileSizeFn = std::mem::transmute(next); + next(file, high) +} + +unsafe extern "system" fn hooked_get_file_size_ex(file: HANDLE, size: *mut i64) -> i32 { + if let Some((length, _)) = handle::size_and_position(file as usize) { + if !size.is_null() { + *size = length as i64; + } + return 1; + } + let next = NEXT_GET_FILE_SIZE_EX.load(Ordering::SeqCst); + if next == 0 { + return 0; + } + let next: GetFileSizeExFn = std::mem::transmute(next); + next(file, size) +} + +unsafe extern "system" fn hooked_set_file_pointer( + file: HANDLE, + distance: i32, + high: *mut i32, + method: u32, +) -> u32 { + if let Some((_, position)) = handle::size_and_position(file as usize) { + let base = if method == FILE_BEGIN { 0isize } else { position as isize }; + let target = base.saturating_add(distance as isize).max(0) as usize; + if handle::seek(file as usize, target).is_some() { + if !high.is_null() { + *high = (target >> 32) as i32; + } + return (target & 0xFFFF_FFFF) as u32; + } + } + let next = NEXT_SET_FILE_POINTER.load(Ordering::SeqCst); + if next == 0 { + return INVALID_FILE_SIZE; + } + let next: SetFilePointerFn = std::mem::transmute(next); + next(file, distance, high, method) +} + +unsafe extern "system" fn hooked_set_file_pointer_ex( + file: HANDLE, + distance: i64, + new_position: *mut i64, + method: u32, +) -> i32 { + if let Some((_, position)) = handle::size_and_position(file as usize) { + let base = if method == FILE_BEGIN { 0 } else { position as i64 }; + let target = base.saturating_add(distance).max(0) as usize; + if handle::seek(file as usize, target).is_some() { + if !new_position.is_null() { + *new_position = target as i64; + } + return 1; + } + } + let next = NEXT_SET_FILE_POINTER_EX.load(Ordering::SeqCst); + if next == 0 { + return 0; + } + let next: SetFilePointerExFn = std::mem::transmute(next); + next(file, distance, new_position, method) +} + +unsafe extern "system" fn hooked_close_handle(object: HANDLE) -> i32 { + handle::unregister(object as usize); + let next = NEXT_CLOSE_HANDLE.load(Ordering::SeqCst); + if next == 0 { + return 0; + } + let next: CloseHandleFn = std::mem::transmute(next); + next(object) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn synthetic_handles_are_flagged_and_unique() { + // Given: 连续分配两个合成枚举句柄。 + let first = next_synthetic_handle(); + let second = next_synthetic_handle(); + + // Then: 都带高位标记且互不相同,不会与内核句柄混淆。 + assert_ne!(first, second); + assert_ne!(first & SYNTHETIC_FLAG, 0); + assert_ne!(second & SYNTHETIC_FLAG, 0); + } + + #[test] + fn no_option_root_means_no_injection() { + // Given: VFS 尚未初始化。 + // Then: 所有查询都不命中,调用方完全回落真实 API。 + assert!(option_root_glob("C:\\x\\*").is_none()); + assert!(package_key("C:\\x\\A200\\data.conf").is_none()); + } +} diff --git a/packages/applechu/src/util/crypto.rs b/packages/applechu/src/util/crypto.rs new file mode 100644 index 0000000..10a4e75 --- /dev/null +++ b/packages/applechu/src/util/crypto.rs @@ -0,0 +1,165 @@ +//! RSA 验签与 base64 解码 +//! +//! 服务器下发的资源包由 MuNET 私钥签名,客户端用内置公钥验签。 +//! 签名算法与 AquaMai 一致:RSA PKCS#1 v1.5 + SHA-256。 +//! 公钥以 base64 编码的 modulus 形式给出,指数固定 65537。 + +use std::ffi::c_void; +use std::ptr; + +use windows_sys::Win32::Security::Cryptography::{ + BCryptCloseAlgorithmProvider, BCryptDestroyKey, BCryptImportKeyPair, BCryptOpenAlgorithmProvider, + BCryptVerifySignature, BCRYPT_ALG_HANDLE, BCRYPT_KEY_HANDLE, BCRYPT_PAD_PKCS1, + BCRYPT_PKCS1_PADDING_INFO, BCRYPT_RSA_ALGORITHM, BCRYPT_RSAPUBLIC_BLOB, + BCRYPT_SHA256_ALGORITHM, +}; + +use crate::util::hash::sha256; + +/// `BCRYPT_RSAKEY_BLOB` 的公钥魔数("RSA1") +const BCRYPT_RSAPUBLIC_MAGIC: u32 = 0x3141_5352; +/// 固定公钥指数 65537(大端字节序) +const PUBLIC_EXPONENT: [u8; 3] = [0x01, 0x00, 0x01]; + +/// 解码标准 base64(忽略空白,要求正确的 `=` 填充) +pub fn base64_decode(input: &str) -> Option> { + let mut output = Vec::with_capacity(input.len() / 4 * 3); + let mut accumulator: u32 = 0; + let mut bits = 0u32; + + for byte in input.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => break, + b' ' | b'\t' | b'\r' | b'\n' => continue, + _ => return None, + }; + accumulator = (accumulator << 6) | u32::from(value); + bits += 6; + if bits >= 8 { + bits -= 8; + output.push((accumulator >> bits) as u8); + } + } + + Some(output) +} + +/// 用内置公钥验证 `data` 上的 `signature`(base64) +/// +/// `modulus_base64` 是 RSA 公钥的 base64 modulus;解析或验证失败均返回 `false`。 +pub fn verify_signature(modulus_base64: &str, data: &[u8], signature_base64: &str) -> bool { + let Some(modulus) = base64_decode(modulus_base64) else { + return false; + }; + let Some(signature) = base64_decode(signature_base64) else { + return false; + }; + let Some(digest) = sha256(data) else { + return false; + }; + verify_rsa_sha256(&modulus, &signature, &digest) +} + +/// 组装 `BCRYPT_RSAPUBLIC_BLOB` 并调用 BCrypt 验签 +fn verify_rsa_sha256(modulus: &[u8], signature: &[u8], digest: &[u8; 32]) -> bool { + if modulus.is_empty() || signature.is_empty() { + return false; + } + let Ok(modulus_len) = u32::try_from(modulus.len()) else { + return false; + }; + + // BCRYPT_RSAKEY_BLOB 头部 + 公钥指数 + modulus + let mut blob = Vec::with_capacity(24 + PUBLIC_EXPONENT.len() + modulus.len()); + blob.extend_from_slice(&BCRYPT_RSAPUBLIC_MAGIC.to_le_bytes()); + blob.extend_from_slice(&(modulus.len() as u32 * 8).to_le_bytes()); // BitLength + blob.extend_from_slice(&(PUBLIC_EXPONENT.len() as u32).to_le_bytes()); // cbPublicExp + blob.extend_from_slice(&modulus_len.to_le_bytes()); // cbModulus + blob.extend_from_slice(&0u32.to_le_bytes()); // cbPrime1 + blob.extend_from_slice(&0u32.to_le_bytes()); // cbPrime2 + blob.extend_from_slice(&PUBLIC_EXPONENT); + blob.extend_from_slice(modulus); + + unsafe { + let mut algorithm: BCRYPT_ALG_HANDLE = ptr::null_mut(); + if BCryptOpenAlgorithmProvider(&mut algorithm, BCRYPT_RSA_ALGORITHM, ptr::null(), 0) != 0 { + return false; + } + + let mut key: BCRYPT_KEY_HANDLE = ptr::null_mut(); + let imported = BCryptImportKeyPair( + algorithm, + ptr::null_mut(), + BCRYPT_RSAPUBLIC_BLOB, + &mut key, + blob.as_ptr(), + blob.len() as u32, + 0, + ) == 0; + + let verified = if imported { + let padding = BCRYPT_PKCS1_PADDING_INFO { + pszAlgId: BCRYPT_SHA256_ALGORITHM, + }; + BCryptVerifySignature( + key, + (&padding as *const BCRYPT_PKCS1_PADDING_INFO).cast::(), + digest.as_ptr(), + digest.len() as u32, + signature.as_ptr(), + signature.len() as u32, + BCRYPT_PAD_PKCS1, + ) == 0 + } else { + false + }; + + if !key.is_null() { + BCryptDestroyKey(key); + } + BCryptCloseAlgorithmProvider(algorithm, 0); + verified + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_round_trips_known_vectors() { + // Given: RFC 4648 的标准测试向量。 + let cases = [ + ("", ""), + ("Zg==", "f"), + ("Zm8=", "fo"), + ("Zm9v", "foo"), + ("Zm9vYmFy", "foobar"), + ("+/8=", "\u{ff}\u{fe}"), + ]; + + // When/Then: 解码结果与原文一致。 + for (encoded, plain) in cases { + assert_eq!(base64_decode(encoded).unwrap(), plain.as_bytes()); + } + } + + #[test] + fn base64_rejects_invalid_alphabet() { + // Given: 含非法字符的输入。 + // Then: 解码失败而不是静默给出错误字节。 + assert!(base64_decode("Zm9v*").is_none()); + } + + #[test] + fn empty_modulus_never_verifies() { + // Given: 未配置公钥(空 modulus)。 + // Then: 任何签名都不通过,避免"无公钥即放行"。 + assert!(!verify_signature("", b"data", "AAAA")); + } +} diff --git a/packages/applechu/src/util/hash.rs b/packages/applechu/src/util/hash.rs index 64d01aa..a18c16a 100644 --- a/packages/applechu/src/util/hash.rs +++ b/packages/applechu/src/util/hash.rs @@ -12,7 +12,8 @@ pub fn sha256_file(path: impl AsRef) -> Option { Some(digest.iter().map(|byte| format!("{byte:02X}")).collect()) } -fn sha256(data: &[u8]) -> Option<[u8; 32]> { +/// 计算任意字节序列的 SHA-256(用于资源包验签与完整性比对) +pub fn sha256(data: &[u8]) -> Option<[u8; 32]> { unsafe { let mut algorithm: BCRYPT_ALG_HANDLE = std::ptr::null_mut(); if BCryptOpenAlgorithmProvider(&mut algorithm, BCRYPT_SHA256_ALGORITHM, std::ptr::null(), 0) diff --git a/packages/applechu/src/util/mod.rs b/packages/applechu/src/util/mod.rs index 4be9af1..6c61f3f 100644 --- a/packages/applechu/src/util/mod.rs +++ b/packages/applechu/src/util/mod.rs @@ -1,4 +1,5 @@ pub mod api; +pub mod crypto; pub mod hash; pub mod iat_hook; pub mod logging; From 9b51e2982534642746312c9b1d3c5083095d90b0 Mon Sep 17 00:00:00 2001 From: Applesaber Date: Sun, 11 Oct 2026 22:16:04 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=A7=E5=B0=8F/=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E7=9A=84=E7=A7=BB=E4=BD=8D=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 32 位目标上 `usize >> 32` 会触发 deny(arithmetic_overflow), 先转 u64 再取高低位。 --- packages/applechu/src/server_resources/vfs_bridge.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/applechu/src/server_resources/vfs_bridge.rs b/packages/applechu/src/server_resources/vfs_bridge.rs index 039b6c8..e96dd4e 100644 --- a/packages/applechu/src/server_resources/vfs_bridge.rs +++ b/packages/applechu/src/server_resources/vfs_bridge.rs @@ -340,9 +340,9 @@ unsafe extern "system" fn hooked_read_file( unsafe extern "system" fn hooked_get_file_size(file: HANDLE, high: *mut u32) -> u32 { if let Some((length, _)) = handle::size_and_position(file as usize) { if !high.is_null() { - *high = (length >> 32) as u32; + *high = ((length as u64) >> 32) as u32; } - return (length & 0xFFFF_FFFF) as u32; + return (length as u64 & 0xFFFF_FFFF) as u32; } let next = NEXT_GET_FILE_SIZE.load(Ordering::SeqCst); if next == 0 { @@ -378,9 +378,9 @@ unsafe extern "system" fn hooked_set_file_pointer( let target = base.saturating_add(distance as isize).max(0) as usize; if handle::seek(file as usize, target).is_some() { if !high.is_null() { - *high = (target >> 32) as i32; + *high = ((target as u64) >> 32) as i32; } - return (target & 0xFFFF_FFFF) as u32; + return (target as u64 & 0xFFFF_FFFF) as u32; } } let next = NEXT_SET_FILE_POINTER.load(Ordering::SeqCst); From 2ec7636a6b21b60b9dc8dc1db48883fd806b79a4 Mon Sep 17 00:00:00 2001 From: Applesaber Date: Sun, 11 Oct 2026 22:18:02 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E7=BD=91=E7=BB=9C=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=B9=BF=E6=92=AD=E7=BB=99=20chu-abi=20=E6=A8=A1?= =?UTF-8?q?=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取包桥在内部订阅者处理完后,把 `{"api":..,"response":..}` 广播到 `net_packet` 主题,模组可据此读取或协作处理响应,对应需求文档 「模组和服务器通讯」一节。 --- .../src/server_resources/net_packet.rs | 33 +++++++++++++++++++ packages/applechu/src/util/api.rs | 17 ++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/applechu/src/server_resources/net_packet.rs b/packages/applechu/src/server_resources/net_packet.rs index 5d29b90..a73a2c7 100644 --- a/packages/applechu/src/server_resources/net_packet.rs +++ b/packages/applechu/src/server_resources/net_packet.rs @@ -156,6 +156,19 @@ fn dispatch(path: &str, body: &[u8]) { } } } + + // 同时广播给 chu-abi 模组,使其能读取/协作处理该响应。 + // 载荷为 `{"api":"<名>","response":<原始响应>}`,便于订阅方按 API 过滤。 + if let Some(api) = crate::util::api::API.get() { + let mut envelope = String::with_capacity(value.to_string().len() + 32); + envelope.push_str("{\"api\":"); + write_json_string(&name, &mut envelope); + envelope.push_str(",\"response\":"); + envelope.push_str(&value.to_string()); + envelope.push('}'); + api.publish(PUBLISH_TOPIC, envelope.as_bytes()); + } + // 改写结果记录到日志;回写由调用方(游戏自身缓冲)决定, // 本桥只读响应,不修改游戏的内存流。 if modified { @@ -166,6 +179,26 @@ fn dispatch(path: &str, body: &[u8]) { } } +/// 对外广播主题名(chu-abi 模组按此订阅) +const PUBLISH_TOPIC: &str = "net_packet"; + +/// 在本模块内构造转义后的 JSON 字符串(避免把 json 模块的私有工具外泄) +fn write_json_string(value: &str, output: &mut String) { + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + control if (control as u32) < 0x20 => { + use std::fmt::Write as _; + let _ = write!(output, "\\u{:04x}", control as u32); + } + other => output.push(other), + } + } + output.push('"'); +} + /// 从请求路径解析逻辑 API 名(末段,去掉查询串与扩展) fn api_name_from_path(path: &str) -> Option { let without_query = path.split(['?', '#']).next().unwrap_or(path); diff --git a/packages/applechu/src/util/api.rs b/packages/applechu/src/util/api.rs index 1f3d91e..e6a8eb0 100644 --- a/packages/applechu/src/util/api.rs +++ b/packages/applechu/src/util/api.rs @@ -262,6 +262,23 @@ impl Api { .is_some_and(|func| unsafe { func(target as *mut c_void) == 0 }) } + /// 向订阅了 `topic` 的模组广播一段字节 + /// + /// 把游戏侧事件(如网络响应)暴露给 chu-abi 模组;无订阅者时为空操作。 + pub fn publish(&self, topic: &str, data: &[u8]) -> bool { + let Ok(topic) = CString::new(topic) else { + return false; + }; + let Ok(size) = u32::try_from(data.len()) else { + return false; + }; + self.raw() + .and_then(|api| api.publish) + .is_some_and(|func| unsafe { + func(topic.as_ptr(), data.as_ptr().cast_mut().cast(), size) == 0 + }) + } + pub fn register_present_callback(&self, cb: chu_abi::ChuModPresentCallback) -> bool { self.raw() .and_then(|api| api.register_present_callback) From e6e760df447367e5c0c6cbd1f3c4057085a68262 Mon Sep 17 00:00:00 2001 From: Applesaber Date: Sun, 11 Oct 2026 23:11:58 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E5=8E=9F=E7=94=9F=E5=88=86?= =?UTF-8?q?=E5=8F=91=E5=99=A8=E6=94=B6=E5=8C=85=E3=80=81=E5=BC=80=E6=9C=BA?= =?UTF-8?q?=E9=97=B8=E9=97=A8=E4=B8=8E=20deflate=20=E8=A7=A3=E5=8E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dispatcher: 挂钩 sub_99FA60(特征码定位),直接从原生收包分发器拿到 逻辑 API 名与明文响应,取代从 URL 猜名;WinHTTP 层保留为兜底, 由 [ServerResources].native_dispatcher 切换 - power_on_gate: 挂钩 projGame::PowerOn 状态机每帧更新(虚表 0x19249BC 第 0 槽),下载期间停在当前帧,结束或超时(60s)放行 - deflate: 复用游戏内嵌 zlib 1.2.8,经子 9AB850 解压;zip 的 deflate 条目不再报错,改为真正解压 - 新增配置项 native_dispatcher / hold_boot --- .../applechu/src/server_resources/deflate.rs | 174 ++++++++++++++++ .../src/server_resources/dispatcher.rs | 117 +++++++++++ packages/applechu/src/server_resources/mod.rs | 58 +++++- .../src/server_resources/net_packet.rs | 29 ++- .../applechu/src/server_resources/package.rs | 4 +- .../src/server_resources/power_on_gate.rs | 193 ++++++++++++++++++ 6 files changed, 556 insertions(+), 19 deletions(-) create mode 100644 packages/applechu/src/server_resources/deflate.rs create mode 100644 packages/applechu/src/server_resources/dispatcher.rs create mode 100644 packages/applechu/src/server_resources/power_on_gate.rs diff --git a/packages/applechu/src/server_resources/deflate.rs b/packages/applechu/src/server_resources/deflate.rs new file mode 100644 index 0000000..c3c0890 --- /dev/null +++ b/packages/applechu/src/server_resources/deflate.rs @@ -0,0 +1,174 @@ +//! 解压:调用游戏自带的 zlib(`gm::Deflate` / `projClient::Deflate`) +//! +//! 资源包若使用 deflate 压缩,直接复用游戏内嵌的 zlib 1.2.8,不自写 inflate。 +//! 挂钩点选用游戏自己的高层封装 `sub_9AB850`(RVA `0x5AB850`):它按 `__thiscall` 接收 +//! `(this, 输入 std::string*, 输出 std::string*)`,内部完成初始化、inflate +//! 与结果赋值,因此不需要我们手工维护 `z_stream`。 +//! +//! `this` 需要一个带虚表的对象(布局 `[vtable][z_stream]`,共 0x3C 字节), +//! 虚表指针取自 IDA 里的 `off_18EFDBC`。 + +use crate::util::pattern; + +/// `gm::Deflate` 的虚表(IDA: `off_18EFDBC`) +const DEFLATE_VTABLE_RVA: usize = 0x14E_FDBC; +/// 高层解压封装的函数头特征(`sub_9AB850`) +const INFLATE_SIGNATURE: &str = concat!( + "55 8D AC 24 E4 DF FC FF B8 1C 20 03 00 E8 ?? ?? ?? ?? ", + "6A FF 68 ?? ?? ?? ?? 64 A1 00 00 00 00 50" +); + +/// 对象大小:`[vtable]` + `z_stream`(0x38) +const DEFLATE_OBJECT_SIZE: usize = 0x3C; +/// MSVC `std::string` 对象大小 +const STD_STRING_SIZE: usize = 0x18; +/// `_Myres` 达到该值表示数据在堆上 +const SSO_CAPACITY: u32 = 16; + +type InflateFn = unsafe extern "thiscall" fn(*mut u8, *mut u8, *mut u8) -> i32; + +/// 用游戏自带的 zlib 解压 `input` +pub fn inflate(input: &[u8]) -> Result, String> { + let Some(api) = crate::util::api::API.get() else { + return Err("api handle is not available".to_owned()); + }; + let base = api.game_base(); + if base == 0 { + return Err("game base is not available".to_owned()); + } + + let entry = pattern::scan(api, INFLATE_SIGNATURE); + if entry == 0 { + return Err("deflate entry signature not found".to_owned()); + } + let vtable = base + .checked_add(DEFLATE_VTABLE_RVA) + .ok_or_else(|| "deflate vtable address overflowed".to_owned())?; + + // 对象与两个 std::string 都放在本线程栈上;z_stream 由被调函数负责清零 + let mut object = [0u8; DEFLATE_OBJECT_SIZE]; + object[..4].copy_from_slice(&(vtable as u32).to_le_bytes()); + + let mut input_buffer = [0u8; STD_STRING_SIZE]; + let mut output_buffer = [0u8; STD_STRING_SIZE]; + unsafe { + std::ptr::write_bytes(output_buffer.as_mut_ptr(), 0, STD_STRING_SIZE); + layout_string(&mut input_buffer, input)?; + // 空输出的 `_Myres` 置 0,避免被调函数在赋值前尝试释放栈内存 + output_buffer[0x14..0x18].copy_from_slice(&0u32.to_le_bytes()); + } + + let inflate: InflateFn = unsafe { std::mem::transmute(entry) }; + let status = unsafe { + inflate( + object.as_mut_ptr(), + input_buffer.as_mut_ptr(), + output_buffer.as_mut_ptr(), + ) + }; + + // 读出解压结果 + let result = unsafe { read_string(&output_buffer) }.ok_or_else(|| { + format!("deflate returned {status} without a usable output buffer") + })?; + + // 输出若落在堆上,用游戏的释放函数回收,避免每次解压泄漏 + unsafe { + let capacity = u32::from_le_bytes(output_buffer[0x14..0x18].try_into().unwrap()); + if capacity >= SSO_CAPACITY { + let pointer = u32::from_le_bytes(output_buffer[0..4].try_into().unwrap()) as usize; + deallocate(pointer, capacity as usize + 1); + } + } + + if result.is_empty() && status != 1 { + return Err(format!("deflate failed with status {status}")); + } + Ok(result) +} + +/// 在调用方缓冲区内布置一个指向 `data` 的 MSVC `std::string` +/// +/// 使用「堆视图」布局(`_Myres >= 16`),让被调函数直接读 `_Bx._Ptr`; +/// 这里只借用 `data` 的指针,不负责其生命周期。 +unsafe fn layout_string(buffer: &mut [u8; STD_STRING_SIZE], data: &[u8]) -> Result<(), String> { + let length = u32::try_from(data.len()).map_err(|_| "input is too large".to_owned())?; + buffer[0..4].copy_from_slice(&(data.as_ptr() as u32).to_le_bytes()); + buffer[0x10..0x14].copy_from_slice(&length.to_le_bytes()); + // 置为 SSO 上限以上的值,确保走指针路径且不被误判为可释放 + buffer[0x14..0x18].copy_from_slice(&u32::MAX.to_le_bytes()); + Ok(()) +} + +/// 读取 MSVC `std::string` 内容 +unsafe fn read_string(buffer: &[u8; STD_STRING_SIZE]) -> Option> { + let length = u32::from_le_bytes(buffer[0x10..0x14].try_into().ok()?) as usize; + if length == 0 { + return Some(Vec::new()); + } + if length > 1 << 26 { + return None; + } + let capacity = u32::from_le_bytes(buffer[0x14..0x18].try_into().ok()?); + let data = if capacity >= SSO_CAPACITY { + u32::from_le_bytes(buffer[0..4].try_into().ok()?) as usize as *const u8 + } else { + buffer.as_ptr() + }; + if data.is_null() { + return None; + } + Some(std::slice::from_raw_parts(data, length).to_vec()) +} + +/// 释放游戏内嵌 zlib 分配的字符串缓冲(`std::string::_Deallocate`,`sub_448A31`) +unsafe fn deallocate(pointer: usize, size: usize) { + if pointer == 0 { + return; + } + let Some(api) = crate::util::api::API.get() else { + return; + }; + let base = api.game_base(); + if base == 0 { + return; + } + let Some(free) = base.checked_add(0x48A31) else { + return; + }; + let free: unsafe extern "cdecl" fn(usize, usize, usize) = std::mem::transmute(free); + free(pointer, size, 1); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layouts_heap_backed_string_view() { + // Given: 一段输入数据。 + let data = b"hello"; + let mut buffer = [0u8; STD_STRING_SIZE]; + + // When: 布置成 std::string 视图。 + unsafe { layout_string(&mut buffer, data).unwrap() }; + + // Then: 长度与指针写入正确,容量足以让被调函数走指针路径。 + assert_eq!(u32::from_le_bytes(buffer[0x10..0x14].try_into().unwrap()), 5); + assert!(u32::from_le_bytes(buffer[0x14..0x18].try_into().unwrap()) >= SSO_CAPACITY); + assert_eq!( + u32::from_le_bytes(buffer[0..4].try_into().unwrap()) as usize, + data.as_ptr() as usize + ); + } + + #[test] + fn reads_inline_and_heap_strings() { + // Given: 一个内联存储的空字符串。 + let mut inline = [0u8; STD_STRING_SIZE]; + inline[0x14..0x18].copy_from_slice(&15u32.to_le_bytes()); + + // When/Then: 空长度读出空内容。 + assert_eq!(unsafe { read_string(&inline) }, Some(Vec::new())); + } +} diff --git a/packages/applechu/src/server_resources/dispatcher.rs b/packages/applechu/src/server_resources/dispatcher.rs new file mode 100644 index 0000000..4ad5a64 --- /dev/null +++ b/packages/applechu/src/server_resources/dispatcher.rs @@ -0,0 +1,117 @@ +//! 原生 API 分发器钩子 +//! +//! 对应 AquaMai 的 `Packet::ProcImpl`。游戏在 `projClient` 的收包状态机里 +//! 先解密再分发:`sub_9A0AB0` 调 `sub_42FD83`(AES 解密)得到明文响应, +//! 随即调 `sub_99FA60`(本模块挂钩的分发器)把 `(逻辑 API 名, 明文响应)` +//! 交给具体 handler。 +//! +//! 挂在这一层相比 WinHTTP 的优势: +//! - 拿到的是**已解密的明文**,不需要自己调用 `gm::Aes`; +//! - 拿到的是**逻辑 API 名**(如 `GetGameSettingApi`),不必从 URL 猜。 +//! +//! ## 签名(2.50 实测,唯一匹配) +//! +//! `sub_99FA60` 是 `__thiscall`:`(this, apiName*, response*, out*)`, +//! 前两个字符串参数是 MSVC `std::string`(`_Bx`@0,`_Mysize`@0x10, +//! `_Myres`@0x14;`_Myres >= 16` 时数据在 `_Bx._Ptr`,否则在对象内)。 + +use crate::util::api::Api; +use crate::util::pattern; + +/// `sub_99FA60` 的函数头特征(本二进制唯一;栈 cookie 与 SEH 立刻数按 `??` 处理) +const DISPATCHER_SIGNATURE: &str = concat!( + "55 8B EC 6A FF 68 ?? ?? ?? ?? 64 A1 00 00 00 00 50 ", + "83 EC 08 53 56 57 A1 ?? ?? ?? ?? 33 C5 50 8D 45 F4 ", + "64 A3 00 00 00 00 8B F9 8B 45 10 B9" +); + +/// MSVC `std::string::_Myres` 达到该值表示数据在堆上 +const SSO_CAPACITY: u32 = 16; + +type DispatcherFn = + unsafe extern "thiscall" fn(*mut u8, *mut u8, *mut u8, *mut u8) -> i32; + +static ORIGINAL: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static TARGET: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// 安装分发器钩子,返回是否成功 +pub fn install(api: &Api) -> Result<(), String> { + let target = pattern::scan(api, DISPATCHER_SIGNATURE); + if target == 0 { + return Err("client dispatcher signature not found".to_owned()); + } + let Some(trampoline) = api.hook_create(target, hooked_dispatcher as *const () as usize) else { + return Err("client dispatcher hook could not be created".to_owned()); + }; + if !api.hook_enable(target) { + api.hook_remove(target); + return Err("client dispatcher hook could not be enabled".to_owned()); + } + ORIGINAL.store(trampoline, std::sync::atomic::Ordering::Release); + TARGET.store(target, std::sync::atomic::Ordering::Release); + api.log_info(&format!( + "ServerResources: client dispatcher hooked at 0x{target:08X}" + )); + Ok(()) +} + +pub fn shutdown() { + let target = TARGET.swap(0, std::sync::atomic::Ordering::AcqRel); + if target != 0 { + if let Some(api) = crate::util::api::API.get() { + api.hook_disable(target); + api.hook_remove(target); + } + } + ORIGINAL.store(0, std::sync::atomic::Ordering::Release); +} + +/// 读取 MSVC `std::string` 的内容 +/// +/// # Safety +/// `value` 必须指向有效的 `std::string` 对象 +unsafe fn read_std_string(value: *const u8) -> Option { + if value.is_null() { + return None; + } + let length = std::ptr::read_unaligned(value.add(0x10).cast::()) as usize; + let capacity = std::ptr::read_unaligned(value.add(0x14).cast::()); + if length == 0 { + return Some(String::new()); + } + // 上限保护:异常长度不至于越界读取 + if length > 1 << 20 { + return None; + } + let data = if capacity >= SSO_CAPACITY { + let pointer = std::ptr::read_unaligned(value.cast::()) as usize; + if pointer == 0 { + return None; + } + pointer as *const u8 + } else { + value + }; + let bytes = std::slice::from_raw_parts(data, length); + Some(String::from_utf8_lossy(bytes).into_owned()) +} + +unsafe extern "thiscall" fn hooked_dispatcher( + this: *mut u8, + api_name: *mut u8, + response: *mut u8, + out: *mut u8, +) -> i32 { + let original = ORIGINAL.load(std::sync::atomic::Ordering::Acquire); + if original == 0 { + return 0; + } + let original: DispatcherFn = std::mem::transmute(original); + + // 读逻辑 API 名与响应(失败则原样放行) + if let (Some(name), Some(body)) = (read_std_string(api_name), read_std_string(response)) { + crate::server_resources::on_net_response(&name, &body); + } + + original(this, api_name, response, out) +} diff --git a/packages/applechu/src/server_resources/mod.rs b/packages/applechu/src/server_resources/mod.rs index 05cccd9..0d4f69c 100644 --- a/packages/applechu/src/server_resources/mod.rs +++ b/packages/applechu/src/server_resources/mod.rs @@ -6,12 +6,15 @@ //! //! 流程细节见 `D:\Git\work\plan\服务器下发-架构.md`。 +pub mod deflate; +pub mod dispatcher; pub mod download; pub mod entry; pub mod handle; pub mod json; pub mod net_packet; pub mod package; +pub mod power_on_gate; pub mod vfs_bridge; use std::sync::atomic::{AtomicBool, Ordering}; @@ -37,6 +40,12 @@ crate::config_section! { pub cache_path: String = String::from("tmp/ServerResources"), advanced: true, comment: "资源包缓存路径(相对游戏目录)"; + pub native_dispatcher: bool = true, + advanced: true, + comment: "挂在原生收包分发器上;关闭则退回 WinHTTP 响应层"; + pub hold_boot: bool = true, + advanced: true, + comment: "下载期间挂起开机流程"; } } } @@ -66,15 +75,38 @@ pub(crate) fn init(api: &Api, config: &Config) -> Result<(), String> { return Ok(()); } - net_packet::subscribe(on_response); - net_packet::install(api, config)?; + // 信令来源:优先挂在原生分发器(拿逻辑 API 名 + 明文响应), + // 关闭或挂钩失败时退回 WinHTTP 响应层。 + if section.native_dispatcher { + match dispatcher::install(api) { + Ok(()) => {} + Err(error) => { + api.log_warn(&format!( + "ServerResources: native dispatcher unavailable ({error}), falling back to WinHTTP" + )); + net_packet::subscribe(on_response); + net_packet::install(api, config)?; + } + } + } else { + net_packet::subscribe(on_response); + net_packet::install(api, config)?; + } vfs_bridge::install(api)?; + if section.hold_boot { + // 闸门装不上不致命:退化为「下载不阻塞开机」 + if let Err(error) = power_on_gate::install(api) { + api.log_warn(&format!("ServerResources: boot gate unavailable: {error}")); + } + } api.log_info("ServerResources: waiting for GetGameSettingApi response"); Ok(()) } -/// 关闭:还原 VFS 钩子并释放虚拟句柄 +/// 关闭:还原钩子并释放虚拟句柄 pub(crate) fn shutdown() { + power_on_gate::shutdown(); + dispatcher::shutdown(); vfs_bridge::shutdown(); } @@ -83,6 +115,18 @@ pub(crate) fn package() -> Option<&'static package::OptionPackage> { PACKAGE.get().and_then(Option::as_ref) } +/// 原生分发器回传的响应:解析 JSON 后进入条件筛选 +/// +/// 与 WinHTTP 层共用同一段逻辑,区别只在信令来源。 +pub(crate) fn on_net_response(api_name: &str, body: &str) { + let Some(mut value) = json::parse(body) else { + return; + }; + // 广播给 chu-abi 模组(与 WinHTTP 路径行为一致) + net_packet::publish(api_name, &value); + on_response(api_name, &mut value); +} + /// 信令回调:在 `GetGameSettingApi` 响应中查找下发条目 fn on_response(api_name: &str, response: &mut JsonValue) -> bool { // 只监听登录设置响应;其他 API 与包含资源字段的响应都不处理 @@ -142,18 +186,22 @@ fn version_to_int(text: &str) -> i64 { } /// 启动后台下载(只允许一次) +/// +/// 下载与解析放在独立线程,避免阻塞收包线程;期间由开机闸门把 +/// `projGame::PowerOn` 状态机停在当前帧,下载结束后放行。 fn start_download(selected: &entry::ServerResourcesEntry) { if DOWNLOAD_STARTED.swap(true, Ordering::AcqRel) { return; } - // 下载与解析放在独立线程,避免阻塞收包线程;开机闸门(PowerOn 状态改写) - // 接线前,先保证拿到包即可。 + power_on_gate::begin_block(); let url = selected.url.clone(); let sign = selected.sign.clone(); std::thread::spawn(move || { if let Err(error) = fetch_and_load(&url, &sign) { log_warn(&format!("ServerResources: {error}")); } + // 无论成功失败都恢复开机,避免下载异常时卡住 + power_on_gate::end_block(); }); } diff --git a/packages/applechu/src/server_resources/net_packet.rs b/packages/applechu/src/server_resources/net_packet.rs index a73a2c7..55509a3 100644 --- a/packages/applechu/src/server_resources/net_packet.rs +++ b/packages/applechu/src/server_resources/net_packet.rs @@ -157,17 +157,8 @@ fn dispatch(path: &str, body: &[u8]) { } } - // 同时广播给 chu-abi 模组,使其能读取/协作处理该响应。 - // 载荷为 `{"api":"<名>","response":<原始响应>}`,便于订阅方按 API 过滤。 - if let Some(api) = crate::util::api::API.get() { - let mut envelope = String::with_capacity(value.to_string().len() + 32); - envelope.push_str("{\"api\":"); - write_json_string(&name, &mut envelope); - envelope.push_str(",\"response\":"); - envelope.push_str(&value.to_string()); - envelope.push('}'); - api.publish(PUBLISH_TOPIC, envelope.as_bytes()); - } + // 同时广播给 chu-abi 模组,使其能读取/协作处理该响应 + publish(&name, &value); // 改写结果记录到日志;回写由调用方(游戏自身缓冲)决定, // 本桥只读响应,不修改游戏的内存流。 @@ -179,6 +170,22 @@ fn dispatch(path: &str, body: &[u8]) { } } +/// 把一次响应广播到 `net_packet` 主题,供 chu-abi 模组订阅 +/// +/// 载荷为 `{"api":"<名>","response":<响应>}`,便于订阅方按 API 过滤。 +pub(crate) fn publish(api_name: &str, response: &JsonValue) { + let Some(api) = crate::util::api::API.get() else { + return; + }; + let mut envelope = String::with_capacity(response.to_string().len() + 32); + envelope.push_str("{\"api\":"); + write_json_string(api_name, &mut envelope); + envelope.push_str(",\"response\":"); + envelope.push_str(&response.to_string()); + envelope.push('}'); + api.publish(PUBLISH_TOPIC, envelope.as_bytes()); +} + /// 对外广播主题名(chu-abi 模组按此订阅) const PUBLISH_TOPIC: &str = "net_packet"; diff --git a/packages/applechu/src/server_resources/package.rs b/packages/applechu/src/server_resources/package.rs index 5f3c59f..3e819b8 100644 --- a/packages/applechu/src/server_resources/package.rs +++ b/packages/applechu/src/server_resources/package.rs @@ -210,9 +210,7 @@ fn read_entry_data( match method { METHOD_STORE => Ok(data.to_vec()), - METHOD_DEFLATE => Err( - "option package uses deflate compression, which is not wired up yet".to_owned(), - ), + METHOD_DEFLATE => crate::server_resources::deflate::inflate(data), other => Err(format!("unsupported zip compression method {other}")), } } diff --git a/packages/applechu/src/server_resources/power_on_gate.rs b/packages/applechu/src/server_resources/power_on_gate.rs new file mode 100644 index 0000000..1756f03 --- /dev/null +++ b/packages/applechu/src/server_resources/power_on_gate.rs @@ -0,0 +1,193 @@ +//! 开机闸门:下载期间挂起 PowerOn 状态机 +//! +//! 对应 AquaMai 在 `PowerOnProcess.OnUpdate` 上把 state 顶到 255 的做法。 +//! 这里改用更稳的方式:挂钩 `projGame::PowerOn` 状态机的每帧更新(虚表 +//! `0x19249BC` 的第 0 槽),下载进行中直接返回、不调用原函数,状态机因此 +//! 停在同一帧;下载结束(或超时)后恢复调用。 +//! +//! 安全性: +//! - 收包分发器与下载都在各自线程,冻结 PowerOn 更新不会阻塞它们; +//! - 设了最长冻结时间,下载卡死也不会把开机永久挂住。 + +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; + +use crate::util::api::Api; + +/// `projGame::PowerOn` 虚表 RVA(IDA: `0x19249BC`) +const POWER_ON_VTABLE_RVA: usize = 0x152_49BC; +/// 状态机每帧更新在虚表中的槽位 +const UPDATE_SLOT: usize = 0; +/// 最长冻结时间:超时自动放行,避免下载卡死导致开机永久挂起 +const MAX_BLOCK_MS: u32 = 60_000; + +/// 原更新函数(trampoline) +static ORIGINAL: AtomicUsize = AtomicUsize::new(0); +/// 被挂钩的更新函数地址 +static TARGET: AtomicUsize = AtomicUsize::new(0); +/// 是否正在冻结开机流程 +static BLOCKING: AtomicBool = AtomicBool::new(false); +/// 冻结开始时刻(`GetTickCount`) +static BLOCK_STARTED: AtomicU64 = AtomicU64::new(0); +/// 已记录过冻结日志,避免刷屏 +static BLOCK_LOGGED: AtomicBool = AtomicBool::new(false); + +/// 读取系统启动以来的毫秒数(`GetTickCount`,与游戏同源) +#[cfg(windows)] +fn now_ms() -> u32 { + extern "system" { + fn GetTickCount() -> u32; + } + unsafe { GetTickCount() } +} + +#[cfg(not(windows))] +fn now_ms() -> u32 { + 0 +} + +type UpdateFn = unsafe extern "thiscall" fn(*mut u8); + +/// 安装闸门;须在 VFS / 脚本初始化之后的 `Late` 阶段调用 +pub fn install(api: &Api) -> Result<(), String> { + let base = api.game_base(); + if base == 0 { + return Err("game base is not available".to_owned()); + } + let vtable = base + .checked_add(POWER_ON_VTABLE_RVA) + .ok_or_else(|| "PowerOn vtable address overflowed".to_owned())?; + + let mut slot = [0u8; 4]; + if !api.mem_read(vtable + UPDATE_SLOT * 4, &mut slot) { + return Err("PowerOn update slot could not be read".to_owned()); + } + let update = u32::from_le_bytes(slot) as usize; + let (text_base, text_size) = (api.text_base(), api.text_size() as usize); + if update == 0 || !(text_base..text_base + text_size).contains(&update) { + return Err(format!( + "PowerOn update slot 0x{update:08X} is outside the code segment" + )); + } + + let Some(trampoline) = api.hook_create(update, hooked_update as *const () as usize) else { + return Err("PowerOn update hook could not be created".to_owned()); + }; + if !api.hook_enable(update) { + api.hook_remove(update); + return Err("PowerOn update hook could not be enabled".to_owned()); + } + ORIGINAL.store(trampoline, Ordering::Release); + TARGET.store(update, Ordering::Release); + api.log_info(&format!( + "ServerResources: boot gate armed on PowerOn update at 0x{update:08X}" + )); + Ok(()) +} + +pub fn shutdown() { + BLOCKING.store(false, Ordering::Release); + let target = TARGET.swap(0, Ordering::AcqRel); + if target != 0 { + if let Some(api) = crate::util::api::API.get() { + api.hook_disable(target); + api.hook_remove(target); + } + } + ORIGINAL.store(0, Ordering::Release); + BLOCK_LOGGED.store(false, Ordering::Release); +} + +/// 开始冻结开机流程 +pub fn begin_block() { + BLOCK_STARTED.store(u64::from(now_ms()), Ordering::Release); + BLOCKING.store(true, Ordering::Release); + if !BLOCK_LOGGED.swap(true, Ordering::AcqRel) { + log_info("ServerResources: boot held while downloading server resources"); + } +} + +/// 结束冻结,恢复开机流程 +pub fn end_block() { + if BLOCKING.swap(false, Ordering::Release) { + log_info("ServerResources: boot resumed"); + } + BLOCK_LOGGED.store(false, Ordering::Release); +} + +/// 是否处于冻结中(含超时判定) +fn blocking() -> bool { + if !BLOCKING.load(Ordering::Acquire) { + return false; + } + let started = BLOCK_STARTED.load(Ordering::Acquire); + if started == 0 { + return false; + } + let now = u64::from(now_ms()); + if now.wrapping_sub(started) > u64::from(MAX_BLOCK_MS) { + // 超时自动放行,避免下载异常时开机永久挂起 + BLOCKING.store(false, Ordering::Release); + log_warn("ServerResources: boot gate timed out, resuming boot"); + return false; + } + true +} + +unsafe extern "thiscall" fn hooked_update(this: *mut u8) { + if blocking() { + // 停在当前帧:不推进状态机,也不调用原函数 + return; + } + let original = ORIGINAL.load(Ordering::Acquire); + if original == 0 { + return; + } + let original: UpdateFn = std::mem::transmute(original); + original(this); +} + +fn log_info(message: &str) { + if let Some(api) = crate::util::api::API.get() { + api.log_info(message); + } +} + +fn log_warn(message: &str) { + if let Some(api) = crate::util::api::API.get() { + api.log_warn(message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gate_is_open_by_default() { + // Given: 未开始冻结。 + // Then: 不阻塞开机,超时判定也不会误触发。 + assert!(!blocking()); + } + + #[test] + fn block_and_release_round_trip() { + // Given: 开始冻结。 + begin_block(); + + // When/Then: 冻结生效,释放后恢复。 + assert!(blocking()); + end_block(); + assert!(!blocking()); + } + + #[test] + fn stale_start_time_opens_the_gate() { + // Given: 冻结但开始时刻被设为 0(异常状态)。 + BLOCKING.store(true, Ordering::Release); + BLOCK_STARTED.store(0, Ordering::Release); + + // Then: 判定为不阻塞,避免永久挂起。 + assert!(!blocking()); + BLOCKING.store(false, Ordering::Release); + } +} From 3ae9f84ac7501e8d6b141785bc041983e2ac08ab Mon Sep 17 00:00:00 2001 From: Applesaber Date: Sun, 11 Oct 2026 23:45:00 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E8=B5=84=E6=BA=90=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=90=8D=E4=B8=8E=20AquaMai=20=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _appleChuResources 与 AquaMai 的 _aquaMaiResources 结构一致,仅换品牌 前缀。为让服务端一份响应同时服务两个客户端,版本范围字段同时接受 minimum/maximumAquaMaiVersion(AppleChu 命名优先)。 另外下发字段除根级外,再兼容嵌在 gameSetting 内的情况。 --- .../applechu/src/server_resources/entry.rs | 60 +++++++++++++++---- packages/applechu/src/server_resources/mod.rs | 51 +++++++++------- 2 files changed, 81 insertions(+), 30 deletions(-) diff --git a/packages/applechu/src/server_resources/entry.rs b/packages/applechu/src/server_resources/entry.rs index f9a4edb..167352a 100644 --- a/packages/applechu/src/server_resources/entry.rs +++ b/packages/applechu/src/server_resources/entry.rs @@ -1,7 +1,11 @@ //! 服务器下发条目与条件筛选 //! -//! 对应 `_appleChuResources.entries[]` 中的单个条目,以及 AquaMai +//! 对应 `.entries[]` 中的单个条目,以及 AquaMai //! `ConditionalMessage.ShouldShow()` 的等价判定。 +//! +//! 字段名与 AquaMai 保持一致,只替换品牌前缀:`_appleChuResources` / +//! `minimumAppleChuVersion` / `maximumAppleChuVersion`,其余(`url` / `sign` / +//! `locales` / `minimumGameVersion` / `maximumGameVersion`)完全相同。 use crate::server_resources::json::JsonValue; @@ -19,6 +23,9 @@ pub struct ServerResourcesEntry { impl ServerResourcesEntry { /// 从 JSON 对象解析;缺少 `url` 或 `sign` 视为无效条目 + /// + /// 字段名按「AppleChu 优先、AquaMai 兼容」读取:同一份响应里两种命名 + /// 都能生效,方便服务端一次性同时服务 AquaMai 与 AppleChu。 pub fn from_json(value: &JsonValue) -> Option { let url = value.get("url").and_then(JsonValue::as_str)?; let sign = value.get("sign").and_then(JsonValue::as_str)?; @@ -33,8 +40,14 @@ impl ServerResourcesEntry { .get("locales") .map(JsonValue::string_array) .unwrap_or_default(), - minimum_apple_chu_version: optional_string(value, "minimumAppleChuVersion"), - maximum_apple_chu_version: optional_string(value, "maximumAppleChuVersion"), + minimum_apple_chu_version: optional_string_any( + value, + &["minimumAppleChuVersion", "minimumAquaMaiVersion"], + ), + maximum_apple_chu_version: optional_string_any( + value, + &["maximumAppleChuVersion", "maximumAquaMaiVersion"], + ), minimum_game_version: value .get("minimumGameVersion") .and_then(JsonValue::as_i64) @@ -99,13 +112,16 @@ pub fn select_entry<'a>( .find(|entry| entry.should_show(locale, game_version)) } -fn optional_string(value: &JsonValue, key: &str) -> Option { - value - .get(key) - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|text| !text.is_empty()) - .map(ToOwned::to_owned) +/// 按候选键顺序取第一个非空字符串(AppleChu 键优先,AquaMai 键兜底) +fn optional_string_any(value: &JsonValue, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + value + .get(key) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) + }) } /// 语言筛选:空列表表示不限制 @@ -182,6 +198,30 @@ mod tests { assert!(ServerResourcesEntry::from_json(&value).is_none()); } + #[test] + fn accepts_aquamai_version_field_names() { + // Given: 服务端按 AquaMai 命名下发版本范围(同一份响应同时服务两个客户端)。 + let entry = entry_from( + r#"{"url":"u","sign":"s","minimumAquaMaiVersion":"2.0.0","maximumAquaMaiVersion":"9.9.9"}"#, + ); + + // Then: 兼容读取,语义与 AppleChu 命名一致。 + assert_eq!(entry.minimum_apple_chu_version.as_deref(), Some("2.0.0")); + assert_eq!(entry.maximum_apple_chu_version.as_deref(), Some("9.9.9")); + assert!(entry.should_show(None, 0)); + } + + #[test] + fn apple_chu_field_names_take_priority() { + // Given: 两种命名同时出现。 + let entry = entry_from( + r#"{"url":"u","sign":"s","minimumAppleChuVersion":"2.0.0","minimumAquaMaiVersion":"9.0.0"}"#, + ); + + // Then: 取 AppleChu 命名,不因兼容读取而放宽条件。 + assert_eq!(entry.minimum_apple_chu_version.as_deref(), Some("2.0.0")); + } + #[test] fn game_version_range_filters_entries() { // Given: 限制游戏版本 25000 及以上的条目。 diff --git a/packages/applechu/src/server_resources/mod.rs b/packages/applechu/src/server_resources/mod.rs index 0d4f69c..ceb44c8 100644 --- a/packages/applechu/src/server_resources/mod.rs +++ b/packages/applechu/src/server_resources/mod.rs @@ -133,34 +133,45 @@ fn on_response(api_name: &str, response: &mut JsonValue) -> bool { if api_name != "GetGameSettingApi" { return false; } - let Some(resources) = response.get("_appleChuResources") else { - return false; - }; + if let Some(resources) = locate_resources(response) { + let entries = entry::parse_entries(resources); + if entries.is_empty() { + return false; + } - let entries = entry::parse_entries(resources); - if entries.is_empty() { - return false; - } + let game_version = game_version_from(response); + let Some(selected) = entry::select_entry(&entries, None, game_version) else { + log_info(&format!( + "ServerResources: {count} entr{suffix} present but none matched (gameVersion={game_version})", + count = entries.len(), + suffix = if entries.len() == 1 { "y" } else { "ies" } + )); + return false; + }; - let game_version = game_version_from(response); - let Some(selected) = entry::select_entry(&entries, None, game_version) else { log_info(&format!( - "ServerResources: {count} entr{suffix} present but none matched (gameVersion={game_version})", - count = entries.len(), - suffix = if entries.len() == 1 { "y" } else { "ies" } + "ServerResources: entry matched, url={} gameVersion={game_version}", + selected.url )); - return false; - }; - - log_info(&format!( - "ServerResources: entry matched, url={} gameVersion={game_version}", - selected.url - )); - start_download(selected); + start_download(selected); + } // 本功能只读取响应,不改写,因此始终返回 false false } +/// 找到下发的资源字段 +/// +/// 根级优先;为兼容把字段嵌进 `gameSetting` 的服务器实现,再往里找一层。 +/// 字段名 `_appleChuResources` 与 AquaMai 的 `_aquaMaiResources` 结构一致。 +fn locate_resources(response: &JsonValue) -> Option<&JsonValue> { + response + .get(RESOURCES_FIELD) + .or_else(|| response.get("gameSetting")?.get(RESOURCES_FIELD)) +} + +/// 下发字段名(与 AquaMai 的 `_aquaMaiResources` 同构,仅换品牌前缀) +pub(crate) const RESOURCES_FIELD: &str = "_appleChuResources"; + /// 从同一响应派生游戏版本整数(`romVersion` / `dataVersion`) /// /// 取 `major*10000 + minor*100 + release`;缺失或异常时返回 0(不限制)。 From ad41b0bab3b580be0627c895f3a9a377cfa5d9a3 Mon Sep 17 00:00:00 2001 From: Applesaber Date: Sun, 11 Oct 2026 23:54:34 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CI=20=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema 测试的 section 顺序表补上 ServerResources(order 355, 排在 PCBID 与 VFS 之间) - rustfmt:规范化 server_resources 与 util/crypto 的换行缩进 - clippy:去掉 vfs_bridge 中 usize -> usize 的冗余 cast --- packages/applechu-schema/src/lib.rs | 2 +- .../applechu/src/server_resources/deflate.rs | 10 +- .../src/server_resources/dispatcher.rs | 3 +- .../applechu/src/server_resources/entry.rs | 11 +- packages/applechu/src/server_resources/mod.rs | 14 ++- .../applechu/src/server_resources/package.rs | 6 +- .../src/server_resources/vfs_bridge.rs | 101 +++++++++++++----- packages/applechu/src/util/crypto.rs | 6 +- 8 files changed, 108 insertions(+), 45 deletions(-) diff --git a/packages/applechu-schema/src/lib.rs b/packages/applechu-schema/src/lib.rs index 4ed569c..421bb99 100644 --- a/packages/applechu-schema/src/lib.rs +++ b/packages/applechu-schema/src/lib.rs @@ -1124,12 +1124,12 @@ mod tests { "RestartSong", "SkipWarning", "PCBID", + "ServerResources", "VFS", "NetEnv", ] ); } - #[test] fn rejects_malformed_entries_instead_of_silently_dropping_them() { let source = r#" diff --git a/packages/applechu/src/server_resources/deflate.rs b/packages/applechu/src/server_resources/deflate.rs index c3c0890..95fd785 100644 --- a/packages/applechu/src/server_resources/deflate.rs +++ b/packages/applechu/src/server_resources/deflate.rs @@ -68,9 +68,8 @@ pub fn inflate(input: &[u8]) -> Result, String> { }; // 读出解压结果 - let result = unsafe { read_string(&output_buffer) }.ok_or_else(|| { - format!("deflate returned {status} without a usable output buffer") - })?; + let result = unsafe { read_string(&output_buffer) } + .ok_or_else(|| format!("deflate returned {status} without a usable output buffer"))?; // 输出若落在堆上,用游戏的释放函数回收,避免每次解压泄漏 unsafe { @@ -154,7 +153,10 @@ mod tests { unsafe { layout_string(&mut buffer, data).unwrap() }; // Then: 长度与指针写入正确,容量足以让被调函数走指针路径。 - assert_eq!(u32::from_le_bytes(buffer[0x10..0x14].try_into().unwrap()), 5); + assert_eq!( + u32::from_le_bytes(buffer[0x10..0x14].try_into().unwrap()), + 5 + ); assert!(u32::from_le_bytes(buffer[0x14..0x18].try_into().unwrap()) >= SSO_CAPACITY); assert_eq!( u32::from_le_bytes(buffer[0..4].try_into().unwrap()) as usize, diff --git a/packages/applechu/src/server_resources/dispatcher.rs b/packages/applechu/src/server_resources/dispatcher.rs index 4ad5a64..5a71fdc 100644 --- a/packages/applechu/src/server_resources/dispatcher.rs +++ b/packages/applechu/src/server_resources/dispatcher.rs @@ -28,8 +28,7 @@ const DISPATCHER_SIGNATURE: &str = concat!( /// MSVC `std::string::_Myres` 达到该值表示数据在堆上 const SSO_CAPACITY: u32 = 16; -type DispatcherFn = - unsafe extern "thiscall" fn(*mut u8, *mut u8, *mut u8, *mut u8) -> i32; +type DispatcherFn = unsafe extern "thiscall" fn(*mut u8, *mut u8, *mut u8, *mut u8) -> i32; static ORIGINAL: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); static TARGET: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); diff --git a/packages/applechu/src/server_resources/entry.rs b/packages/applechu/src/server_resources/entry.rs index 167352a..135af10 100644 --- a/packages/applechu/src/server_resources/entry.rs +++ b/packages/applechu/src/server_resources/entry.rs @@ -97,7 +97,12 @@ pub fn parse_entries(resources: &JsonValue) -> Vec { resources .get("entries") .and_then(JsonValue::as_array) - .map(|items| items.iter().filter_map(ServerResourcesEntry::from_json).collect()) + .map(|items| { + items + .iter() + .filter_map(ServerResourcesEntry::from_json) + .collect() + }) .unwrap_or_default() } @@ -132,7 +137,9 @@ fn locale_matches(locales: &[String], locale: Option<&str>) -> bool { return true; } match locale { - Some(current) => locales.iter().any(|item| item.eq_ignore_ascii_case(current)), + Some(current) => locales + .iter() + .any(|item| item.eq_ignore_ascii_case(current)), // 无语言来源时不做排除,避免误杀全部条目 None => true, } diff --git a/packages/applechu/src/server_resources/mod.rs b/packages/applechu/src/server_resources/mod.rs index ceb44c8..fa1cc17 100644 --- a/packages/applechu/src/server_resources/mod.rs +++ b/packages/applechu/src/server_resources/mod.rs @@ -27,7 +27,7 @@ use crate::util::api::Api; crate::config_section! { pub(crate) struct ServerResourcesConfig => SERVER_RESOURCES_CONFIG_SECTION { section: "ServerResources", - order: 350, + order: 355, default_on: false, always_enabled: false, hidden: false, @@ -186,9 +186,15 @@ fn game_version_from(response: &JsonValue) -> i64 { fn version_to_int(text: &str) -> i64 { let mut parts = text.trim().split('.'); - let major = parts.next().and_then(|part| part.trim().parse::().ok()); - let minor = parts.next().and_then(|part| part.trim().parse::().ok()); - let release = parts.next().and_then(|part| part.trim().parse::().ok()); + let major = parts + .next() + .and_then(|part| part.trim().parse::().ok()); + let minor = parts + .next() + .and_then(|part| part.trim().parse::().ok()); + let release = parts + .next() + .and_then(|part| part.trim().parse::().ok()); match (major, minor, release) { (Some(major), Some(minor), Some(release)) => major * 10_000 + minor * 100 + release, (Some(major), Some(minor), None) => major * 10_000 + minor * 100, diff --git a/packages/applechu/src/server_resources/package.rs b/packages/applechu/src/server_resources/package.rs index 3e819b8..161db72 100644 --- a/packages/applechu/src/server_resources/package.rs +++ b/packages/applechu/src/server_resources/package.rs @@ -194,11 +194,9 @@ fn read_entry_data( // 本地文件头:签名(4) + 版本(2) + 标志(2) + 方法(2) + 时间(2) + 日期(2) // + CRC(4) + 压缩大小(4) + 原始大小(4) + 名称长度(2) + 扩展长度(2) let name_len = read_u16(bytes, local_offset + 26) - .ok_or_else(|| "zip local header is truncated".to_owned())? - as usize; + .ok_or_else(|| "zip local header is truncated".to_owned())? as usize; let extra_len = read_u16(bytes, local_offset + 28) - .ok_or_else(|| "zip local header is truncated".to_owned())? - as usize; + .ok_or_else(|| "zip local header is truncated".to_owned())? as usize; let data_start = local_offset .checked_add(30) .and_then(|value| value.checked_add(name_len)) diff --git a/packages/applechu/src/server_resources/vfs_bridge.rs b/packages/applechu/src/server_resources/vfs_bridge.rs index e96dd4e..18619db 100644 --- a/packages/applechu/src/server_resources/vfs_bridge.rs +++ b/packages/applechu/src/server_resources/vfs_bridge.rs @@ -86,33 +86,73 @@ fn synthetic() -> &'static Mutex>> { /// 安装 VFS 桥接;须在 `platform` 阶段(`path_hook` 安装完成)之后调用 pub fn install(api: &Api) -> Result<(), String> { unsafe { - bind(api, "FindFirstFileW", hooked_find_first_file_w as *const (), &NEXT_FIND_FIRST_FILE_W)?; - bind(api, "FindNextFileW", hooked_find_next_file_w as *const (), &NEXT_FIND_NEXT_FILE_W)?; - bind(api, "FindClose", hooked_find_close as *const (), &NEXT_FIND_CLOSE)?; - bind(api, "CreateFileW", hooked_create_file_w as *const (), &NEXT_CREATE_FILE_W)?; - bind(api, "ReadFile", hooked_read_file as *const (), &NEXT_READ_FILE)?; - bind(api, "GetFileSize", hooked_get_file_size as *const (), &NEXT_GET_FILE_SIZE)?; - bind(api, "GetFileSizeEx", hooked_get_file_size_ex as *const (), &NEXT_GET_FILE_SIZE_EX)?; - bind(api, "SetFilePointer", hooked_set_file_pointer as *const (), &NEXT_SET_FILE_POINTER)?; + bind( + api, + "FindFirstFileW", + hooked_find_first_file_w as *const (), + &NEXT_FIND_FIRST_FILE_W, + )?; + bind( + api, + "FindNextFileW", + hooked_find_next_file_w as *const (), + &NEXT_FIND_NEXT_FILE_W, + )?; + bind( + api, + "FindClose", + hooked_find_close as *const (), + &NEXT_FIND_CLOSE, + )?; + bind( + api, + "CreateFileW", + hooked_create_file_w as *const (), + &NEXT_CREATE_FILE_W, + )?; + bind( + api, + "ReadFile", + hooked_read_file as *const (), + &NEXT_READ_FILE, + )?; + bind( + api, + "GetFileSize", + hooked_get_file_size as *const (), + &NEXT_GET_FILE_SIZE, + )?; + bind( + api, + "GetFileSizeEx", + hooked_get_file_size_ex as *const (), + &NEXT_GET_FILE_SIZE_EX, + )?; + bind( + api, + "SetFilePointer", + hooked_set_file_pointer as *const (), + &NEXT_SET_FILE_POINTER, + )?; bind( api, "SetFilePointerEx", hooked_set_file_pointer_ex as *const (), &NEXT_SET_FILE_POINTER_EX, )?; - bind(api, "CloseHandle", hooked_close_handle as *const (), &NEXT_CLOSE_HANDLE)?; + bind( + api, + "CloseHandle", + hooked_close_handle as *const (), + &NEXT_CLOSE_HANDLE, + )?; } api.log_info("ServerResources: option VFS bridge installed"); Ok(()) } /// 把 detour 挂到目标进程内该导入的**当前**实现上,并保留它作为下一跳 -unsafe fn bind( - api: &Api, - name: &str, - detour: *const (), - slot: &AtomicUsize, -) -> Result<(), String> { +unsafe fn bind(api: &Api, name: &str, detour: *const (), slot: &AtomicUsize) -> Result<(), String> { // 只挂游戏主模块:避免与 path_hook 覆盖的其他模块重复包装 let previous = hook_iat(api.game_base(), KERNEL32_DLL, name, detour); let Some(previous) = previous else { @@ -148,12 +188,7 @@ unsafe fn restore(name: &str, slot: &AtomicUsize) { return; } if let Some(api) = crate::util::api::API.get() { - let _ = hook_iat( - api.game_base(), - KERNEL32_DLL, - name, - previous as *const (), - ); + let _ = hook_iat(api.game_base(), KERNEL32_DLL, name, previous as *const ()); } } @@ -199,7 +234,7 @@ unsafe fn fill_entry(name: &str, directory: bool, data: *mut WIN32_FIND_DATAW) { fn next_synthetic_handle() -> usize { let token = NEXT_SYNTHETIC.fetch_add(1, Ordering::Relaxed); - (token | SYNTHETIC_FLAG) as usize + token | SYNTHETIC_FLAG } unsafe extern "system" fn hooked_find_first_file_w( @@ -292,7 +327,15 @@ unsafe extern "system" fn hooked_create_file_w( .unwrap_or(0); // \\.\NUL 必然可打开,且 CloseHandle 语义正常 let nul = winapi::string_to_wide("\\\\.\\NUL"); - let real = next(nul.as_ptr(), access, share, security, creation, flags, template); + let real = next( + nul.as_ptr(), + access, + share, + security, + creation, + flags, + template, + ); if real != INVALID_HANDLE_VALUE && handle::register(real as usize, &key, length) { return real; } @@ -374,7 +417,11 @@ unsafe extern "system" fn hooked_set_file_pointer( method: u32, ) -> u32 { if let Some((_, position)) = handle::size_and_position(file as usize) { - let base = if method == FILE_BEGIN { 0isize } else { position as isize }; + let base = if method == FILE_BEGIN { + 0isize + } else { + position as isize + }; let target = base.saturating_add(distance as isize).max(0) as usize; if handle::seek(file as usize, target).is_some() { if !high.is_null() { @@ -398,7 +445,11 @@ unsafe extern "system" fn hooked_set_file_pointer_ex( method: u32, ) -> i32 { if let Some((_, position)) = handle::size_and_position(file as usize) { - let base = if method == FILE_BEGIN { 0 } else { position as i64 }; + let base = if method == FILE_BEGIN { + 0 + } else { + position as i64 + }; let target = base.saturating_add(distance).max(0) as usize; if handle::seek(file as usize, target).is_some() { if !new_position.is_null() { diff --git a/packages/applechu/src/util/crypto.rs b/packages/applechu/src/util/crypto.rs index 10a4e75..0aea549 100644 --- a/packages/applechu/src/util/crypto.rs +++ b/packages/applechu/src/util/crypto.rs @@ -8,9 +8,9 @@ use std::ffi::c_void; use std::ptr; use windows_sys::Win32::Security::Cryptography::{ - BCryptCloseAlgorithmProvider, BCryptDestroyKey, BCryptImportKeyPair, BCryptOpenAlgorithmProvider, - BCryptVerifySignature, BCRYPT_ALG_HANDLE, BCRYPT_KEY_HANDLE, BCRYPT_PAD_PKCS1, - BCRYPT_PKCS1_PADDING_INFO, BCRYPT_RSA_ALGORITHM, BCRYPT_RSAPUBLIC_BLOB, + BCryptCloseAlgorithmProvider, BCryptDestroyKey, BCryptImportKeyPair, + BCryptOpenAlgorithmProvider, BCryptVerifySignature, BCRYPT_ALG_HANDLE, BCRYPT_KEY_HANDLE, + BCRYPT_PAD_PKCS1, BCRYPT_PKCS1_PADDING_INFO, BCRYPT_RSAPUBLIC_BLOB, BCRYPT_RSA_ALGORITHM, BCRYPT_SHA256_ALGORITHM, }; From 62947a2c07be5b91743712d1b79b3dd483f77189 Mon Sep 17 00:00:00 2001 From: Applesaber Date: Mon, 12 Oct 2026 18:54:39 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20option=20=E6=8C=82=E8=BD=BD=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=E5=AD=90=E6=A0=91=E9=87=8D=E5=AE=9A=E5=90=91=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=20ANSI=20=E6=9E=9A=E4=B8=BE=E5=8F=98?= =?UTF-8?q?=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option 不再只存在于内存:解压到缓存目录 tmp/ServerResources.d//, 由 platform/vfs 把 c:\mount\option\\... 重定向过去(源路径与 mount 根两种拼法都覆盖),读取与子目录枚举全部交回游戏原有的文件 API。 - vfs_bridge 只保留 option 根那一级的枚举注入(包内根不在游戏真实 option 目录里,扫不到),语义为叠加:真实枚举先跑并原样返回,自定义根追加其后, 磁盘原有 A001 等的顺序与内容不变 - 补齐 ANSI 变体:游戏 option 扫描走 FindFirstFileExA + FindNextFileA, 原先只挂 W 变体导致注入完全不触发;A/W 各自记自己的下一跳 - 删除 handle.rs 与 CreateFile/ReadFile/GetFileSize/SetFilePointer/CloseHandle /GetFileAttributes 等读取类 hook - package.rs 新增 extract_to,条目路径做穿越校验(拒绝绝对路径、盘符、..) - 未注册覆盖时 option_override 返回 None,原有 resolve_path 分支逐条保留 --- packages/applechu/src/platform/vfs.rs | 68 +- packages/applechu/src/platform/vfs/tests.rs | 67 +- .../applechu/src/server_resources/handle.rs | 173 ---- packages/applechu/src/server_resources/mod.rs | 124 ++- .../applechu/src/server_resources/package.rs | 187 ++-- .../src/server_resources/vfs_bridge.rs | 823 ++++++++++-------- 6 files changed, 798 insertions(+), 644 deletions(-) delete mode 100644 packages/applechu/src/server_resources/handle.rs diff --git a/packages/applechu/src/platform/vfs.rs b/packages/applechu/src/platform/vfs.rs index 994ecc3..43057d7 100644 --- a/packages/applechu/src/platform/vfs.rs +++ b/packages/applechu/src/platform/vfs.rs @@ -1,6 +1,7 @@ use std::ffi::CString; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; use once_cell::sync::OnceCell; @@ -11,6 +12,8 @@ use crate::platform::{path_hook, winapi}; use crate::util::api::Api; static CONFIG: OnceCell = OnceCell::new(); +/// option 根下的子树重定向:(小写目录名, 目标目录) +static OPTION_OVERRIDES: OnceLock>> = OnceLock::new(); static OPTION_API_LOGGED: AtomicBool = AtomicBool::new(false); static OPTION_PATH_LOGGED: AtomicBool = AtomicBool::new(false); @@ -123,6 +126,11 @@ pub fn resolve_path(path: &str) -> Option { if normalized == VFS_TMP_ICF && !config.allow_amfs_downloads { return Some(PathBuf::from(&config.appdata).join("tmpIcf.icf")); } + // 自定义 option 的挂载点优先于盘符映射:它的位置可能正好落在某个映射盘上, + // 未注册覆盖时返回 `None`,下面的分支一个都不少。 + if let Some(target) = option_override(config, &normalized) { + return Some(target); + } if let Some(tail) = strip_drive(&normalized, 'e') { return Some(join_root(&config.amfs, tail)); } @@ -136,7 +144,7 @@ pub fn resolve_path(path: &str) -> Option { return Some(join_root(&config.nthome, tail)); } if let Some(tail) = strip_prefix_dir(&normalized, VFS_OPTION) { - return Some(join_root(&config.option, tail)); + return Some(option_target(config, tail)); } if let Some(tail) = strip_prefix_dir(&normalized, VFS_APM) { return Some(join_root(&config.option, tail)); @@ -144,6 +152,64 @@ pub fn resolve_path(path: &str) -> Option { None } +/// option 源路径 → 真实路径 +/// +/// 根下第一段命中自定义 option 覆盖时,该段**被替换**为解压目录(不是简单叠加), +/// 其余路径原样接在后面;未命中时与没有覆盖时完全一致,落在配置的 option 根下。 +fn option_target(config: &VfsConfig, tail: &str) -> PathBuf { + if tail.is_empty() { + return PathBuf::from(&config.option); + } + let mut segments = tail.splitn(2, '\\'); + let name = segments.next().unwrap_or_default().to_ascii_lowercase(); + let rest = segments.next().unwrap_or_default(); + match override_dir(&name) { + Some(target) => join_root(&target, rest), + None => join_root(&config.option, tail), + } +} + +/// 命中挂载根(mount 根由本模块 hook 给出)的 option 子树 +/// +/// 与 [`option_target`] 同理:第一段被替换为解压目录。`normalized` 已小写, +/// 因此比较也用小写。 +fn option_override(config: &VfsConfig, normalized: &str) -> Option { + let option_root = config.option.trim_end_matches('\\').to_ascii_lowercase(); + let tail = strip_prefix_dir(normalized, &option_root)?; + if tail.is_empty() { + return None; + } + let mut segments = tail.splitn(2, '\\'); + let name = segments.next()?.to_ascii_lowercase(); + let rest = segments.next().unwrap_or_default(); + Some(join_root(&override_dir(&name)?, rest)) +} + +/// 查 option 根下某个子目录名的重定向目标(`None` 表示未挂载) +fn override_dir(name: &str) -> Option { + let overrides = OPTION_OVERRIDES.get()?.lock().ok()?; + overrides + .iter() + .find(|(candidate, _)| candidate == name) + .map(|(_, target)| target.clone()) +} + +/// 注册(或替换)option 根下一个子目录的重定向 +/// +/// 由服务器下发功能在解压完成后调用:`c:\mount\option\\...` 将指向 +/// `target`,读取与枚举都由游戏走真实文件 API 完成。传空 `target` 注销。 +pub fn set_option_override(name: &str, target: &str) { + let overrides = OPTION_OVERRIDES.get_or_init(|| Mutex::new(Vec::new())); + let Ok(mut overrides) = overrides.lock() else { + return; + }; + let key = name.trim_matches('\\').to_ascii_lowercase(); + overrides.retain(|(candidate, _)| *candidate != key); + if !target.is_empty() { + overrides.push((key, winapi::fixup_path(Path::new(target)))); + } +} + fn strip_drive(path: &str, drive: char) -> Option<&str> { let bytes = path.as_bytes(); if bytes.len() < 2 || bytes[0] != drive as u8 || bytes[1] != b':' { diff --git a/packages/applechu/src/platform/vfs/tests.rs b/packages/applechu/src/platform/vfs/tests.rs index d578c15..9bfa29d 100644 --- a/packages/applechu/src/platform/vfs/tests.rs +++ b/packages/applechu/src/platform/vfs/tests.rs @@ -1,7 +1,7 @@ use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; -use super::select_option_path; +use super::{option_override, option_target, select_option_path, set_option_override, VfsConfig}; #[test] fn configured_option_directory_wins_when_non_empty() { @@ -56,6 +56,71 @@ fn missing_candidates_keep_configured_option_path() { remove_test_tree(&root); } +#[test] +fn option_override_redirects_only_the_mounted_subtree() { + // Given: 一个注册了重定向的自定义 option 根。 + let config = test_config("override-only-mounted"); + set_option_override("a9z1", "D:\\cache\\ServerResources.d\\A9Z1"); + + // Then: 该子树的任何路径都指向解压目录——无论游戏走源路径 + // (`c:\mount\option\...`) 还是 mount 根 (`