diff --git a/README.md b/README.md
index 3123501..207233b 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@
| 常用 | 禁用选歌计时器 | 选歌界面不再倒计时 |
| 常用 | 跳过地图动画 | 跳过地图过场动画 |
| 游戏 | 解锁曲数上限 | 自定义单局最大游玩曲数 |
+| 游戏 | 解锁游戏内容 | 内存改写 XML 解锁角色 / 乐曲 / 铭牌 / 语音 / 活动 / 小人 / 称号,不改动磁盘文件 |
| 游戏 | 自定义计时器 | 单独调整各场景计时器 |
| 游戏 | 全部计时器 999 | 将所有计时器统一拉满 |
| 游戏 | 自动游玩 | Autoplay,默认 `Home` 键切换,可配置键位 |
diff --git a/packages/applechu/src/lib.rs b/packages/applechu/src/lib.rs
index d3d2ff3..5d754ad 100644
--- a/packages/applechu/src/lib.rs
+++ b/packages/applechu/src/lib.rs
@@ -26,6 +26,7 @@ mod platform;
mod proxy;
mod slider;
mod system_config;
+mod unlocker;
mod util;
mod ux;
mod vfd;
diff --git a/packages/applechu/src/platform/mod.rs b/packages/applechu/src/platform/mod.rs
index 6e6d943..3481ba8 100644
--- a/packages/applechu/src/platform/mod.rs
+++ b/packages/applechu/src/platform/mod.rs
@@ -7,4 +7,4 @@ pub mod pcbid;
pub mod reg_hook;
pub mod system;
pub mod vfs;
-mod winapi;
+pub mod winapi;
diff --git a/packages/applechu/src/unlocker/mod.rs b/packages/applechu/src/unlocker/mod.rs
new file mode 100644
index 0000000..1e96679
--- /dev/null
+++ b/packages/applechu/src/unlocker/mod.rs
@@ -0,0 +1,921 @@
+// 感谢 @MoeGrid 提供的解锁模块
+//! XML 文件解锁模块
+//!
+//! 通过 iohook 责任链拦截文件打开,在内存中替换 XML 内容实现 CHUNITHM
+//! 内容解锁,不修改磁盘上的原始文件。
+//!
+//! 原理参照 chunlocker:检测到游戏以只读方式打开目标 XML 时,读取原始文件,
+//! 在内存中改写标签值(如 `false` →
+//! `true`),再用 NUL 设备句柄作为占位返回,
+//! 后续 Read / Seek / GetFileSize 全部由本模块从内存缓冲区应答。
+//!
+//! 本模块读取原始文件用的 `std::fs::read` 同样会经过 iohook 责任链,因此
+//! VFS 路径(`C:\Mount\Option\...` 等)会由 `platform::path_hook` 正常转换;
+//! 重入标志保证不会递归回到本模块。
+
+use std::cell::Cell;
+use std::collections::HashMap;
+use std::ffi::c_void;
+use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
+use std::sync::{Mutex, OnceLock};
+
+use once_cell::sync::Lazy;
+
+use crate::iohook::{self, Irp, IrpOp};
+use crate::platform::winapi;
+use crate::util::api::Api;
+use crate::util::iat_hook::hook_iat_all_modules;
+use crate::util::win32::handle_value;
+
+// Win32 错误码
+const ERROR_INVALID_FUNCTION: u32 = 1;
+const ERROR_INVALID_HANDLE: u32 = 6;
+const ERROR_INVALID_PARAMETER: u32 = 87;
+const ERROR_NEGATIVE_SEEK: u32 = 131;
+const INVALID_FILE_SIZE: u32 = 0xFFFF_FFFF;
+
+// CreateFile 参数位
+const GENERIC_WRITE: u32 = 0x4000_0000;
+const GENERIC_ALL: u32 = 0x1000_0000;
+const FILE_WRITE_DATA: u32 = 0x0002;
+const FILE_APPEND_DATA: u32 = 0x0004;
+const OPEN_EXISTING: u32 = 3;
+const OPEN_ALWAYS: u32 = 4;
+const FILE_FLAG_OVERLAPPED: u32 = 0x4000_0000;
+
+// SetFilePointer 定位基准
+const FILE_BEGIN: u32 = 0;
+const FILE_CURRENT: u32 = 1;
+const FILE_END: u32 = 2;
+
+// ---------------------------------------------------------------------------
+// 配置
+// ---------------------------------------------------------------------------
+
+crate::config_section! {
+ pub(crate) struct UnlockerConfig => UNLOCKER_CONFIG_SECTION {
+ section: "Unlocker",
+ order: 400,
+ default_enabled: false,
+ always_enabled: false,
+ hidden: false,
+ comment: "解锁游戏内容(内存改写 XML,不修改磁盘文件)",
+ fields: {
+ pub unlock_chara: bool = true,
+ key: "unlockChara",
+ comment: "解锁角色(Chara.xml)";
+
+ pub unlock_music: bool = true,
+ key: "unlockMusic",
+ comment: "解锁乐曲(Music.xml)";
+
+ pub unlock_nameplate: bool = true,
+ key: "unlockNamePlate",
+ comment: "解锁铭牌(NamePlate.xml)";
+
+ pub unlock_systemvoice: bool = true,
+ key: "unlockSystemVoice",
+ comment: "解锁系统语音(SystemVoice.xml)";
+
+ pub unlock_event: bool = true,
+ key: "unlockEvent",
+ comment: "解锁活动(Event.xml)";
+
+ pub unlock_mapicon: bool = true,
+ key: "unlockMapIcon",
+ comment: "解锁跑图小人(MapIcon.xml)";
+
+ pub unlock_trophy: bool = true,
+ key: "unlockTrophy",
+ comment: "解锁称号(Trophy.xml)";
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 解锁规则
+// ---------------------------------------------------------------------------
+
+/// 单条解锁规则:文件名 → 需要改写的 XML 标签及目标值。
+struct UnlockRule {
+ /// 目标文件名(不含目录,忽略大小写)
+ filename: &'static str,
+ /// 日志中显示的中文名
+ label: &'static str,
+ /// 需要改写的标签名
+ tag: &'static str,
+ /// 标签的目标值
+ value: &'static str,
+ /// 该规则对应的配置开关
+ enabled: fn(&UnlockerConfig) -> bool,
+}
+
+const RULES: &[UnlockRule] = &[
+ UnlockRule {
+ filename: "Chara.xml",
+ label: "角色",
+ tag: "defaultHave",
+ value: "true",
+ enabled: |config| config.unlock_chara,
+ },
+ UnlockRule {
+ filename: "Music.xml",
+ label: "乐曲",
+ tag: "firstLock",
+ value: "false",
+ enabled: |config| config.unlock_music,
+ },
+ UnlockRule {
+ filename: "NamePlate.xml",
+ label: "铭牌",
+ tag: "defaultHave",
+ value: "true",
+ enabled: |config| config.unlock_nameplate,
+ },
+ UnlockRule {
+ filename: "SystemVoice.xml",
+ label: "系统语音",
+ tag: "defaultHave",
+ value: "true",
+ enabled: |config| config.unlock_systemvoice,
+ },
+ UnlockRule {
+ filename: "Event.xml",
+ label: "活动",
+ tag: "alwaysOpen",
+ value: "true",
+ enabled: |config| config.unlock_event,
+ },
+ UnlockRule {
+ filename: "MapIcon.xml",
+ label: "跑图小人",
+ tag: "defaultHave",
+ value: "true",
+ enabled: |config| config.unlock_mapicon,
+ },
+ UnlockRule {
+ filename: "Trophy.xml",
+ label: "称号",
+ tag: "defaultHave",
+ value: "true",
+ enabled: |config| config.unlock_trophy,
+ },
+];
+
+// 日志去重用 u32 位图按规则下标标记,位宽必须覆盖规则数量。
+const _: () = assert!(RULES.len() <= u32::BITS as usize);
+
+/// 查找与文件名匹配且已启用的解锁规则,返回规则下标。
+fn find_rule(config: &UnlockerConfig, filename: &str) -> Option {
+ // CreateFile 是热路径,先用扩展名快速否决绝大多数调用。
+ if !has_xml_extension(filename) {
+ return None;
+ }
+ RULES
+ .iter()
+ .position(|rule| filename.eq_ignore_ascii_case(rule.filename) && (rule.enabled)(config))
+}
+
+fn has_xml_extension(filename: &str) -> bool {
+ filename
+ .len()
+ .checked_sub(4)
+ .and_then(|split| filename.get(split..))
+ .is_some_and(|ext| ext.eq_ignore_ascii_case(".xml"))
+}
+
+// ---------------------------------------------------------------------------
+// 句柄状态管理
+// ---------------------------------------------------------------------------
+
+/// 每个被拦截的文件句柄对应一份修改后的内容和当前读取位置。
+struct FileState {
+ data: Vec,
+ pos: usize,
+}
+
+static UNLOCKER_CONFIG: OnceLock = OnceLock::new();
+static UNLOCKER_FILES: Lazy>> =
+ Lazy::new(|| Mutex::new(HashMap::new()));
+
+fn is_unlocker_handle(handle: usize) -> bool {
+ UNLOCKER_FILES
+ .lock()
+ .ok()
+ .is_some_and(|map| map.contains_key(&handle))
+}
+
+fn unlocker_file_size(handle: usize) -> u64 {
+ UNLOCKER_FILES
+ .lock()
+ .ok()
+ .and_then(|map| map.get(&handle).map(|state| state.data.len() as u64))
+ .unwrap_or(0)
+}
+
+// ---------------------------------------------------------------------------
+// 重入保护
+// ---------------------------------------------------------------------------
+
+// 线程本地重入标志。
+//
+// `load_modified` 用 `std::fs::read` 读取原始文件,Rust 标准库内部会调用
+// `CreateFileW` / `ReadFile`;这些调用经过 iohook 的 IAT hook 会再次进入
+// 本模块的 handler。持有 guard 期间重入直接放行,避免无限递归。
+//
+// 注意:正因为嵌套调用仍会走完 iohook 责任链,我们的 `std::fs::read` 才能
+// 落到 `path_hook` 的 CreateFileW 上并享受 VFS 路径转换 —— 游戏传进来的
+// `C:\Mount\Option\...` 之类虚拟路径因此能被正确解析到真实文件。
+thread_local! {
+ static IN_UNLOCKER: Cell = const { Cell::new(false) };
+}
+
+struct ReentrancyGuard;
+
+impl ReentrancyGuard {
+ /// 未重入时返回 guard 并置位标志;已在模块内部时返回 None。
+ fn acquire() -> Option {
+ IN_UNLOCKER.with(|flag| {
+ if flag.get() {
+ None
+ } else {
+ flag.set(true);
+ Some(Self)
+ }
+ })
+ }
+}
+
+impl Drop for ReentrancyGuard {
+ fn drop(&mut self) {
+ IN_UNLOCKER.with(|flag| flag.set(false));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// GetFileSize / GetFileSizeEx hook
+// ---------------------------------------------------------------------------
+
+/// iohook 框架未 hook GetFileSize,但游戏可能在 ReadFile 前调用它来
+/// 获取文件大小并分配缓冲区。由于我们使用 NUL 设备句柄作为占位,
+/// 原始 GetFileSize 对 NUL 返回 0,会导致游戏认为文件为空。
+/// 因此在此模块单独 hook 这两个 API,对被拦截的句柄返回修改后的内容长度。
+static ORIG_GET_FILE_SIZE: AtomicUsize = AtomicUsize::new(0);
+static ORIG_GET_FILE_SIZE_EX: AtomicUsize = AtomicUsize::new(0);
+
+type GetFileSizeFn = unsafe extern "system" fn(usize, *mut u32) -> u32;
+type GetFileSizeExFn = unsafe extern "system" fn(usize, *mut i64) -> i32;
+
+unsafe fn install_file_size_hooks(api: &Api) {
+ if let Some(original) = hook_iat_all_modules(
+ "kernel32.dll",
+ "GetFileSize",
+ hooked_get_file_size as *const (),
+ ) {
+ ORIG_GET_FILE_SIZE.store(original as usize, Ordering::SeqCst);
+ api.log_info("unlocker: GetFileSize hooked");
+ }
+
+ if let Some(original) = hook_iat_all_modules(
+ "kernel32.dll",
+ "GetFileSizeEx",
+ hooked_get_file_size_ex as *const (),
+ ) {
+ ORIG_GET_FILE_SIZE_EX.store(original as usize, Ordering::SeqCst);
+ api.log_info("unlocker: GetFileSizeEx hooked");
+ }
+}
+
+unsafe extern "system" fn hooked_get_file_size(handle: usize, high: *mut u32) -> u32 {
+ if is_unlocker_handle(handle) {
+ let size = unlocker_file_size(handle);
+ if !high.is_null() {
+ *high = (size >> 32) as u32;
+ }
+ return size as u32;
+ }
+ let addr = ORIG_GET_FILE_SIZE.load(Ordering::SeqCst);
+ if addr == 0 {
+ iohook::set_last_error(ERROR_INVALID_HANDLE);
+ return INVALID_FILE_SIZE;
+ }
+ let original: GetFileSizeFn = std::mem::transmute(addr);
+ original(handle, high)
+}
+
+unsafe extern "system" fn hooked_get_file_size_ex(handle: usize, size: *mut i64) -> i32 {
+ if is_unlocker_handle(handle) {
+ if !size.is_null() {
+ *size = unlocker_file_size(handle) as i64;
+ }
+ return 1;
+ }
+ let addr = ORIG_GET_FILE_SIZE_EX.load(Ordering::SeqCst);
+ if addr == 0 {
+ iohook::set_last_error(ERROR_INVALID_HANDLE);
+ return 0;
+ }
+ let original: GetFileSizeExFn = std::mem::transmute(addr);
+ original(handle, size)
+}
+
+// ---------------------------------------------------------------------------
+// 初始化
+// ---------------------------------------------------------------------------
+
+// stage = IoHook:本模块依赖 iohook 的 CreateFile/ReadFile 责任链,
+// 与设备仿真无关。order 必须大于 iohook::init_all(order = 10),
+// 否则 push_handler 会在钩子安装前执行。
+#[applechu_macros::config_section(stage = IoHook, order = 20)]
+pub fn init(api: &Api, config: &UnlockerConfig) {
+ let enabled: Vec<&str> = RULES
+ .iter()
+ .filter(|rule| (rule.enabled)(config))
+ .map(|rule| rule.filename)
+ .collect();
+
+ if enabled.is_empty() {
+ api.log_warn("unlocker: 所有解锁项均已关闭,跳过安装");
+ return;
+ }
+
+ let _ = UNLOCKER_CONFIG.set(config.clone());
+
+ unsafe {
+ install_file_size_hooks(api);
+ iohook::push_handler(unlocker_irp_handler);
+ }
+
+ api.log_info(&format!(
+ "Unlocker initialized (in-memory XML replacement): [{}]",
+ enabled.join(", ")
+ ));
+}
+
+// ---------------------------------------------------------------------------
+// IRP handler
+// ---------------------------------------------------------------------------
+
+unsafe fn unlocker_irp_handler(irp: &mut Irp) -> i32 {
+ if irp.op == IrpOp::Open {
+ return handle_open(irp);
+ }
+
+ let handle_val = handle_value(irp.fd);
+ if !is_unlocker_handle(handle_val) {
+ return iohook::invoke_next(irp);
+ }
+
+ match irp.op {
+ IrpOp::Close => handle_close(irp, handle_val),
+ IrpOp::Read => handle_read(irp, handle_val),
+ IrpOp::Seek => handle_seek(irp, handle_val),
+ IrpOp::Write => {
+ // 写操作静默丢弃:正常情况到不了这里(handle_open 只拦截只读打开),
+ // 保留作为兜底,避免把游戏的写入落到 NUL 之外的地方。
+ if !irp.out_nbytes.is_null() {
+ *irp.out_nbytes = irp.nbytes;
+ }
+ 1
+ }
+ // Open 已在上方处理;Fsync 对内存缓冲无意义,直接报成功。
+ IrpOp::Fsync | IrpOp::Open => 1,
+ IrpOp::Ioctl => {
+ iohook::set_last_error(ERROR_INVALID_FUNCTION);
+ 0
+ }
+ }
+}
+
+/// 处理文件打开:匹配目标 XML → 读取原始内容 → 内存修改 → 返回虚拟句柄
+unsafe fn handle_open(irp: &mut Irp) -> i32 {
+ let Some(config) = UNLOCKER_CONFIG.get() else {
+ return iohook::invoke_next(irp);
+ };
+
+ // 只接管只读、同步打开。写入 / 截断 / 创建 / 重叠 I/O 一律放行,
+ // 否则游戏的写操作会被 NUL 句柄静默吞掉。
+ if !is_read_only_open(irp) {
+ return iohook::invoke_next(irp);
+ }
+
+ let Some(path) = open_path(irp) else {
+ return iohook::invoke_next(irp);
+ };
+
+ let filename = std::path::Path::new(&path)
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("");
+
+ let Some(rule_index) = find_rule(config, filename) else {
+ return iohook::invoke_next(irp);
+ };
+
+ // 读盘 + 改写在独立函数里完成,重入 guard 随之释放,
+ // 不会覆盖到下面的 invoke_next。
+ let Some(modified) = load_modified(&path, filename, rule_index) else {
+ return iohook::invoke_next(irp);
+ };
+
+ let Some(fd) = iohook::open_nul_fd() else {
+ return iohook::invoke_next(irp);
+ };
+ let handle_val = handle_value(fd);
+ let modified_len = modified.len();
+
+ if let Ok(mut files) = UNLOCKER_FILES.lock() {
+ files.insert(
+ handle_val,
+ FileState {
+ data: modified,
+ pos: 0,
+ },
+ );
+ } else {
+ return iohook::invoke_next(irp);
+ }
+
+ log_rule_once(rule_index, modified_len);
+ irp.fd = fd;
+ 1
+}
+
+/// 读取原始文件并按规则改写;返回 `None` 表示应放行读原始文件。
+///
+/// 重入保护只覆盖这段:`std::fs::read` 内部的 `CreateFileW` 会经 IAT
+/// 再次进入 `handle_open`,若不设标志会对同名文件无限递归。
+/// 注意此处依赖同一条责任链:我们的 `CreateFileW` 仍会落到
+/// `path_hook`,因此 `C:\Mount\Option\...` 这类 VFS 路径能正确解析。
+fn load_modified(path: &str, filename: &str, rule_index: usize) -> Option> {
+ let _guard = ReentrancyGuard::acquire()?;
+ let rule = &RULES[rule_index];
+
+ let raw = match std::fs::read(path) {
+ Ok(data) => data,
+ Err(error) => {
+ warn_once(rule_index, &LOGGED_READ_FAIL, || {
+ format!("unlocker: 读取 {} 失败: {}", filename, error)
+ });
+ return None;
+ }
+ };
+
+ // 非 UTF-8 文件(UTF-16、含非法字节等)直接放行:
+ // from_utf8_lossy 会把非法字节替换成 U+FFFD,等于把文件喂坏。
+ let Ok(text) = std::str::from_utf8(&raw) else {
+ warn_once(rule_index, &LOGGED_NOT_UTF8, || {
+ format!("unlocker: {} 不是合法 UTF-8,已放行读原文件", filename)
+ });
+ return None;
+ };
+
+ let Some(modified) = modify_xml(text, rule.tag, rule.value) else {
+ // 同名文件有上千个(每首曲子 / 每个角色一份),其中一部分的标签
+ // 本来就是目标值或压根没这个标签,无需改写 —— 属正常情况,不是故障。
+ info_once(rule_index, &LOGGED_NO_TAG, || {
+ format!(
+ "unlocker: 部分 {} 的 <{}> 无需改写,已放行读原文件",
+ filename, rule.tag
+ )
+ });
+ return None;
+ };
+
+ Some(modified.into_bytes())
+}
+
+/// 处理文件关闭:先移除状态,再让责任链关闭 NUL 句柄。
+///
+/// 顺序不能反:若先关闭句柄,该值可能被其它线程立刻复用,
+/// 随后的 remove 就会删掉别人的条目。
+unsafe fn handle_close(irp: &mut Irp, handle_val: usize) -> i32 {
+ if let Ok(mut files) = UNLOCKER_FILES.lock() {
+ files.remove(&handle_val);
+ }
+ iohook::invoke_next(irp)
+}
+
+/// 处理文件读取:从改写后的缓冲区按位置拷贝数据
+unsafe fn handle_read(irp: &mut Irp, handle_val: usize) -> i32 {
+ if !irp.out_nbytes.is_null() {
+ *irp.out_nbytes = 0;
+ }
+
+ let Ok(mut files) = UNLOCKER_FILES.lock() else {
+ iohook::set_last_error(ERROR_INVALID_HANDLE);
+ return 0;
+ };
+ let Some(state) = files.get_mut(&handle_val) else {
+ iohook::set_last_error(ERROR_INVALID_HANDLE);
+ return 0;
+ };
+
+ // 同步句柄上传入 OVERLAPPED 是合法的,此时偏移由结构体给出而非当前位置。
+ let overlapped = irp.ovl.cast::();
+ let start = if overlapped.is_null() {
+ state.pos
+ } else {
+ let offset = (*overlapped).offset as u64 | (((*overlapped).offset_high as u64) << 32);
+ offset.min(usize::MAX as u64) as usize
+ };
+
+ let available = state.data.len().saturating_sub(start);
+ let count = available.min(irp.nbytes as usize);
+
+ if count > 0 && !irp.read_buf.is_null() {
+ std::ptr::copy_nonoverlapping(state.data.as_ptr().add(start), irp.read_buf, count);
+ } else if count > 0 {
+ iohook::set_last_error(ERROR_INVALID_PARAMETER);
+ return 0;
+ }
+
+ if overlapped.is_null() {
+ state.pos = start + count;
+ } else {
+ (*overlapped).internal = 0;
+ (*overlapped).internal_high = count;
+ }
+ if !irp.out_nbytes.is_null() {
+ *irp.out_nbytes = count as u32;
+ }
+ 1
+}
+
+/// 处理文件定位:在虚拟缓冲区上模拟 Seek
+unsafe fn handle_seek(irp: &mut Irp, handle_val: usize) -> i32 {
+ const FILE_BEGIN: u32 = 0;
+ const FILE_CURRENT: u32 = 1;
+ const FILE_END: u32 = 2;
+
+ let Ok(mut files) = UNLOCKER_FILES.lock() else {
+ iohook::set_last_error(ERROR_INVALID_HANDLE);
+ return 0;
+ };
+ let Some(state) = files.get_mut(&handle_val) else {
+ iohook::set_last_error(ERROR_INVALID_HANDLE);
+ return 0;
+ };
+
+ let base = match irp.seek_method {
+ FILE_BEGIN => 0i64,
+ FILE_CURRENT => state.pos as i64,
+ FILE_END => state.data.len() as i64,
+ _ => {
+ iohook::set_last_error(ERROR_INVALID_PARAMETER);
+ return 0;
+ }
+ };
+
+ // 定位到负偏移是错误,不是钳到 0;越过 EOF 则合法(读取时自然返回 0 字节)。
+ let Some(new_pos) = base.checked_add(irp.seek_distance).filter(|pos| *pos >= 0) else {
+ iohook::set_last_error(ERROR_NEGATIVE_SEEK);
+ return 0;
+ };
+
+ state.pos = new_pos as usize;
+ if !irp.seek_result.is_null() {
+ *irp.seek_result = new_pos;
+ }
+ 1
+}
+
+// ---------------------------------------------------------------------------
+// 打开参数判定与路径解析
+// ---------------------------------------------------------------------------
+
+/// 仅当打开方式是「只读、不创建、不截断、同步」时才接管。
+fn is_read_only_open(irp: &Irp) -> bool {
+ const WRITE_BITS: u32 = GENERIC_WRITE | GENERIC_ALL | FILE_WRITE_DATA | FILE_APPEND_DATA;
+ irp.open_access & WRITE_BITS == 0
+ && matches!(irp.open_creation, OPEN_EXISTING | OPEN_ALWAYS)
+ && irp.open_flags & FILE_FLAG_OVERLAPPED == 0
+}
+
+fn open_path(irp: &Irp) -> Option {
+ winapi::cstr_to_string(irp.open_filename_a.cast())
+ .or_else(|| winapi::wide_to_string(irp.open_filename_w))
+}
+
+/// 与 Win32 `OVERLAPPED` 布局一致;windows-sys 的定义带联合体,
+/// 这里只取需要的偏移字段。
+#[repr(C)]
+struct Overlapped {
+ internal: usize,
+ internal_high: usize,
+ offset: u32,
+ offset_high: u32,
+ event: *mut c_void,
+}
+
+// ---------------------------------------------------------------------------
+// XML 内容改写
+// ---------------------------------------------------------------------------
+
+/// 把 XML 中所有指定标签的文本内容替换为目标值,返回 `None` 表示无需改动。
+///
+/// 例如 `tag = "defaultHave"`, `target = "true"` 会把
+/// `false` 改成 `true`。
+///
+/// 支持带属性的开标签 `false`;
+/// 跳过自闭合标签 ``;不匹配 `` 这类前缀同名标签。
+fn modify_xml(text: &str, tag: &str, target: &str) -> Option {
+ let open_prefix = format!("<{}", tag);
+ let close_tag = format!("{}>", tag);
+
+ let mut result = String::with_capacity(text.len());
+ let mut search_from = 0;
+ let mut changed = false;
+
+ loop {
+ let Some(rel) = text[search_from..].find(&open_prefix) else {
+ result.push_str(&text[search_from..]);
+ break;
+ };
+ let abs = search_from + rel;
+
+ // 精确标签名匹配:``。
+ let after_name = abs + open_prefix.len();
+ let exact = text
+ .as_bytes()
+ .get(after_name)
+ .is_some_and(|&byte| matches!(byte, b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r'));
+ if !exact {
+ result.push_str(&text[search_from..after_name]);
+ search_from = after_name;
+ continue;
+ }
+
+ // 定位开标签的 '>'
+ let Some(gt_rel) = text[after_name..].find('>') else {
+ result.push_str(&text[search_from..]);
+ break;
+ };
+ let open_end = after_name + gt_rel;
+
+ // 自闭合标签 没有文本内容,原样保留
+ if text[after_name..open_end].ends_with('/') {
+ result.push_str(&text[search_from..=open_end]);
+ search_from = open_end + 1;
+ continue;
+ }
+
+ // 定位闭合标签
+ let Some(close_rel) = text[open_end + 1..].find(&close_tag) else {
+ result.push_str(&text[search_from..=open_end]);
+ search_from = open_end + 1;
+ continue;
+ };
+ let inner = &text[open_end + 1..open_end + 1 + close_rel];
+ let close_end = open_end + 1 + close_rel + close_tag.len();
+
+ // 开标签(含属性)+ 目标值 + 闭合标签
+ result.push_str(&text[search_from..=open_end]);
+ result.push_str(target);
+ result.push_str(&close_tag);
+ if inner != target {
+ changed = true;
+ }
+
+ search_from = close_end;
+ }
+
+ changed.then_some(result)
+}
+
+// ---------------------------------------------------------------------------
+// 日志去重
+// ---------------------------------------------------------------------------
+
+// Chunithm 的 Chara.xml / Music.xml 是每个条目一个文件,量级上千。
+// 每类日志用一张位图按规则下标去重,只输出首次命中,避免刷满日志。
+static LOGGED_INTERCEPT: AtomicU32 = AtomicU32::new(0);
+static LOGGED_READ_FAIL: AtomicU32 = AtomicU32::new(0);
+static LOGGED_NOT_UTF8: AtomicU32 = AtomicU32::new(0);
+static LOGGED_NO_TAG: AtomicU32 = AtomicU32::new(0);
+
+/// 首次为该规则置位时返回 true。
+fn claim_log_slot(seen: &AtomicU32, rule_index: usize) -> bool {
+ let bit = 1u32 << rule_index;
+ seen.fetch_or(bit, Ordering::Relaxed) & bit == 0
+}
+
+/// 该规则首次触发时输出一行 warn,后续静默。用于真正的异常(读盘失败、编码不符)。
+fn warn_once(rule_index: usize, seen: &AtomicU32, message: impl FnOnce() -> String) {
+ if !claim_log_slot(seen, rule_index) {
+ return;
+ }
+ if let Some(api) = crate::util::api::API.get() {
+ api.log_warn(&message());
+ }
+}
+
+/// 该规则首次触发时输出一行 info,后续静默。用于正常但值得知道的情况。
+fn info_once(rule_index: usize, seen: &AtomicU32, message: impl FnOnce() -> String) {
+ if !claim_log_slot(seen, rule_index) {
+ return;
+ }
+ if let Some(api) = crate::util::api::API.get() {
+ api.log_info(&message());
+ }
+}
+
+/// 该规则首次拦截成功时输出一行 info,后续静默。
+fn log_rule_once(rule_index: usize, size: usize) {
+ if !claim_log_slot(&LOGGED_INTERCEPT, rule_index) {
+ return;
+ }
+ let rule = &RULES[rule_index];
+ if let Some(api) = crate::util::api::API.get() {
+ api.log_info(&format!(
+ "unlocker: {} 已解锁({}, <{}> → {}, {} 字节)",
+ rule.label, rule.filename, rule.tag, rule.value, size
+ ));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 单元测试
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::{find_rule, has_xml_extension, modify_xml, UnlockerConfig, RULES};
+
+ #[test]
+ fn replaces_simple_tag() {
+ let output = modify_xml(
+ "false",
+ "defaultHave",
+ "true",
+ );
+ assert_eq!(
+ output.as_deref(),
+ Some("true")
+ );
+ }
+
+ #[test]
+ fn replaces_every_occurrence() {
+ let input =
+ "falsefalse";
+ let output = modify_xml(input, "defaultHave", "true");
+ assert_eq!(
+ output.as_deref(),
+ Some("truetrue")
+ );
+ }
+
+ #[test]
+ fn preserves_attributes_on_open_tag() {
+ let output = modify_xml(
+ "false",
+ "defaultHave",
+ "true",
+ );
+ assert_eq!(
+ output.as_deref(),
+ Some("true")
+ );
+ }
+
+ #[test]
+ fn skips_self_closing_tag() {
+ assert_eq!(
+ modify_xml("", "defaultHave", "true"),
+ None
+ );
+ }
+
+ #[test]
+ fn skips_prefixed_tag_name() {
+ // 不该被 匹配到
+ assert_eq!(
+ modify_xml(
+ "x",
+ "defaultHave",
+ "true"
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn replaces_firstlock_tag() {
+ let output = modify_xml(
+ "true",
+ "firstLock",
+ "false",
+ );
+ assert_eq!(
+ output.as_deref(),
+ Some("false")
+ );
+ }
+
+ #[test]
+ fn returns_none_when_tag_absent() {
+ assert_eq!(
+ modify_xml("v", "defaultHave", "true"),
+ None
+ );
+ }
+
+ #[test]
+ fn returns_none_when_already_unlocked() {
+ // 值已经是目标值,没必要拦截这个句柄
+ assert_eq!(
+ modify_xml(
+ "true",
+ "defaultHave",
+ "true"
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn fills_empty_inner_text() {
+ let output = modify_xml(
+ "",
+ "defaultHave",
+ "true",
+ );
+ assert_eq!(
+ output.as_deref(),
+ Some("true")
+ );
+ }
+
+ #[test]
+ fn keeps_whitespace_inside_open_tag() {
+ let output = modify_xml(
+ "false",
+ "defaultHave",
+ "true",
+ );
+ assert_eq!(
+ output.as_deref(),
+ Some("true")
+ );
+ }
+
+ #[test]
+ fn leaves_unclosed_tag_alone() {
+ assert_eq!(
+ modify_xml("false", "defaultHave", "true"),
+ None
+ );
+ }
+
+ #[test]
+ fn whitespace_around_value_counts_as_change() {
+ // 内部文本 " true " != "true",改写后收紧为 "true"
+ let output = modify_xml(
+ " true ",
+ "defaultHave",
+ "true",
+ );
+ assert_eq!(
+ output.as_deref(),
+ Some("true")
+ );
+ }
+
+ #[test]
+ fn detects_xml_extension_case_insensitively() {
+ assert!(has_xml_extension("Chara.xml"));
+ assert!(has_xml_extension("chara.XML"));
+ assert!(!has_xml_extension("Chara.xml.bak"));
+ assert!(!has_xml_extension("xml"));
+ assert!(!has_xml_extension(""));
+ }
+
+ #[test]
+ fn matches_rule_ignoring_case() {
+ let config = UnlockerConfig::default();
+ assert_eq!(find_rule(&config, "Chara.xml"), Some(0));
+ assert_eq!(find_rule(&config, "MUSIC.XML"), Some(1));
+ assert_eq!(find_rule(&config, "Other.xml"), None);
+ assert_eq!(find_rule(&config, "Chara.bin"), None);
+ }
+
+ #[test]
+ fn respects_disabled_rule() {
+ let config = UnlockerConfig {
+ unlock_chara: false,
+ ..UnlockerConfig::default()
+ };
+ assert_eq!(find_rule(&config, "Chara.xml"), None);
+ assert_eq!(find_rule(&config, "Music.xml"), Some(1));
+ }
+
+ #[test]
+ fn every_rule_targets_an_xml_file() {
+ // 规则文件名必须过 has_xml_extension 的快速否决,否则永远匹配不上
+ for rule in RULES {
+ assert!(has_xml_extension(rule.filename), "{}", rule.filename);
+ }
+ }
+}