Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/applechu-schema/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
2 changes: 2 additions & 0 deletions packages/applechu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
76 changes: 75 additions & 1 deletion packages/applechu/src/platform/vfs.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -11,6 +12,8 @@ use crate::platform::{path_hook, winapi};
use crate::util::api::Api;

static CONFIG: OnceCell<VfsConfig> = OnceCell::new();
/// option 根下的子树重定向:(小写目录名, 目标目录)
static OPTION_OVERRIDES: OnceLock<Mutex<Vec<(String, String)>>> = OnceLock::new();
static OPTION_API_LOGGED: AtomicBool = AtomicBool::new(false);
static OPTION_PATH_LOGGED: AtomicBool = AtomicBool::new(false);

Expand Down Expand Up @@ -123,6 +126,11 @@ pub fn resolve_path(path: &str) -> Option<PathBuf> {
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));
}
Expand All @@ -136,14 +144,72 @@ pub fn resolve_path(path: &str) -> Option<PathBuf> {
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));
}
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<PathBuf> {
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<String> {
let overrides = OPTION_OVERRIDES.get()?.lock().ok()?;
overrides
.iter()
.find(|(candidate, _)| candidate == name)
.map(|(_, target)| target.clone())
}

/// 注册(或替换)option 根下一个子目录的重定向
///
/// 由服务器下发功能在解压完成后调用:`c:\mount\option\<name>\...` 将指向
/// `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':' {
Expand Down Expand Up @@ -205,6 +271,14 @@ pub fn amfs_path() -> Option<String> {
CONFIG.get().map(|config| config.amfs.clone())
}

/// 游戏原始请求中 option 的**源前缀**(重定向前),规范化小写
///
/// 游戏访问 option 时传入的是 `c:\mount\option\...`,`resolve_path` 之后才被
/// 改写为真实目录;虚拟包匹配必须对源前缀做判断。
pub fn option_source_prefix() -> String {
VFS_OPTION.to_owned()
Comment on lines +278 to +279

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: option_source_prefix() 返回 String,每次调用都会对编译期常量 VFS_OPTION(&'static str)做一次堆分配。两个调用点 option_root_glob 和 package_key 位于 FindFirstFileW/CreateFileW 的 IAT detour 路径上,游戏每个文件操作都会走到这里,只为取一个常量而反复分配不值当。返回 &'static str 即可,调用方的 trim_end_matches/format! 用法都不受影响。

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/applechu/src/platform/vfs.rs, line 212:

<comment>`option_source_prefix()` 返回 `String`,每次调用都会对编译期常量 `VFS_OPTION`(`&'static str`)做一次堆分配。两个调用点 `option_root_glob` 和 `package_key` 位于 `FindFirstFileW`/`CreateFileW` 的 IAT detour 路径上,游戏每个文件操作都会走到这里,只为取一个常量而反复分配不值当。返回 `&'static str` 即可,调用方的 `trim_end_matches`/`format!` 用法都不受影响。</comment>

<file context>
@@ -205,6 +205,14 @@ pub fn amfs_path() -> Option<String> {
+///
+/// 游戏访问 option 时传入的是 `c:\mount\option\...`,`resolve_path` 之后才被
+/// 改写为真实目录;虚拟包匹配必须对源前缀做判断。
+pub fn option_source_prefix() -> String {
+    VFS_OPTION.to_owned()
+}
</file context>
Suggested change
pub fn option_source_prefix() -> String {
VFS_OPTION.to_owned()
pub fn option_source_prefix() -> &'static str {
VFS_OPTION
}

}

pub fn appdata_path() -> Option<String> {
CONFIG.get().map(|config| config.appdata.clone())
}
Expand Down
67 changes: 66 additions & 1 deletion packages/applechu/src/platform/vfs/tests.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -56,6 +56,71 @@ fn missing_candidates_keep_configured_option_path() {
remove_test_tree(&root);
}

#[test]
fn option_override_redirects_only_the_mounted_subtree() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: 新增测试只直接调用 option_target/option_override,没有覆盖生产入口 resolve_path。resolve_path 里 option_override 与盘符映射(strip_drive('e'/'y'))、VFS_OPTION 分支的判断顺序正是这次改动最关键的接缝(改动注释反复强调“优先于盘符映射”),而 c:\mount\option\a9z1\... 源拼法只在 resolve_path 里经 option_target 生效;这两点都没有端到端断言,如果顺序被调整或分支被遗漏,现有测试不会失败。建议补一组通过 resolve_path 的断言(例如注册后 resolve_path("c:\\mount\\option\\a9z1\\music\\*") 与 resolve_path("d:\\game\\option\\a9z1\\music") 分别指向解压目录、注销后恢复原有行为)。

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/applechu/src/platform/vfs/tests.rs, line 60:

<comment>新增测试只直接调用 `option_target`/`option_override`,没有覆盖生产入口 `resolve_path`。`resolve_path` 里 `option_override` 与盘符映射(`strip_drive('e'/'y')`)、`VFS_OPTION` 分支的判断顺序正是这次改动最关键的接缝(改动注释反复强调“优先于盘符映射”),而 `c:\mount\option\a9z1\...` 源拼法只在 `resolve_path` 里经 `option_target` 生效;这两点都没有端到端断言,如果顺序被调整或分支被遗漏,现有测试不会失败。建议补一组通过 `resolve_path` 的断言(例如注册后 `resolve_path("c:\\mount\\option\\a9z1\\music\\*")` 与 `resolve_path("d:\\game\\option\\a9z1\\music")` 分别指向解压目录、注销后恢复原有行为)。</comment>

<file context>
@@ -56,6 +56,71 @@ fn missing_candidates_keep_configured_option_path() {
 }
 
+#[test]
+fn option_override_redirects_only_the_mounted_subtree() {
+    // Given: 一个注册了重定向的自定义 option 根。
+    let config = test_config("override-only-mounted");
</file context>

// Given: 一个注册了重定向的自定义 option 根。
let config = test_config("override-only-mounted");
set_option_override("a9z1", "D:\\cache\\ServerResources.d\\A9Z1");

// Then: 该子树的任何路径都指向解压目录——无论游戏走源路径
// (`c:\mount\option\...`) 还是 mount 根 (`<option>\...`) 拼路径。
let mounted = |rest: &str| {
std::path::PathBuf::from(format!("D:\\cache\\ServerResources.d\\A9Z1\\{rest}"))
};
assert_eq!(
option_target(&config, "a9z1\\music\\*"),
mounted("music\\*")
);
assert_eq!(
option_target(&config, "a9z1\\data.conf"),
mounted("data.conf")
);
assert_eq!(
option_override(&config, "d:\\game\\option\\a9z1\\music"),
Some(mounted("music"))
);

// 其他 option、option 根本身,以及未挂载的选项,行为完全不变。
assert_eq!(
option_target(&config, "a001\\music"),
std::path::PathBuf::from("D:\\game\\option\\a001\\music")
);
assert_eq!(
option_target(&config, ""),
std::path::PathBuf::from(&config.option)
);
assert_eq!(option_override(&config, "d:\\game\\option\\a001"), None);
assert_eq!(option_override(&config, "d:\\game\\option"), None);

set_option_override("a9z1", "");
}

#[test]
fn clearing_an_override_restores_the_plain_option_root() {
// Given: 先注册再注销同名重定向。
let config = test_config("override-clear");
set_option_override("a9z2", "D:\\cache\\A9Z2");
set_option_override("a9z2", "");

// Then: 完全回到配置的 option 根,行为与没有覆盖时一致。
assert_eq!(
option_target(&config, "a9z2\\*"),
std::path::PathBuf::from("D:\\game\\option\\a9z2\\*")
);
assert_eq!(option_override(&config, "d:\\game\\option\\a9z2"), None);
}

/// 测试用的最小 VFS 配置(`option` 指向游戏目录下的 option)
fn test_config(_case: &str) -> VfsConfig {
VfsConfig {
amfs: "D:\\game\\amfs\\".to_owned(),
option: "D:\\game\\option\\".to_owned(),
appdata: "D:\\game\\appdata\\".to_owned(),
nthome: "C:\\Users\\Default\\".to_owned(),
allow_amfs_downloads: false,
}
}

fn temporary_directory(case: &str) -> std::path::PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand Down
Loading
Loading