diff --git a/Cargo.toml b/Cargo.toml index 31c67d8..3d1a5d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,5 +30,6 @@ windows-sys = { version = "0.59", features = [ "Win32_System_SystemServices", "Win32_System_SystemInformation", "Win32_System_Threading", + "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", ] } diff --git a/src/debug/config.rs b/src/debug/config.rs index 64e02f0..6a09038 100644 --- a/src/debug/config.rs +++ b/src/debug/config.rs @@ -26,6 +26,8 @@ pub struct DebugConfig { pub include_debug_mod: bool, #[serde(default = "default_true")] pub auto_hot_reload_mods: bool, + #[serde(default)] + pub auto_hot_reload_ui: bool, #[serde(default = "default_world_type")] pub world_type: i32, #[serde(default = "default_game_mode")] @@ -200,6 +202,7 @@ fn create_default_config() -> DebugConfig { auto_join_game: true, include_debug_mod: true, auto_hot_reload_mods: true, + auto_hot_reload_ui: false, world_type: default_world_type(), game_mode: default_game_mode(), enable_cheats: true, @@ -350,7 +353,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use super::{allocate_new_world, resolve_mod_dir, strip_json_comments}; + use super::{DebugConfig, allocate_new_world, resolve_mod_dir, strip_json_comments}; struct TempProject { path: PathBuf, @@ -388,6 +391,15 @@ mod tests { ); } + #[test] + fn json_ui_hot_reload_is_opt_in() { + let default_config: DebugConfig = serde_json::from_str("{}").unwrap(); + assert!(!default_config.auto_hot_reload_ui); + + let enabled: DebugConfig = serde_json::from_str(r#"{"auto_hot_reload_ui":true}"#).unwrap(); + assert!(enabled.auto_hot_reload_ui); + } + #[test] fn allocate_new_world_updates_folder_and_name() { let project = TempProject::new(); diff --git a/src/debug/hotreload.rs b/src/debug/hotreload.rs index d983583..284d075 100644 --- a/src/debug/hotreload.rs +++ b/src/debug/hotreload.rs @@ -1,32 +1,101 @@ use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeSet, HashSet}, + mem, path::{Path, PathBuf}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }, thread::{self, JoinHandle}, - time::Duration, + time::{Duration, Instant}, }; #[cfg(not(windows))] -use std::{fs, time::SystemTime}; +use std::{collections::HashMap, fs, time::SystemTime}; use serde_json::json; #[cfg(not(windows))] use walkdir::WalkDir; use super::{ + addon::{self, PackType}, config::ResolvedModDir, ipc::DebugIpcServer, log::{self, ConsoleColor}, + win, }; -const DEBOUNCE_MS: u64 = 100; +const DEBOUNCE_MS: u64 = 150; + +#[derive(Clone, Copy, Debug, Default)] +pub struct HotReloadOptions { + pub python: bool, + pub json_ui: bool, +} + +impl HotReloadOptions { + fn enabled(self) -> bool { + self.python || self.json_ui + } +} + +#[derive(Debug, Default, Eq, PartialEq)] +struct PendingReload { + python_modules: BTreeSet, + json_ui: bool, +} + +impl PendingReload { + fn is_empty(&self) -> bool { + self.python_modules.is_empty() && !self.json_ui + } + + fn merge(&mut self, mut other: Self) { + self.python_modules.append(&mut other.python_modules); + self.json_ui |= other.json_ui; + } +} + +#[derive(Debug, Default)] +struct ReloadState { + pending: PendingReload, + is_foreground: bool, +} + +#[derive(Debug, Eq, PartialEq)] +enum ReloadAction { + Python(Vec), + JsonUi, +} + +#[derive(Debug, Default)] +struct DebouncedPaths { + paths: HashSet, + last_event: Option, +} + +impl DebouncedPaths { + fn push(&mut self, path: PathBuf, now: Instant) { + self.paths.insert(path); + self.last_event = Some(now); + } + + fn take_ready(&mut self, now: Instant) -> Vec { + let ready = self + .last_event + .map(|last| now.duration_since(last) >= Duration::from_millis(DEBOUNCE_MS)) + .unwrap_or(false); + if !ready { + return Vec::new(); + } + + self.last_event = None; + self.paths.drain().collect() + } +} pub struct HotReloadTask { stop: Arc, - file_thread: Option>, foreground_thread: Option>, } @@ -35,8 +104,13 @@ impl HotReloadTask { pub fn start( process_id: u32, mod_dirs: &[ResolvedModDir], - ipc: DebugIpcServer, + ipc: Option, + options: HotReloadOptions, ) -> Option { + if !options.enabled() { + return None; + } + let roots: Vec = mod_dirs .iter() .filter(|dir| dir.hot_reload) @@ -52,45 +126,24 @@ impl HotReloadTask { } let stop = Arc::new(AtomicBool::new(false)); - let need_update = Arc::new(AtomicBool::new(false)); - let is_foreground = Arc::new(AtomicBool::new(false)); - let cached_paths = Arc::new(Mutex::new(HashSet::new())); + let state = Arc::new(Mutex::new(ReloadState::default())); let file_thread = { let stop = Arc::clone(&stop); - let need_update = Arc::clone(&need_update); - let is_foreground = Arc::clone(&is_foreground); - let cached_paths = Arc::clone(&cached_paths); + let state = Arc::clone(&state); let ipc = ipc.clone(); let roots = roots.clone(); - thread::spawn(move || { - watch_py_files(roots, stop, need_update, is_foreground, cached_paths, ipc) - }) + thread::spawn(move || watch_files(process_id, roots, stop, state, ipc, options)) }; let foreground_thread = { let stop = Arc::clone(&stop); - let need_update = Arc::clone(&need_update); - let is_foreground = Arc::clone(&is_foreground); - let cached_paths = Arc::clone(&cached_paths); - let ipc = ipc.clone(); - let roots = roots.clone(); - thread::spawn(move || { - watch_foreground( - process_id, - roots, - stop, - need_update, - is_foreground, - cached_paths, - ipc, - ) - }) + let state = Arc::clone(&state); + thread::spawn(move || watch_foreground(process_id, stop, state, ipc)) }; Some(Self { stop, - file_thread: Some(file_thread), foreground_thread: Some(foreground_thread), }) @@ -113,32 +166,32 @@ impl Drop for HotReloadTask { } } -fn watch_py_files( +fn watch_files( + process_id: u32, roots: Vec, stop: Arc, - need_update: Arc, - is_foreground: Arc, - cached_paths: Arc>>, - ipc: DebugIpcServer, + state: Arc>, + ipc: Option, + options: HotReloadOptions, ) { - watch_py_files_native(roots, stop, need_update, is_foreground, cached_paths, ipc); + watch_files_native(process_id, roots, stop, state, ipc, options); } #[cfg(windows)] -fn watch_py_files_native( +fn watch_files_native( + process_id: u32, roots: Vec, stop: Arc, - need_update: Arc, - is_foreground: Arc, - cached_paths: Arc>>, - ipc: DebugIpcServer, + state: Arc>, + ipc: Option, + options: HotReloadOptions, ) { - use std::{mem, os::windows::ffi::OsStrExt, ptr, time::Instant}; + use std::{mem, os::windows::ffi::OsStrExt, ptr}; use windows_sys::Win32::{ Foundation::{HANDLE, INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT}, Storage::FileSystem::{ - CreateFileW, FILE_ACTION_MODIFIED, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, - FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_INFORMATION, + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_LIST_DIRECTORY, + FILE_NOTIFY_CHANGE_FILE_NAME, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_INFORMATION, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, ReadDirectoryChangesW, }, @@ -179,7 +232,7 @@ fn watch_py_files_native( item.buffer.as_mut_ptr().cast(), item.buffer.len() as u32, 1, - FILE_NOTIFY_CHANGE_LAST_WRITE, + FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_FILE_NAME, &mut bytes_returned, &mut item.overlapped, None, @@ -239,178 +292,236 @@ fn watch_py_files_native( start_watch(item); } - let mut debounce = HashMap::::new(); + let mut debounced = DebouncedPaths::default(); while !stop.load(Ordering::Relaxed) { let handles: Vec = items.iter().map(|item| item.event).collect(); let result = - unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, 100) }; - if result == WAIT_TIMEOUT { - continue; - } + unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, 50) }; if result == WAIT_FAILED { break; } - let index = (result - WAIT_OBJECT_0) as usize; - if index >= items.len() { - continue; - } - let item = &mut items[index]; - let mut bytes = 0u32; - let ok = unsafe { GetOverlappedResult(item.h_dir, &mut item.overlapped, &mut bytes, 0) }; - if ok == 0 || bytes == 0 { - start_watch(item); - continue; + + if result != WAIT_TIMEOUT { + let index = (result - WAIT_OBJECT_0) as usize; + if index < items.len() { + let item = &mut items[index]; + let mut bytes = 0u32; + let ok = + unsafe { GetOverlappedResult(item.h_dir, &mut item.overlapped, &mut bytes, 0) }; + if ok != 0 && bytes != 0 { + let mut offset = 0usize; + let now = Instant::now(); + while offset < bytes as usize { + let info = unsafe { + &*(item.buffer.as_ptr().add(offset) as *const FILE_NOTIFY_INFORMATION) + }; + let name_len = (info.FileNameLength / 2) as usize; + let name = + unsafe { std::slice::from_raw_parts(info.FileName.as_ptr(), name_len) }; + let path = item.dir.join(String::from_utf16_lossy(name)); + if is_supported_file_action(info.Action) + && is_tracked_reload_path(&path, &roots, options) + { + debounced.push(path, now); + } + if info.NextEntryOffset == 0 { + break; + } + offset += info.NextEntryOffset as usize; + } + } + start_watch(item); + } } - let mut offset = 0usize; - while offset < bytes as usize { - let info = - unsafe { &*(item.buffer.as_ptr().add(offset) as *const FILE_NOTIFY_INFORMATION) }; - let name_len = (info.FileNameLength / 2) as usize; - let name = unsafe { std::slice::from_raw_parts(info.FileName.as_ptr(), name_len) }; - let path = item.dir.join(String::from_utf16_lossy(name)); - if info.Action == FILE_ACTION_MODIFIED - && path.extension().and_then(|ext| ext.to_str()) == Some("py") - { - let now = Instant::now(); - let should_trigger = debounce - .get(&path) - .map(|last| now.duration_since(*last) >= Duration::from_millis(DEBOUNCE_MS)) - .unwrap_or(true); - if should_trigger { - debounce.insert(path.clone(), now); - record_changed_path( - &roots, - &path, - &cached_paths, - &need_update, - &is_foreground, - &ipc, - ); - } - } - if info.NextEntryOffset == 0 { - break; - } - offset += info.NextEntryOffset as usize; + let paths = debounced.take_ready(Instant::now()); + if !paths.is_empty() { + record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref()); } - start_watch(item); } } +#[cfg(windows)] +fn is_supported_file_action(action: u32) -> bool { + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_REMOVED, FILE_ACTION_RENAMED_NEW_NAME, + FILE_ACTION_RENAMED_OLD_NAME, + }; + + matches!( + action, + FILE_ACTION_ADDED + | FILE_ACTION_MODIFIED + | FILE_ACTION_REMOVED + | FILE_ACTION_RENAMED_OLD_NAME + | FILE_ACTION_RENAMED_NEW_NAME + ) +} + #[cfg(not(windows))] -fn watch_py_files_native( +fn watch_files_native( + process_id: u32, roots: Vec, stop: Arc, - need_update: Arc, - is_foreground: Arc, - cached_paths: Arc>>, - ipc: DebugIpcServer, + state: Arc>, + ipc: Option, + options: HotReloadOptions, ) { - let mut last_seen = snapshot_py_files(&roots); - let mut debounce = HashMap::::new(); + let mut last_seen = snapshot_files(&roots, options); while !stop.load(Ordering::Relaxed) { thread::sleep(Duration::from_millis(DEBOUNCE_MS)); - let now = SystemTime::now(); - let current = snapshot_py_files(&roots); + let current = snapshot_files(&roots, options); + let mut paths = Vec::new(); + for (path, modified) in ¤t { - let changed = match last_seen.get(path) { - Some(previous) => modified > previous, - None => false, - }; - if !changed { - continue; + if last_seen.get(path) != Some(modified) { + paths.push(path.clone()); } - if let Some(last) = debounce.get(path) { - if now.duration_since(*last).unwrap_or_default() - < Duration::from_millis(DEBOUNCE_MS) - { - continue; - } + } + for path in last_seen.keys() { + if !current.contains_key(path) { + paths.push(path.clone()); } - debounce.insert(path.clone(), now); - record_changed_path( - &roots, - path, - &cached_paths, - &need_update, - &is_foreground, - &ipc, - ); + } + + if !paths.is_empty() { + record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref()); } last_seen = current; } } -fn record_changed_path( +fn record_changed_paths( + process_id: u32, roots: &[PathBuf], - path: &Path, - cached_paths: &Arc>>, - need_update: &Arc, - is_foreground: &Arc, - ipc: &DebugIpcServer, + paths: Vec, + options: HotReloadOptions, + state: &Arc>, + ipc: Option<&DebugIpcServer>, ) { - log::print_colored( - &format!("[HotReload] Detected change in: {}", path.display()), - ConsoleColor::Yellow, - ); - cached_paths.lock().unwrap().insert(path.to_path_buf()); - need_update.store(true, Ordering::Relaxed); - if is_foreground.load(Ordering::Relaxed) { - trigger_reload(roots, cached_paths, need_update, ipc); + let (batch, tracked_paths) = build_reload_batch(paths, roots, options); + if batch.is_empty() { + return; } + + for path in tracked_paths { + log::print_colored( + &format!("[HotReload] Detected change in: {}", path.display()), + ConsoleColor::Yellow, + ); + } + + let should_trigger = { + let mut guard = state.lock().unwrap(); + guard.pending.merge(batch); + guard.is_foreground + }; + if should_trigger { + trigger_reload(process_id, state, ipc); + } +} + +fn build_reload_batch( + paths: Vec, + roots: &[PathBuf], + options: HotReloadOptions, +) -> (PendingReload, Vec) { + let mut batch = PendingReload::default(); + let mut tracked_paths = Vec::new(); + + for path in paths { + let mut tracked = false; + if options.python { + if let Some(module) = py_path_to_module_name(&path, roots) { + batch.python_modules.insert(module); + tracked = true; + } + } + if options.json_ui && is_json_ui_path(&path, roots) { + batch.json_ui = true; + tracked = true; + } + if tracked { + tracked_paths.push(path); + } + } + + (batch, tracked_paths) } fn watch_foreground( process_id: u32, - roots: Vec, stop: Arc, - need_update: Arc, - is_foreground: Arc, - cached_paths: Arc>>, - ipc: DebugIpcServer, + state: Arc>, + ipc: Option, ) { let mut last_state = false; while !stop.load(Ordering::Relaxed) { let current = foreground_process_id() == Some(process_id); - is_foreground.store(current, Ordering::Relaxed); - if current && !last_state && need_update.load(Ordering::Relaxed) { - trigger_reload(&roots, &cached_paths, &need_update, &ipc); + let should_trigger = { + let mut guard = state.lock().unwrap(); + guard.is_foreground = current; + current && !last_state && !guard.pending.is_empty() + }; + if should_trigger { + trigger_reload(process_id, &state, ipc.as_ref()); } last_state = current; thread::sleep(Duration::from_millis(80)); } } -fn trigger_reload( - roots: &[PathBuf], - cached_paths: &Arc>>, - need_update: &Arc, - ipc: &DebugIpcServer, -) { - let paths: Vec = { - let mut guard = cached_paths.lock().unwrap(); - guard.drain().collect() +fn trigger_reload(process_id: u32, state: &Arc>, ipc: Option<&DebugIpcServer>) { + let batch = { + let mut guard = state.lock().unwrap(); + mem::take(&mut guard.pending) }; - let modules: Vec = paths - .iter() - .filter_map(|path| py_path_to_module_name(path, roots)) - .collect(); - need_update.store(false, Ordering::Relaxed); - if modules.is_empty() { - return; + + for action in reload_actions(batch) { + match action { + ReloadAction::Python(modules) => { + log::print_colored( + "[HotReload] 检测到 Python 修改,已触发增量热更新。", + ConsoleColor::Yellow, + ); + if let Some(ipc) = ipc { + let payload = json!(modules).to_string(); + let _ = ipc.send_message(2, payload.as_bytes()); + } + } + ReloadAction::JsonUi => match win::trigger_ui_reload_shortcut(process_id) { + Ok(()) => log::print_colored( + "[HotReload] JSON UI 修改已触发原生 Ctrl+R 重载。", + ConsoleColor::Green, + ), + Err(err) => { + log::print_colored( + &format!("[HotReload] JSON UI 原生 Ctrl+R 重载失败:{err}"), + ConsoleColor::Red, + ); + state.lock().unwrap().pending.json_ui = true; + } + }, + } } - log::print_colored( - "[HotReload] 检测到修改,已触发热更新。", - ConsoleColor::Yellow, - ); - let payload = json!(modules).to_string(); - let _ = ipc.send_message(2, payload.as_bytes()); +} + +fn reload_actions(batch: PendingReload) -> Vec { + let mut actions = Vec::with_capacity(2); + if !batch.python_modules.is_empty() { + actions.push(ReloadAction::Python( + batch.python_modules.into_iter().collect(), + )); + } + if batch.json_ui { + actions.push(ReloadAction::JsonUi); + } + actions } #[cfg(not(windows))] -fn snapshot_py_files(roots: &[PathBuf]) -> HashMap { +fn snapshot_files(roots: &[PathBuf], options: HotReloadOptions) -> HashMap { let mut files = HashMap::new(); for root in roots { if !root.is_dir() { @@ -418,7 +529,7 @@ fn snapshot_py_files(roots: &[PathBuf]) -> HashMap { } for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) { let path = entry.path(); - if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("py") { + if !path.is_file() || !is_tracked_reload_path(path, roots, options) { continue; } if let Ok(metadata) = fs::metadata(path) { @@ -431,6 +542,53 @@ fn snapshot_py_files(roots: &[PathBuf]) -> HashMap { files } +fn is_tracked_reload_path(path: &Path, roots: &[PathBuf], options: HotReloadOptions) -> bool { + (options.python && py_path_to_module_name(path, roots).is_some()) + || (options.json_ui && is_json_ui_path(path, roots)) +} + +fn is_json_ui_path(file_path: &Path, mod_roots: &[PathBuf]) -> bool { + let is_json = file_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("json")) + .unwrap_or(false); + if !is_json { + return false; + } + + let Some(parent) = file_path.parent() else { + return false; + }; + for ancestor in parent.ancestors() { + let is_ui_dir = ancestor + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case("ui")) + .unwrap_or(false); + if !is_ui_dir { + continue; + } + let Some(pack_root) = ancestor.parent() else { + continue; + }; + let under_tracked_root = mod_roots + .iter() + .any(|root| same_path(root, pack_root) || pack_root.strip_prefix(root).is_ok()); + if !under_tracked_root { + continue; + } + if matches!( + addon::parse_pack_info(pack_root), + Ok(Some(info)) if info.pack_type == PackType::Resource + ) { + return true; + } + } + + false +} + pub fn py_path_to_module_name(file_path: &Path, mod_roots: &[PathBuf]) -> Option { if file_path.extension().and_then(|ext| ext.to_str()) != Some("py") { return None; @@ -484,30 +642,224 @@ fn foreground_process_id() -> Option { #[cfg(test)] mod tests { - use std::fs; + use std::{ + fs, + path::{Path, PathBuf}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, + }; - use super::py_path_to_module_name; + use serde_json::json; + + use super::{ + DEBOUNCE_MS, DebouncedPaths, HotReloadOptions, ReloadAction, build_reload_batch, + is_json_ui_path, py_path_to_module_name, reload_actions, + }; + + struct TempProject { + path: PathBuf, + } + + impl TempProject { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "emod-hot-reload-test-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + } + + impl Drop for TempProject { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn create_pack(project: &Path, name: &str, module_type: &str) -> PathBuf { + let pack = project.join(name); + fs::create_dir_all(&pack).unwrap(); + let manifest = json!({ + "header": { + "name": name, + "uuid": "00000000-0000-0000-0000-000000000001", + "version": [1, 0, 0] + }, + "modules": [{ + "type": module_type, + "uuid": "00000000-0000-0000-0000-000000000002", + "version": [1, 0, 0] + }] + }); + fs::write( + pack.join("pack_manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + pack + } #[test] fn maps_py_file_to_module_name_under_manifest() { - let root = std::env::temp_dir().join(format!("emod-hot-{}", std::process::id())); - let pack = root.join("behavior_pack"); + let project = TempProject::new(); + let pack = create_pack(&project.path, "behavior_pack", "data"); let module = pack.join("foo/bar.py"); fs::create_dir_all(module.parent().unwrap()).unwrap(); - fs::write(pack.join("manifest.json"), "{}").unwrap(); fs::write(&module, "").unwrap(); assert_eq!( - py_path_to_module_name(&module, std::slice::from_ref(&root)), + py_path_to_module_name(&module, std::slice::from_ref(&project.path)), Some("foo.bar".to_string()) ); - let outside = root.join("x.py"); + let outside = project.path.join("x.py"); fs::write(&outside, "").unwrap(); assert_eq!( - py_path_to_module_name(&outside, std::slice::from_ref(&root)), + py_path_to_module_name(&outside, std::slice::from_ref(&project.path)), None ); + } - let _ = fs::remove_dir_all(root); + #[test] + fn recognizes_only_resource_pack_json_ui_files() { + let project = TempProject::new(); + let resource = create_pack(&project.path, "resource_pack", "resources"); + let behavior = create_pack(&project.path, "behavior_pack", "data"); + let roots = std::slice::from_ref(&project.path); + + let screen = resource.join("ui/screen.json"); + let nested = resource.join("ui/widgets/_global_variables.json"); + let defs = resource.join("UI/_ui_defs.JSON"); + let removed = resource.join("ui/removed.json"); + let texture_json = resource.join("textures/ui.json"); + let nested_texture_json = resource.join("textures/ui/icons.json"); + let mcgui = resource.join("ui/screen.mcgui"); + let behavior_ui = behavior.join("ui/screen.json"); + let mcp_state = project.path.join(".emod-cli/debug-mcp.json"); + fs::create_dir_all(nested.parent().unwrap()).unwrap(); + fs::create_dir_all(defs.parent().unwrap()).unwrap(); + fs::write(&screen, "{}").unwrap(); + fs::write(&nested, "{}").unwrap(); + fs::write(&defs, "{}").unwrap(); + fs::write(&removed, "{}").unwrap(); + fs::remove_file(&removed).unwrap(); + + assert!(is_json_ui_path(&screen, roots)); + assert!(is_json_ui_path(&nested, roots)); + assert!(is_json_ui_path(&defs, roots)); + assert!(is_json_ui_path(&removed, roots)); + assert!(is_json_ui_path(&screen, std::slice::from_ref(&resource))); + assert!(!is_json_ui_path(&texture_json, roots)); + assert!(!is_json_ui_path(&nested_texture_json, roots)); + assert!(!is_json_ui_path(&mcgui, roots)); + assert!(!is_json_ui_path(&behavior_ui, roots)); + assert!(!is_json_ui_path(&mcp_state, roots)); + + let (excluded, tracked) = build_reload_batch( + vec![ + texture_json, + nested_texture_json, + mcgui, + behavior_ui, + mcp_state, + ], + roots, + HotReloadOptions { + python: false, + json_ui: true, + }, + ); + assert!(excluded.is_empty()); + assert!(tracked.is_empty()); + + let (disabled, tracked) = + build_reload_batch(vec![screen], roots, HotReloadOptions::default()); + assert!(disabled.is_empty()); + assert!(tracked.is_empty()); + } + + #[test] + fn batches_python_and_json_ui_changes_together() { + let project = TempProject::new(); + let behavior = create_pack(&project.path, "behavior_pack", "data"); + let resource = create_pack(&project.path, "resource_pack", "resources"); + let python = behavior.join("scripts/client.py"); + let screen = resource.join("ui/screen.json"); + fs::create_dir_all(python.parent().unwrap()).unwrap(); + fs::create_dir_all(screen.parent().unwrap()).unwrap(); + fs::write(&python, "").unwrap(); + fs::write(&screen, "{}").unwrap(); + + let (batch, tracked) = build_reload_batch( + vec![screen, python], + std::slice::from_ref(&project.path), + HotReloadOptions { + python: true, + json_ui: true, + }, + ); + + assert_eq!(tracked.len(), 2); + assert_eq!( + reload_actions(batch), + vec![ + ReloadAction::Python(vec!["scripts.client".to_string()]), + ReloadAction::JsonUi, + ] + ); + } + + #[test] + fn uses_global_trailing_edge_debounce() { + let start = Instant::now(); + let mut paths = DebouncedPaths::default(); + paths.push(PathBuf::from("ui/screen.json"), start); + paths.push( + PathBuf::from("ui/screen.json"), + start + Duration::from_millis(50), + ); + paths.push( + PathBuf::from("ui/_ui_defs.json"), + start + Duration::from_millis(100), + ); + + assert!( + paths + .take_ready(start + Duration::from_millis(100 + DEBOUNCE_MS - 1)) + .is_empty() + ); + assert_eq!( + paths + .take_ready(start + Duration::from_millis(100 + DEBOUNCE_MS)) + .len(), + 2 + ); + assert!( + paths + .take_ready(start + Duration::from_millis(100 + DEBOUNCE_MS * 2)) + .is_empty() + ); + } + + #[cfg(windows)] + #[test] + fn accepts_windows_content_and_name_change_actions() { + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_REMOVED, + FILE_ACTION_RENAMED_NEW_NAME, FILE_ACTION_RENAMED_OLD_NAME, + }; + + for action in [ + FILE_ACTION_ADDED, + FILE_ACTION_MODIFIED, + FILE_ACTION_REMOVED, + FILE_ACTION_RENAMED_OLD_NAME, + FILE_ACTION_RENAMED_NEW_NAME, + ] { + assert!(super::is_supported_file_action(action)); + } } } diff --git a/src/debug/nbt.rs b/src/debug/nbt.rs index 8987dc6..0c4c21f 100644 --- a/src/debug/nbt.rs +++ b/src/debug/nbt.rs @@ -457,6 +457,7 @@ mod tests { auto_join_game: true, include_debug_mod: true, auto_hot_reload_mods: true, + auto_hot_reload_ui: false, world_type: 1, game_mode: 1, enable_cheats: true, diff --git a/src/debug/process.rs b/src/debug/process.rs index 6f5f60f..0d596e7 100644 --- a/src/debug/process.rs +++ b/src/debug/process.rs @@ -4,7 +4,7 @@ use crate::error::{CliError, Result}; use super::{ config::{DebugConfig, ResolvedModDir}, - hotreload::HotReloadTask, + hotreload::{HotReloadOptions, HotReloadTask}, ipc::DebugIpcServer, log::{self, LineBuffer}, }; @@ -52,9 +52,13 @@ pub fn launch_game( let command = build_command(config, config_arg); let mut command_w = wide_null(OsStr::new(&command)); - let env_block = ipc - .as_ref() - .map(|server| build_environment_block(server.port())); + let env_block = if ipc.is_some() || config.auto_hot_reload_ui { + Some(build_environment_block( + ipc.as_ref().map(|server| server.port()), + )) + } else { + None + }; let mut security = SECURITY_ATTRIBUTES { nLength: std::mem::size_of::() as u32, @@ -116,12 +120,15 @@ pub fn launch_game( let stderr_thread = thread::spawn(move || read_pipe(err_read, stderr_filter, log::process_stderr_line)); - let mut hotreload = if config.auto_hot_reload_mods { - ipc.clone() - .and_then(|server| HotReloadTask::start(pid, mod_dirs, server)) - } else { - None - }; + let mut hotreload = HotReloadTask::start( + pid, + mod_dirs, + ipc.clone(), + HotReloadOptions { + python: config.auto_hot_reload_mods, + json_ui: config.auto_hot_reload_ui, + }, + ); unsafe { WaitForSingleObject(process_info.hProcess, INFINITE) }; @@ -223,14 +230,21 @@ where } #[cfg(windows)] -fn build_environment_block(ipc_port: u16) -> Vec { +fn build_environment_block(ipc_port: Option) -> Vec { let mut pairs: Vec<(Vec, Vec)> = std::env::vars_os() + .filter(|(key, _)| { + let key = key.to_string_lossy(); + !key.eq_ignore_ascii_case("MCDEV_DEBUG_IPC_PORT") + }) .map(|(key, value)| (key.encode_wide().collect(), value.encode_wide().collect())) .collect(); - pairs.push(( - OsStr::new("MCDEV_DEBUG_IPC_PORT").encode_wide().collect(), - OsStr::new(&ipc_port.to_string()).encode_wide().collect(), - )); + + if let Some(port) = ipc_port { + pairs.push(( + OsStr::new("MCDEV_DEBUG_IPC_PORT").encode_wide().collect(), + OsStr::new(&port.to_string()).encode_wide().collect(), + )); + } pairs.sort_by(|a, b| a.0.cmp(&b.0)); let mut block = Vec::new(); diff --git a/src/debug/win.rs b/src/debug/win.rs index ef29330..9b5800f 100644 --- a/src/debug/win.rs +++ b/src/debug/win.rs @@ -10,6 +10,12 @@ use windows_sys::Win32::{ CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, OPEN_EXISTING, }, System::IO::DeviceIoControl, + UI::{ + Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW, VK_CONTROL}, + WindowsAndMessaging::{ + GetForegroundWindow, GetWindowThreadProcessId, PostMessageW, WM_KEYDOWN, WM_KEYUP, + }, + }, }; #[cfg(windows)] @@ -148,6 +154,89 @@ pub fn create_junction(target: &Path, link: &Path) -> io::Result<()> { } } +#[cfg(windows)] +pub fn trigger_ui_reload_shortcut(process_id: u32) -> io::Result<()> { + let window = unsafe { GetForegroundWindow() }; + if window.is_null() { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "当前没有前台窗口,无法向 Minecraft 发送 Ctrl+R", + )); + } + + let mut foreground_process_id = 0u32; + let foreground_thread_id = + unsafe { GetWindowThreadProcessId(window, &mut foreground_process_id) }; + if foreground_thread_id == 0 { + return Err(io::Error::new( + io::ErrorKind::Other, + "无法读取当前前台窗口的进程 ID", + )); + } + if foreground_process_id != process_id { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + format!( + "Minecraft 不在前台:期望 PID {process_id},当前前台 PID {foreground_process_id}" + ), + )); + } + + let [ctrl_down, r_down, r_up, ctrl_up] = ui_reload_key_messages(); + post_key_message(window, ctrl_down)?; + if let Err(err) = post_key_message(window, r_down) { + let _ = post_key_message(window, ctrl_up); + return Err(err); + } + let r_up_result = post_key_message(window, r_up); + let ctrl_up_result = post_key_message(window, ctrl_up); + r_up_result?; + ctrl_up_result?; + + Ok(()) +} + +#[cfg(windows)] +fn ui_reload_key_messages() -> [(u32, u32, bool); 4] { + let control = u32::from(VK_CONTROL); + [ + (WM_KEYDOWN, control, false), + (WM_KEYDOWN, u32::from(b'R'), false), + (WM_KEYUP, u32::from(b'R'), true), + (WM_KEYUP, control, true), + ] +} + +#[cfg(windows)] +fn post_key_message( + window: *mut std::ffi::c_void, + key_message: (u32, u32, bool), +) -> io::Result<()> { + let (message, key, key_up) = key_message; + let posted = unsafe { + PostMessageW( + window, + message, + key as usize, + key_message_lparam(key, key_up), + ) + }; + if posted == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(windows)] +fn key_message_lparam(key: u32, key_up: bool) -> isize { + let scan_code = unsafe { MapVirtualKeyW(key, MAPVK_VK_TO_VSC) }; + let mut value = 1isize | ((scan_code as isize) << 16); + if key_up { + value |= (1isize << 30) | (1isize << 31); + } + value +} + #[cfg(not(windows))] pub fn create_junction(_target: &Path, _link: &Path) -> io::Result<()> { Err(io::Error::new( @@ -155,3 +244,40 @@ pub fn create_junction(_target: &Path, _link: &Path) -> io::Result<()> { "debug launch is only supported on Windows", )) } + +#[cfg(not(windows))] +pub fn trigger_ui_reload_shortcut(_process_id: u32) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "debug launch is only supported on Windows", + )) +} + +#[cfg(all(test, windows))] +mod tests { + use windows_sys::Win32::UI::{ + Input::KeyboardAndMouse::VK_CONTROL, + WindowsAndMessaging::{WM_KEYDOWN, WM_KEYUP}, + }; + + use super::{key_message_lparam, ui_reload_key_messages}; + + #[test] + fn ui_reload_shortcut_uses_native_ctrl_r_order() { + let control = u32::from(VK_CONTROL); + assert_eq!( + ui_reload_key_messages(), + [ + (WM_KEYDOWN, control, false), + (WM_KEYDOWN, u32::from(b'R'), false), + (WM_KEYUP, u32::from(b'R'), true), + (WM_KEYUP, control, true), + ] + ); + + let r_down = key_message_lparam(u32::from(b'R'), false) as usize; + let r_up = key_message_lparam(u32::from(b'R'), true) as usize; + assert_eq!((r_down >> 30) & 0b11, 0); + assert_eq!((r_up >> 30) & 0b11, 0b11); + } +}