Files
emod-cli/src/debug/hotreload.rs
Blank038 3afc122404 feat(debug): 支持 JSON UI 热重载
新增可选的 auto_hot_reload_ui 配置,追踪资源包 UI JSON 的新增、修改、删除和重命名,并在游戏回到前台时发送原生 Ctrl+R。

统一 Python 与 JSON UI 的批处理和拖尾防抖逻辑,同时补充路径分类、事件动作与快捷键消息测试。
2026-07-21 20:44:13 +08:00

866 lines
26 KiB
Rust

use std::{
collections::{BTreeSet, HashSet},
mem,
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
#[cfg(not(windows))]
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 = 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<String>,
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<String>),
JsonUi,
}
#[derive(Debug, Default)]
struct DebouncedPaths {
paths: HashSet<PathBuf>,
last_event: Option<Instant>,
}
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<PathBuf> {
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<AtomicBool>,
file_thread: Option<JoinHandle<()>>,
foreground_thread: Option<JoinHandle<()>>,
}
impl HotReloadTask {
pub fn start(
process_id: u32,
mod_dirs: &[ResolvedModDir],
ipc: Option<DebugIpcServer>,
options: HotReloadOptions,
) -> Option<Self> {
if !options.enabled() {
return None;
}
let roots: Vec<PathBuf> = mod_dirs
.iter()
.filter(|dir| dir.hot_reload)
.map(|dir| dir.path.clone())
.collect();
if roots.is_empty() {
return None;
}
println!("[HotReload] 追踪目录列表:");
for root in &roots {
println!(" └── {}", root.display());
}
let stop = Arc::new(AtomicBool::new(false));
let state = Arc::new(Mutex::new(ReloadState::default()));
let file_thread = {
let stop = Arc::clone(&stop);
let state = Arc::clone(&state);
let ipc = ipc.clone();
let roots = roots.clone();
thread::spawn(move || watch_files(process_id, roots, stop, state, ipc, options))
};
let foreground_thread = {
let stop = Arc::clone(&stop);
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),
})
}
pub fn safe_exit(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.file_thread.take() {
let _ = handle.join();
}
if let Some(handle) = self.foreground_thread.take() {
let _ = handle.join();
}
}
}
impl Drop for HotReloadTask {
fn drop(&mut self) {
self.safe_exit();
}
}
fn watch_files(
process_id: u32,
roots: Vec<PathBuf>,
stop: Arc<AtomicBool>,
state: Arc<Mutex<ReloadState>>,
ipc: Option<DebugIpcServer>,
options: HotReloadOptions,
) {
watch_files_native(process_id, roots, stop, state, ipc, options);
}
#[cfg(windows)]
fn watch_files_native(
process_id: u32,
roots: Vec<PathBuf>,
stop: Arc<AtomicBool>,
state: Arc<Mutex<ReloadState>>,
ipc: Option<DebugIpcServer>,
options: HotReloadOptions,
) {
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_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,
},
System::{
IO::{GetOverlappedResult, OVERLAPPED},
Threading::{CreateEventW, WaitForMultipleObjects},
},
};
struct WatchItem {
dir: PathBuf,
h_dir: HANDLE,
event: HANDLE,
overlapped: OVERLAPPED,
buffer: Vec<u8>,
}
impl Drop for WatchItem {
fn drop(&mut self) {
unsafe {
if self.h_dir != INVALID_HANDLE_VALUE && !self.h_dir.is_null() {
windows_sys::Win32::Foundation::CloseHandle(self.h_dir);
}
if !self.event.is_null() {
windows_sys::Win32::Foundation::CloseHandle(self.event);
}
}
}
}
fn start_watch(item: &mut WatchItem) -> bool {
item.overlapped = unsafe { mem::zeroed() };
item.overlapped.hEvent = item.event;
let mut bytes_returned = 0u32;
unsafe {
ReadDirectoryChangesW(
item.h_dir,
item.buffer.as_mut_ptr().cast(),
item.buffer.len() as u32,
1,
FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_FILE_NAME,
&mut bytes_returned,
&mut item.overlapped,
None,
) != 0
}
}
let mut items = Vec::new();
for root in &roots {
if !root.is_dir() {
continue;
}
let mut wide: Vec<u16> = root.as_os_str().encode_wide().collect();
wide.push(0);
let event = unsafe { CreateEventW(ptr::null(), 0, 0, ptr::null()) };
if event.is_null() {
continue;
}
let h_dir = unsafe {
CreateFileW(
wide.as_ptr(),
FILE_LIST_DIRECTORY,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
ptr::null(),
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
ptr::null_mut(),
)
};
if h_dir == INVALID_HANDLE_VALUE {
unsafe {
windows_sys::Win32::Foundation::CloseHandle(event);
}
continue;
}
items.push(WatchItem {
dir: root.clone(),
h_dir,
event,
overlapped: unsafe { mem::zeroed() },
buffer: vec![0u8; 64 * 1024],
});
}
if items.is_empty() {
return;
}
if items.len() > 64 {
log::print_colored(
"[HotReload] 追踪目录超过 Windows WaitForMultipleObjects 限制,已忽略超出部分。",
ConsoleColor::Yellow,
);
items.truncate(64);
}
for item in &mut items {
start_watch(item);
}
let mut debounced = DebouncedPaths::default();
while !stop.load(Ordering::Relaxed) {
let handles: Vec<HANDLE> = items.iter().map(|item| item.event).collect();
let result =
unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, 50) };
if result == WAIT_FAILED {
break;
}
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 paths = debounced.take_ready(Instant::now());
if !paths.is_empty() {
record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref());
}
}
}
#[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_files_native(
process_id: u32,
roots: Vec<PathBuf>,
stop: Arc<AtomicBool>,
state: Arc<Mutex<ReloadState>>,
ipc: Option<DebugIpcServer>,
options: HotReloadOptions,
) {
let mut last_seen = snapshot_files(&roots, options);
while !stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(DEBOUNCE_MS));
let current = snapshot_files(&roots, options);
let mut paths = Vec::new();
for (path, modified) in &current {
if last_seen.get(path) != Some(modified) {
paths.push(path.clone());
}
}
for path in last_seen.keys() {
if !current.contains_key(path) {
paths.push(path.clone());
}
}
if !paths.is_empty() {
record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref());
}
last_seen = current;
}
}
fn record_changed_paths(
process_id: u32,
roots: &[PathBuf],
paths: Vec<PathBuf>,
options: HotReloadOptions,
state: &Arc<Mutex<ReloadState>>,
ipc: Option<&DebugIpcServer>,
) {
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<PathBuf>,
roots: &[PathBuf],
options: HotReloadOptions,
) -> (PendingReload, Vec<PathBuf>) {
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,
stop: Arc<AtomicBool>,
state: Arc<Mutex<ReloadState>>,
ipc: Option<DebugIpcServer>,
) {
let mut last_state = false;
while !stop.load(Ordering::Relaxed) {
let current = foreground_process_id() == Some(process_id);
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(process_id: u32, state: &Arc<Mutex<ReloadState>>, ipc: Option<&DebugIpcServer>) {
let batch = {
let mut guard = state.lock().unwrap();
mem::take(&mut guard.pending)
};
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;
}
},
}
}
}
fn reload_actions(batch: PendingReload) -> Vec<ReloadAction> {
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_files(roots: &[PathBuf], options: HotReloadOptions) -> HashMap<PathBuf, SystemTime> {
let mut files = HashMap::new();
for root in roots {
if !root.is_dir() {
continue;
}
for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
let path = entry.path();
if !path.is_file() || !is_tracked_reload_path(path, roots, options) {
continue;
}
if let Ok(metadata) = fs::metadata(path) {
if let Ok(modified) = metadata.modified() {
files.insert(path.to_path_buf(), modified);
}
}
}
}
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<String> {
if file_path.extension().and_then(|ext| ext.to_str()) != Some("py") {
return None;
}
let mut cur = file_path.parent()?;
loop {
if cur.join("manifest.json").is_file() || cur.join("pack_manifest.json").is_file() {
let rel = file_path.strip_prefix(cur).ok()?;
let mut parts: Vec<String> = rel
.iter()
.map(|part| part.to_string_lossy().into_owned())
.collect();
let last = parts.last_mut()?;
if !last.ends_with(".py") {
return None;
}
last.truncate(last.len() - 3);
return Some(parts.join("."));
}
if mod_roots.iter().any(|root| same_path(root, cur)) {
return None;
}
cur = cur.parent()?;
}
}
fn same_path(left: &Path, right: &Path) -> bool {
left == right || (left.canonicalize().ok().as_deref() == right.canonicalize().ok().as_deref())
}
#[cfg(windows)]
fn foreground_process_id() -> Option<u32> {
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowThreadProcessId,
};
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.is_null() {
return None;
}
let mut pid = 0u32;
GetWindowThreadProcessId(hwnd, &mut pid);
if pid == 0 { None } else { Some(pid) }
}
}
#[cfg(not(windows))]
fn foreground_process_id() -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::{Path, PathBuf},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
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 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(&module, "").unwrap();
assert_eq!(
py_path_to_module_name(&module, std::slice::from_ref(&project.path)),
Some("foo.bar".to_string())
);
let outside = project.path.join("x.py");
fs::write(&outside, "").unwrap();
assert_eq!(
py_path_to_module_name(&outside, std::slice::from_ref(&project.path)),
None
);
}
#[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));
}
}
}