feat(debug): 支持网易 Minecraft 调试启动

新增 debug 子命令,自动准备开发世界、注册内置调试 MOD,并将项目行为包和资源包链接到网易运行目录,方便启动游戏后直接进入调试世界。

调试 MOD 资源随仓库一起嵌入,避免依赖本机绝对路径;Windows junction 写入剥离 verbatim 前缀后的 DOS 路径,保证 Minecraft 能正确读取链接包。
This commit is contained in:
2026-05-16 00:59:51 +08:00
parent de2b804aad
commit 011b59c948
36 changed files with 4138 additions and 3 deletions

15
src/commands/debug.rs Normal file
View File

@@ -0,0 +1,15 @@
use std::path::PathBuf;
use crate::{commands::DebugArgs, debug};
pub fn execute(args: &DebugArgs) {
let project_dir = args
.path
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
if let Err(err) = debug::run(&project_dir) {
eprintln!("Error: {}", err);
}
}

View File

@@ -2,6 +2,7 @@ use clap::{Args, Parser, Subcommand, arg};
pub mod components;
pub mod create;
pub mod debug;
pub mod init;
pub mod release;
@@ -29,6 +30,8 @@ pub enum Commands {
Init(InitArgs),
/// Create a new component
Components(ComponentsArgs),
/// Launch NetEase Minecraft with debug MOD, IPC logging, and hot reload
Debug(DebugArgs),
}
#[derive(Args)]
@@ -83,3 +86,10 @@ pub struct ComponentsArgs {
#[arg(short, long)]
pub identifier: Option<String>,
}
#[derive(Args)]
pub struct DebugArgs {
/// The path of the project (default: current directory)
#[arg(short, long)]
pub path: Option<String>,
}

247
src/debug/addon.rs Normal file
View File

@@ -0,0 +1,247 @@
use std::{
fs,
path::{Path, PathBuf},
};
use serde_json::Value;
use crate::error::{CliError, Result};
use super::{
config::{DebugConfig, ResolvedModDir},
env, win,
};
include!(concat!(env!("OUT_DIR"), "/embedded_examples.rs"));
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PackType {
Behavior,
Resource,
}
#[derive(Clone, Debug)]
pub struct PackInfo {
pub name: String,
pub uuid: String,
pub version: Value,
pub pack_type: PackType,
pub path: PathBuf,
}
impl PackInfo {
fn runtime_dir_name(&self) -> String {
self.uuid.chars().filter(|ch| *ch != '-').collect()
}
}
pub fn register_debug_mod(config: &DebugConfig, mod_dirs: &[ResolvedModDir]) -> Result<PackInfo> {
let manifest = EMBEDDED_DEBUG_MOD_FILES
.iter()
.find(|file| file.path == "manifest.json")
.ok_or_else(|| CliError::NotFound("embedded debug MOD manifest.json".to_string()))?;
let mut info = parse_manifest_bytes(manifest.contents)?;
let out_root = runtime_root(info.pack_type);
let target = out_root.join(info.runtime_dir_name());
if target.exists() {
fs::remove_dir_all(&target)?;
}
let debug_options = python_literal_json(&config.debug_options)?;
let target_mod_dirs = hot_reload_dirs_json(mod_dirs)?;
for file in EMBEDDED_DEBUG_MOD_FILES {
let output_path = target.join(file.path.replace('/', "\\"));
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
if file.path.ends_with("Config.py") {
let source = std::str::from_utf8(file.contents).map_err(|err| {
CliError::InvalidData(format!("debug MOD Config.py is not UTF-8: {err}"))
})?;
let content = source
.replace("\"{#debug_options}\"", &debug_options)
.replace("\"{#target_mod_dirs}\"", &target_mod_dirs);
fs::write(output_path, content.as_bytes())?;
} else {
fs::write(output_path, file.contents)?;
}
}
info.path = target;
Ok(info)
}
pub fn link_user_mod_dirs(
mod_dirs: &[ResolvedModDir],
linked_packs: &mut Vec<PackInfo>,
) -> Result<()> {
for mod_dir in mod_dirs {
for pack in link_source_addon_to_runtime_packs(&mod_dir.path)? {
match pack.pack_type {
PackType::Behavior => {
println!("[MCDK] LINK行为包: \"{}\", UUID: {}", pack.name, pack.uuid);
if mod_dir.hot_reload {
println!(" └── 热更新标记追踪");
}
}
PackType::Resource => {
println!("[MCDK] LINK资源包: \"{}\", UUID: {}", pack.name, pack.uuid);
}
}
linked_packs.push(pack);
}
}
Ok(())
}
pub fn parse_pack_info(pack_path: &Path) -> Result<Option<PackInfo>> {
if !pack_path.is_dir() {
return Ok(None);
}
let manifest_path = manifest_path(pack_path);
let Some(manifest_path) = manifest_path else {
return Ok(None);
};
let content = fs::read(&manifest_path)?;
let mut info = parse_manifest_bytes(&content)?;
info.path = pack_path.to_path_buf();
Ok(Some(info))
}
fn link_source_addon_to_runtime_packs(source_dir: &Path) -> Result<Vec<PackInfo>> {
if let Some(pack) = link_source_pack_to_runtime_pack(source_dir)? {
return Ok(vec![pack]);
}
let mut packs = Vec::new();
if !source_dir.is_dir() {
return Ok(packs);
}
for entry in fs::read_dir(source_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(pack) = link_source_pack_to_runtime_pack(&path)? {
packs.push(pack);
}
}
}
Ok(packs)
}
fn link_source_pack_to_runtime_pack(source_dir: &Path) -> Result<Option<PackInfo>> {
let Some(mut info) = parse_pack_info(source_dir)? else {
return Ok(None);
};
let destination = runtime_root(info.pack_type).join(info.runtime_dir_name());
win::create_junction(source_dir, &destination).map_err(|err| {
CliError::Io(std::io::Error::new(
err.kind(),
format!(
"failed to create junction {} -> {}: {err}",
destination.display(),
source_dir.display()
),
))
})?;
info.path = destination;
Ok(Some(info))
}
fn runtime_root(pack_type: PackType) -> PathBuf {
match pack_type {
PackType::Behavior => env::behavior_packs_path(),
PackType::Resource => env::resource_packs_path(),
}
}
fn parse_manifest_bytes(bytes: &[u8]) -> Result<PackInfo> {
let manifest: Value = serde_json::from_slice(bytes)?;
let header = manifest
.get("header")
.and_then(Value::as_object)
.ok_or_else(|| CliError::InvalidData("manifest header is missing".to_string()))?;
let name = header
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let uuid = header
.get("uuid")
.and_then(Value::as_str)
.ok_or_else(|| CliError::InvalidData("manifest header uuid is missing".to_string()))?
.to_string();
let version = header
.get("version")
.cloned()
.unwrap_or_else(|| Value::Array(Vec::new()));
let modules = manifest
.get("modules")
.and_then(Value::as_array)
.ok_or_else(|| CliError::InvalidData("manifest modules is missing".to_string()))?;
let pack_type = modules
.iter()
.filter_map(|module| module.get("type").and_then(Value::as_str))
.find_map(|ty| match ty {
"data" => Some(PackType::Behavior),
"resources" => Some(PackType::Resource),
_ => None,
})
.ok_or_else(|| {
CliError::InvalidData("manifest module type is neither data nor resources".to_string())
})?;
Ok(PackInfo {
name,
uuid,
version,
pack_type,
path: PathBuf::new(),
})
}
fn manifest_path(pack_path: &Path) -> Option<PathBuf> {
let manifest = pack_path.join("manifest.json");
if manifest.is_file() {
return Some(manifest);
}
let netease_manifest = pack_path.join("pack_manifest.json");
if netease_manifest.is_file() {
Some(netease_manifest)
} else {
None
}
}
fn hot_reload_dirs_json(mod_dirs: &[ResolvedModDir]) -> Result<String> {
let dirs: Vec<String> = mod_dirs
.iter()
.filter(|dir| dir.hot_reload)
.map(|dir| dir.path.to_string_lossy().replace('\\', "/"))
.collect();
Ok(serde_json::to_string(&dirs)?)
}
fn python_literal_json(value: &Value) -> Result<String> {
let mut text = serde_json::to_string(value)?;
text = text.replace("true", "True");
text = text.replace("false", "False");
text = text.replace("null", "None");
Ok(text)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::python_literal_json;
#[test]
fn converts_json_literals_to_python_literals() {
assert_eq!(
python_literal_json(&json!({"a": true, "b": null})).unwrap(),
"{\"a\":True,\"b\":None}"
);
}
}

356
src/debug/config.rs Normal file
View File

@@ -0,0 +1,356 @@
use std::{
env, fs,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::error::{CliError, Result};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DebugConfig {
#[serde(default = "default_included_mod_dirs")]
pub included_mod_dirs: Vec<IncludedModDir>,
#[serde(default)]
pub world_seed: Option<i64>,
#[serde(default)]
pub reset_world: bool,
#[serde(default = "default_world_name")]
pub world_name: String,
#[serde(default = "default_world_name")]
pub world_folder_name: String,
#[serde(default = "default_true")]
pub auto_join_game: bool,
#[serde(default = "default_true")]
pub include_debug_mod: bool,
#[serde(default = "default_true")]
pub auto_hot_reload_mods: bool,
#[serde(default = "default_world_type")]
pub world_type: i32,
#[serde(default = "default_game_mode")]
pub game_mode: i32,
#[serde(default = "default_true")]
pub enable_cheats: bool,
#[serde(default = "default_true")]
pub keep_inventory: bool,
#[serde(default = "default_true")]
pub do_weather_cycle: bool,
#[serde(default = "default_true")]
pub do_daylight_cycle: bool,
#[serde(default)]
pub game_executable_path: String,
#[serde(default)]
pub debug_options: Value,
#[serde(default)]
pub netease_config: NeteaseConfig,
#[serde(default = "default_user_name")]
pub user_name: String,
#[serde(default)]
pub skin_info: Option<Value>,
#[serde(default)]
pub experiment_options: Option<ExperimentOptions>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum IncludedModDir {
Path(String),
Object {
path: String,
#[serde(default = "default_true", rename = "hot_reload")]
hot_reload: bool,
#[serde(default = "default_true")]
enabled: bool,
},
}
#[derive(Clone, Debug)]
pub struct ResolvedModDir {
pub path: PathBuf,
pub hot_reload: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct NeteaseConfig {
#[serde(default)]
pub chat_extension: bool,
}
impl Default for NeteaseConfig {
fn default() -> Self {
Self {
chat_extension: false,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ExperimentOptions {
#[serde(default)]
pub data_driven_biomes: bool,
#[serde(default)]
pub data_driven_items: bool,
#[serde(default)]
pub experimental_molang_features: bool,
}
impl DebugConfig {
pub fn included_mod_dirs(&self, project_dir: &Path) -> Result<Vec<ResolvedModDir>> {
let mut dirs = Vec::new();
for item in &self.included_mod_dirs {
match item {
IncludedModDir::Path(path) => {
dirs.push(resolve_mod_dir(project_dir, path, true, true)?)
}
IncludedModDir::Object {
path,
hot_reload,
enabled,
} => {
if *enabled {
dirs.push(resolve_mod_dir(project_dir, path, *hot_reload, true)?);
}
}
}
}
Ok(dirs)
}
pub fn auto_join_game_effective(&self) -> bool {
match env::var("MCDEV_AUTO_JOIN_GAME") {
Ok(value) if value == "0" => false,
Ok(value) if value == "1" => true,
_ => self.auto_join_game,
}
}
}
pub fn env_is_subprocess_mode() -> bool {
matches!(env::var("MCDEV_IS_SUBPROCESS_MODE"), Ok(value) if value == "1" || value.eq_ignore_ascii_case("true"))
}
pub fn load_or_create(project_dir: &Path) -> Result<DebugConfig> {
let config_path = project_dir.join(".mcdev.json");
if config_path.is_file() {
let content = fs::read_to_string(&config_path)?;
let stripped = strip_json_comments(&content);
let config = serde_json::from_str(&stripped)?;
return Ok(config);
}
let config = create_default_config();
write_config(project_dir, &config)?;
if config.game_executable_path.is_empty() {
return Err(CliError::NotFound(
"未找到 Minecraft.Windows.exe已创建 .mcdev.json请填写 game_executable_path"
.to_string(),
));
}
Ok(config)
}
pub fn ensure_game_executable(project_dir: &Path, config: &mut DebugConfig) -> Result<()> {
let configured = PathBuf::from(&config.game_executable_path);
if !config.game_executable_path.is_empty() && configured.is_file() {
return Ok(());
}
if let Some(path) = crate::debug::env::auto_match_latest_game_exe_path() {
config.game_executable_path = path.to_string_lossy().replace('\\', "/");
write_config(project_dir, config)?;
println!(
"游戏路径无效,已重新搜索并更新:{}",
config.game_executable_path
);
return Ok(());
}
Err(CliError::NotFound(
"未找到有效的 Minecraft.Windows.exe请在 .mcdev.json 中设置 game_executable_path"
.to_string(),
))
}
pub fn write_config(project_dir: &Path, config: &DebugConfig) -> Result<()> {
let path = project_dir.join(".mcdev.json");
let text = serde_json::to_string_pretty(config)?;
fs::write(path, text)?;
Ok(())
}
fn create_default_config() -> DebugConfig {
DebugConfig {
included_mod_dirs: default_included_mod_dirs(),
world_seed: None,
reset_world: false,
world_name: default_world_name(),
world_folder_name: default_world_name(),
auto_join_game: true,
include_debug_mod: true,
auto_hot_reload_mods: true,
world_type: default_world_type(),
game_mode: default_game_mode(),
enable_cheats: true,
keep_inventory: true,
do_weather_cycle: true,
do_daylight_cycle: true,
game_executable_path: crate::debug::env::auto_match_latest_game_exe_path()
.map(|path| path.to_string_lossy().replace('\\', "/"))
.unwrap_or_default(),
debug_options: Value::Object(Map::new()),
netease_config: NeteaseConfig::default(),
user_name: default_user_name(),
skin_info: None,
experiment_options: None,
}
}
fn resolve_mod_dir(
project_dir: &Path,
path: &str,
hot_reload: bool,
enabled: bool,
) -> Result<ResolvedModDir> {
if !enabled {
return Err(CliError::InvalidInput(
"disabled mod dir should not be resolved".to_string(),
));
}
let raw = PathBuf::from(path);
let path = if raw.is_absolute() {
raw
} else {
let base = if project_dir.is_absolute() {
project_dir.to_path_buf()
} else {
env::current_dir()?.join(project_dir)
};
base.join(raw)
};
Ok(ResolvedModDir {
path: normalize_path(path),
hot_reload,
})
}
fn normalize_path(path: PathBuf) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
if out.as_os_str().is_empty() {
PathBuf::from(".")
} else {
out
}
}
fn default_included_mod_dirs() -> Vec<IncludedModDir> {
vec![IncludedModDir::Path("./".to_string())]
}
fn default_world_name() -> String {
"MC_DEV_WORLD".to_string()
}
fn default_user_name() -> String {
"developer".to_string()
}
fn default_true() -> bool {
true
}
fn default_world_type() -> i32 {
1
}
fn default_game_mode() -> i32 {
1
}
pub fn strip_json_comments(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_string {
out.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
out.push(ch);
continue;
}
if ch == '/' {
match chars.peek().copied() {
Some('/') => {
chars.next();
for next in chars.by_ref() {
if next == '\n' {
out.push('\n');
break;
}
}
}
Some('*') => {
chars.next();
let mut prev = '\0';
for next in chars.by_ref() {
if next == '\n' {
out.push('\n');
}
if prev == '*' && next == '/' {
break;
}
prev = next;
}
}
_ => out.push(ch),
}
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::{resolve_mod_dir, strip_json_comments};
#[test]
fn strips_jsonc_comments_without_touching_strings() {
let input = r#"{"url":"http://x",/*a*/"n":1//b
}"#;
assert_eq!(
strip_json_comments(input),
"{\"url\":\"http://x\",\"n\":1\n}"
);
}
#[test]
fn default_current_project_resolves_to_absolute_cwd() {
let resolved = resolve_mod_dir(std::path::Path::new("."), "./", true, true).unwrap();
let cwd = super::normalize_path(std::env::current_dir().unwrap());
assert_eq!(resolved.path, cwd);
assert!(resolved.hot_reload);
}
}

99
src/debug/env.rs Normal file
View File

@@ -0,0 +1,99 @@
use std::{env, fs, path::PathBuf};
use crate::error::Result;
pub fn app_data_path() -> PathBuf {
env::var_os("APPDATA")
.map(PathBuf::from)
.unwrap_or_default()
}
pub fn minecraft_data_path() -> PathBuf {
app_data_path().join("MinecraftPE_Netease")
}
pub fn games_com_netease_path() -> PathBuf {
minecraft_data_path().join("games/com.netease")
}
pub fn worlds_path() -> PathBuf {
minecraft_data_path().join("minecraftWorlds")
}
pub fn behavior_packs_path() -> PathBuf {
games_com_netease_path().join("behavior_packs")
}
pub fn resource_packs_path() -> PathBuf {
games_com_netease_path().join("resource_packs")
}
pub fn clean_runtime_packs() -> Result<()> {
for path in [behavior_packs_path(), resource_packs_path()] {
if path.is_dir() {
fs::remove_dir_all(&path)?;
}
}
Ok(())
}
pub fn auto_match_latest_game_exe_path() -> Option<PathBuf> {
let mut best: Option<(Vec<u32>, PathBuf)> = None;
for drive in b'A'..=b'Z' {
let root = format!(
"{}:/MCStudioDownload/game/MinecraftPE_Netease",
drive as char
);
let root = PathBuf::from(root);
if !root.is_dir() {
continue;
}
let entries = fs::read_dir(&root).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let exe = path.join("Minecraft.Windows.exe");
if !exe.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Some(version) = parse_version(name) else {
continue;
};
match &best {
Some((best_version, _)) if best_version >= &version => {}
_ => best = Some((version, exe)),
}
}
}
best.map(|(_, path)| path)
}
fn parse_version(value: &str) -> Option<Vec<u32>> {
let mut out = Vec::new();
for part in value.split('.') {
if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
out.push(part.parse().ok()?);
}
if out.is_empty() { None } else { Some(out) }
}
#[cfg(test)]
mod tests {
use super::parse_version;
#[test]
fn parses_numeric_versions_only() {
assert_eq!(parse_version("1.21.3"), Some(vec![1, 21, 3]));
assert_eq!(parse_version("1.x"), None);
}
}

513
src/debug/hotreload.rs Normal file
View File

@@ -0,0 +1,513 @@
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
#[cfg(not(windows))]
use std::{fs, time::SystemTime};
use serde_json::json;
#[cfg(not(windows))]
use walkdir::WalkDir;
use super::{
config::ResolvedModDir,
ipc::DebugIpcServer,
log::{self, ConsoleColor},
};
const DEBOUNCE_MS: u64 = 100;
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: DebugIpcServer,
) -> Option<Self> {
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 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 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 ipc = ipc.clone();
let roots = roots.clone();
thread::spawn(move || {
watch_py_files(roots, stop, need_update, is_foreground, cached_paths, ipc)
})
};
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,
)
})
};
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_py_files(
roots: Vec<PathBuf>,
stop: Arc<AtomicBool>,
need_update: Arc<AtomicBool>,
is_foreground: Arc<AtomicBool>,
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
ipc: DebugIpcServer,
) {
watch_py_files_native(roots, stop, need_update, is_foreground, cached_paths, ipc);
}
#[cfg(windows)]
fn watch_py_files_native(
roots: Vec<PathBuf>,
stop: Arc<AtomicBool>,
need_update: Arc<AtomicBool>,
is_foreground: Arc<AtomicBool>,
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
ipc: DebugIpcServer,
) {
use std::{mem, os::windows::ffi::OsStrExt, ptr, time::Instant};
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,
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,
&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 debounce = HashMap::<PathBuf, Instant>::new();
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, 100) };
if result == WAIT_TIMEOUT {
continue;
}
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;
}
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;
}
start_watch(item);
}
}
#[cfg(not(windows))]
fn watch_py_files_native(
roots: Vec<PathBuf>,
stop: Arc<AtomicBool>,
need_update: Arc<AtomicBool>,
is_foreground: Arc<AtomicBool>,
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
ipc: DebugIpcServer,
) {
let mut last_seen = snapshot_py_files(&roots);
let mut debounce = HashMap::<PathBuf, SystemTime>::new();
while !stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(DEBOUNCE_MS));
let now = SystemTime::now();
let current = snapshot_py_files(&roots);
for (path, modified) in &current {
let changed = match last_seen.get(path) {
Some(previous) => modified > previous,
None => false,
};
if !changed {
continue;
}
if let Some(last) = debounce.get(path) {
if now.duration_since(*last).unwrap_or_default()
< Duration::from_millis(DEBOUNCE_MS)
{
continue;
}
}
debounce.insert(path.clone(), now);
record_changed_path(
&roots,
path,
&cached_paths,
&need_update,
&is_foreground,
&ipc,
);
}
last_seen = current;
}
}
fn record_changed_path(
roots: &[PathBuf],
path: &Path,
cached_paths: &Arc<Mutex<HashSet<PathBuf>>>,
need_update: &Arc<AtomicBool>,
is_foreground: &Arc<AtomicBool>,
ipc: &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);
}
}
fn watch_foreground(
process_id: u32,
roots: Vec<PathBuf>,
stop: Arc<AtomicBool>,
need_update: Arc<AtomicBool>,
is_foreground: Arc<AtomicBool>,
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
ipc: DebugIpcServer,
) {
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);
}
last_state = current;
thread::sleep(Duration::from_millis(80));
}
}
fn trigger_reload(
roots: &[PathBuf],
cached_paths: &Arc<Mutex<HashSet<PathBuf>>>,
need_update: &Arc<AtomicBool>,
ipc: &DebugIpcServer,
) {
let paths: Vec<PathBuf> = {
let mut guard = cached_paths.lock().unwrap();
guard.drain().collect()
};
let modules: Vec<String> = paths
.iter()
.filter_map(|path| py_path_to_module_name(path, roots))
.collect();
need_update.store(false, Ordering::Relaxed);
if modules.is_empty() {
return;
}
log::print_colored(
"[HotReload] 检测到修改,已触发热更新。",
ConsoleColor::Yellow,
);
let payload = json!(modules).to_string();
let _ = ipc.send_message(2, payload.as_bytes());
}
#[cfg(not(windows))]
fn snapshot_py_files(roots: &[PathBuf]) -> 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() || path.extension().and_then(|ext| ext.to_str()) != Some("py") {
continue;
}
if let Ok(metadata) = fs::metadata(path) {
if let Ok(modified) = metadata.modified() {
files.insert(path.to_path_buf(), modified);
}
}
}
}
files
}
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;
use super::py_path_to_module_name;
#[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 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)),
Some("foo.bar".to_string())
);
let outside = root.join("x.py");
fs::write(&outside, "").unwrap();
assert_eq!(
py_path_to_module_name(&outside, std::slice::from_ref(&root)),
None
);
let _ = fs::remove_dir_all(root);
}
}

123
src/debug/ipc.rs Normal file
View File

@@ -0,0 +1,123 @@
use std::{
io::{self, Write},
net::{TcpListener, TcpStream},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
use crate::error::{CliError, Result};
#[derive(Clone)]
pub struct DebugIpcServer {
inner: Arc<Inner>,
}
struct Inner {
port: Mutex<u16>,
clients: Mutex<Vec<TcpStream>>,
stop: AtomicBool,
thread: Mutex<Option<JoinHandle<()>>>,
}
impl DebugIpcServer {
pub fn start() -> Result<Self> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
let inner = Arc::new(Inner {
port: Mutex::new(port),
clients: Mutex::new(Vec::new()),
stop: AtomicBool::new(false),
thread: Mutex::new(None),
});
let thread_inner = Arc::clone(&inner);
let handle = thread::spawn(move || accept_loop(listener, thread_inner));
*inner.thread.lock().unwrap() = Some(handle);
Ok(Self { inner })
}
pub fn port(&self) -> u16 {
*self.inner.port.lock().unwrap()
}
pub fn send_message(&self, message_type: u16, payload: &[u8]) -> Result<bool> {
let frame = encode_frame(message_type, payload)?;
let mut clients = self.inner.clients.lock().unwrap();
let mut any = false;
clients.retain_mut(|stream| match stream.write(&frame) {
Ok(_) => {
any = true;
true
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => true,
Err(_) => false,
});
Ok(any)
}
pub fn stop(&self) {
self.inner.stop.store(true, Ordering::Relaxed);
*self.inner.port.lock().unwrap() = 0;
self.inner.clients.lock().unwrap().clear();
}
pub fn join(&self) {
if let Some(handle) = self.inner.thread.lock().unwrap().take() {
let _ = handle.join();
}
}
pub fn safe_exit(&self) {
self.stop();
self.join();
}
}
impl Drop for DebugIpcServer {
fn drop(&mut self) {
self.stop();
}
}
pub fn encode_frame(message_type: u16, payload: &[u8]) -> Result<Vec<u8>> {
let len = u32::try_from(payload.len())
.map_err(|_| CliError::InvalidInput("IPC payload is larger than u32::MAX".to_string()))?;
let mut frame = Vec::with_capacity(6 + payload.len());
frame.extend_from_slice(&message_type.to_be_bytes());
frame.extend_from_slice(&len.to_be_bytes());
frame.extend_from_slice(payload);
Ok(frame)
}
fn accept_loop(listener: TcpListener, inner: Arc<Inner>) {
while !inner.stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let _ = stream.set_nonblocking(true);
inner.clients.lock().unwrap().push(stream);
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(50));
}
Err(_) => break,
}
}
}
#[cfg(test)]
mod tests {
use super::encode_frame;
#[test]
fn encodes_big_endian_ipc_frame() {
let frame = encode_frame(2, b"[\"a.b\"]").unwrap();
assert_eq!(&frame[..6], &[0, 2, 0, 0, 0, 7]);
assert_eq!(&frame[6..], b"[\"a.b\"]");
}
}

125
src/debug/level.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::{fs, path::PathBuf};
use serde_json::{Value, json};
use crate::error::Result;
use super::{
addon::{PackInfo, PackType},
config::DebugConfig,
env, nbt,
};
pub fn world_dir(config: &DebugConfig) -> PathBuf {
env::worlds_path().join(&config.world_folder_name)
}
pub fn prepare_world(config: &DebugConfig, linked_packs: &[PackInfo]) -> Result<()> {
let world_dir = world_dir(config);
let level_path = world_dir.join("level.dat");
if !world_dir.is_dir() || config.reset_world {
if world_dir.exists() {
fs::remove_dir_all(&world_dir)?;
}
fs::create_dir_all(&world_dir)?;
let data = nbt::create_level_dat(config)?;
fs::write(&level_path, data)?;
} else if level_path.is_file() {
let data = fs::read(&level_path)?;
let updated = if plugin_env_enabled() {
nbt::update_level_dat_world_data(&data, config, false)?
} else {
nbt::update_level_dat_last_played(&data)?
};
fs::write(&level_path, updated)?;
} else {
let data = nbt::create_level_dat(config)?;
fs::write(&level_path, data)?;
}
write_world_pack_manifests(config, linked_packs)
}
pub fn write_dev_config(config: &DebugConfig) -> Result<PathBuf> {
let world_dir = world_dir(config);
fs::create_dir_all(&world_dir)?;
let game_exe = PathBuf::from(&config.game_executable_path);
let default_skin = game_exe
.parent()
.unwrap_or_else(|| game_exe.as_path())
.join("data/skin_packs/vanilla/steve.png")
.to_string_lossy()
.replace('\\', "/");
let mut dev_config = json!({
"world_info": { "level_id": config.world_folder_name },
"room_info": {},
"player_info": {
"urs": "",
"user_id": 0,
"user_name": config.user_name,
}
});
if let Some(skin_info) = &config.skin_info {
let mut skin_info = skin_info.clone();
if let Value::Object(map) = &mut skin_info {
map.entry("slim".to_string()).or_insert(Value::Bool(false));
match map.get("skin").and_then(Value::as_str) {
Some(path) if !path.is_empty() => {}
_ => {
map.insert("skin".to_string(), Value::String(default_skin));
}
}
}
dev_config["skin_info"] = skin_info;
} else {
dev_config["skin_info"] = json!({ "slim": false, "skin": default_skin });
}
let path = world_dir.join("dev_config.cppconfig");
fs::write(&path, serde_json::to_vec_pretty(&dev_config)?)?;
Ok(path)
}
fn write_world_pack_manifests(config: &DebugConfig, linked_packs: &[PackInfo]) -> Result<()> {
let mut behavior = Vec::new();
let mut resource = Vec::new();
for pack in linked_packs {
let entry = json!({
"pack_id": pack.uuid,
"version": pack.version,
});
match pack.pack_type {
PackType::Behavior => behavior.push(entry),
PackType::Resource => resource.push(entry),
}
}
let world_dir = world_dir(config);
let (behavior_file, resource_file) = if config.auto_join_game_effective() {
("world_behavior_packs.json", "world_resource_packs.json")
} else {
(
"netease_world_behavior_packs.json",
"netease_world_resource_packs.json",
)
};
fs::write(
world_dir.join(behavior_file),
serde_json::to_vec_pretty(&behavior)?,
)?;
fs::write(
world_dir.join(resource_file),
serde_json::to_vec_pretty(&resource)?,
)?;
Ok(())
}
fn plugin_env_enabled() -> bool {
matches!(std::env::var("MCDEV_PLUGIN_ENV"), Ok(value) if value == "1" || value.eq_ignore_ascii_case("true"))
}

187
src/debug/level_template.rs Normal file
View File

@@ -0,0 +1,187 @@
pub const LEVEL_DAT_TEMPLATE: &[u8] = &[
0x0a, 0x00, 0x00, 0x00, 0x83, 0x0b, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x08, 0x0d, 0x00, 0x42, 0x69,
0x6f, 0x6d, 0x65, 0x4f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, 0x65, 0x00, 0x00, 0x01, 0x12, 0x00,
0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x4d, 0x61, 0x70, 0x73, 0x54, 0x6f, 0x4f, 0x72, 0x69, 0x67,
0x69, 0x6e, 0x00, 0x01, 0x1e, 0x00, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x65, 0x64, 0x50,
0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x43, 0x6f, 0x6e,
0x74, 0x65, 0x6e, 0x74, 0x00, 0x03, 0x0a, 0x00, 0x44, 0x69, 0x66, 0x66, 0x69, 0x63, 0x75, 0x6c,
0x74, 0x79, 0x02, 0x00, 0x00, 0x00, 0x08, 0x0f, 0x00, 0x46, 0x6c, 0x61, 0x74, 0x57, 0x6f, 0x72,
0x6c, 0x64, 0x4c, 0x61, 0x79, 0x65, 0x72, 0x73, 0xfa, 0x00, 0x7b, 0x22, 0x62, 0x69, 0x6f, 0x6d,
0x65, 0x5f, 0x69, 0x64, 0x22, 0x3a, 0x31, 0x2c, 0x22, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x6c,
0x61, 0x79, 0x65, 0x72, 0x73, 0x22, 0x3a, 0x5b, 0x7b, 0x22, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f,
0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x22, 0x6d, 0x69, 0x6e, 0x65, 0x63, 0x72, 0x61, 0x66, 0x74,
0x3a, 0x62, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x22, 0x2c, 0x22, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x22, 0x3a, 0x31, 0x7d, 0x2c, 0x7b, 0x22, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x6e, 0x61, 0x6d,
0x65, 0x22, 0x3a, 0x22, 0x6d, 0x69, 0x6e, 0x65, 0x63, 0x72, 0x61, 0x66, 0x74, 0x3a, 0x64, 0x69,
0x72, 0x74, 0x22, 0x2c, 0x22, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3a, 0x32, 0x7d, 0x2c, 0x7b,
0x22, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x22, 0x6d, 0x69,
0x6e, 0x65, 0x63, 0x72, 0x61, 0x66, 0x74, 0x3a, 0x67, 0x72, 0x61, 0x73, 0x73, 0x5f, 0x62, 0x6c,
0x6f, 0x63, 0x6b, 0x22, 0x2c, 0x22, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3a, 0x31, 0x7d, 0x5d,
0x2c, 0x22, 0x65, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69,
0x6f, 0x6e, 0x22, 0x3a, 0x36, 0x2c, 0x22, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x75, 0x72, 0x65,
0x5f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x3a, 0x6e, 0x75, 0x6c, 0x6c, 0x2c, 0x22,
0x77, 0x6f, 0x72, 0x6c, 0x64, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x3a, 0x22,
0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x6f, 0x73, 0x74, 0x5f, 0x31, 0x5f, 0x31,
0x38, 0x22, 0x7d, 0x0a, 0x01, 0x0d, 0x00, 0x46, 0x6f, 0x72, 0x63, 0x65, 0x47, 0x61, 0x6d, 0x65,
0x54, 0x79, 0x70, 0x65, 0x00, 0x03, 0x08, 0x00, 0x47, 0x61, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65,
0x01, 0x00, 0x00, 0x00, 0x03, 0x09, 0x00, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72,
0x01, 0x00, 0x00, 0x00, 0x08, 0x10, 0x00, 0x49, 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79,
0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x06, 0x00, 0x31, 0x2e, 0x32, 0x31, 0x2e, 0x32, 0x01,
0x0a, 0x00, 0x49, 0x73, 0x48, 0x61, 0x72, 0x64, 0x63, 0x6f, 0x72, 0x65, 0x00, 0x01, 0x0c, 0x00,
0x4c, 0x41, 0x4e, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x01, 0x01, 0x12, 0x00,
0x4c, 0x41, 0x4e, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x49, 0x6e, 0x74, 0x65,
0x6e, 0x74, 0x01, 0x04, 0x0a, 0x00, 0x4c, 0x61, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x79, 0x65, 0x64,
0x93, 0xcc, 0x04, 0x69, 0x00, 0x00, 0x00, 0x00, 0x08, 0x09, 0x00, 0x4c, 0x65, 0x76, 0x65, 0x6c,
0x4e, 0x61, 0x6d, 0x65, 0x22, 0x00, 0x4b, 0x49, 0x44, 0xe5, 0x8a, 0xa8, 0xe4, 0xbd, 0x9c, 0xe4,
0xbc, 0x98, 0xe5, 0x8c, 0x96, 0x55, 0x4c, 0x54, 0x52, 0x41, 0x5f, 0x58, 0xe5, 0xbc, 0x80, 0xe5,
0x8f, 0x91, 0xe6, 0xb5, 0x8b, 0xe8, 0xaf, 0x95, 0x03, 0x13, 0x00, 0x4c, 0x69, 0x6d, 0x69, 0x74,
0x65, 0x64, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x58, 0x00, 0x00,
0x00, 0x80, 0x03, 0x13, 0x00, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6c,
0x64, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x59, 0x00, 0x00, 0x00, 0x80, 0x03, 0x13, 0x00, 0x4c,
0x69, 0x6d, 0x69, 0x74, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x4f, 0x72, 0x69, 0x67, 0x69,
0x6e, 0x5a, 0x00, 0x00, 0x00, 0x80, 0x09, 0x1e, 0x00, 0x4d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d,
0x43, 0x6f, 0x6d, 0x70, 0x61, 0x74, 0x69, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74,
0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x03, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x0f, 0x00, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x47, 0x61,
0x6d, 0x65, 0x01, 0x01, 0x15, 0x00, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x61, 0x79, 0x65,
0x72, 0x47, 0x61, 0x6d, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x01, 0x03, 0x0b, 0x00, 0x4e,
0x65, 0x74, 0x68, 0x65, 0x72, 0x53, 0x63, 0x61, 0x6c, 0x65, 0x08, 0x00, 0x00, 0x00, 0x03, 0x0e,
0x00, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0xae,
0x02, 0x00, 0x00, 0x03, 0x08, 0x00, 0x50, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x02, 0x00,
0x00, 0x00, 0x03, 0x17, 0x00, 0x50, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x42, 0x72, 0x6f,
0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x49, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x03, 0x00, 0x00, 0x00,
0x04, 0x0a, 0x00, 0x52, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x53, 0x65, 0x65, 0x64, 0xda, 0x83, 0xfb,
0x6a, 0x72, 0x36, 0x25, 0xd5, 0x01, 0x10, 0x00, 0x53, 0x70, 0x61, 0x77, 0x6e, 0x56, 0x31, 0x56,
0x69, 0x6c, 0x6c, 0x61, 0x67, 0x65, 0x72, 0x73, 0x00, 0x03, 0x06, 0x00, 0x53, 0x70, 0x61, 0x77,
0x6e, 0x58, 0x00, 0x00, 0x00, 0x80, 0x03, 0x06, 0x00, 0x53, 0x70, 0x61, 0x77, 0x6e, 0x59, 0x00,
0x00, 0x00, 0x80, 0x03, 0x06, 0x00, 0x53, 0x70, 0x61, 0x77, 0x6e, 0x5a, 0x00, 0x00, 0x00, 0x80,
0x03, 0x0e, 0x00, 0x53, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
0x6e, 0x0a, 0x00, 0x00, 0x00, 0x04, 0x04, 0x00, 0x54, 0x69, 0x6d, 0x65, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x03, 0x0c, 0x00, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x56, 0x65, 0x72, 0x73,
0x69, 0x6f, 0x6e, 0x01, 0x00, 0x00, 0x00, 0x03, 0x12, 0x00, 0x58, 0x42, 0x4c, 0x42, 0x72, 0x6f,
0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x49, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x03, 0x00, 0x00, 0x00,
0x0a, 0x09, 0x00, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x01, 0x0a, 0x00, 0x61,
0x74, 0x74, 0x61, 0x63, 0x6b, 0x6d, 0x6f, 0x62, 0x73, 0x00, 0x01, 0x0d, 0x00, 0x61, 0x74, 0x74,
0x61, 0x63, 0x6b, 0x70, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x00, 0x01, 0x05, 0x00, 0x62, 0x75,
0x69, 0x6c, 0x64, 0x01, 0x01, 0x10, 0x00, 0x64, 0x6f, 0x6f, 0x72, 0x73, 0x61, 0x6e, 0x64, 0x73,
0x77, 0x69, 0x74, 0x63, 0x68, 0x65, 0x73, 0x00, 0x05, 0x08, 0x00, 0x66, 0x6c, 0x79, 0x53, 0x70,
0x65, 0x65, 0x64, 0xcd, 0xcc, 0x4c, 0x3d, 0x01, 0x06, 0x00, 0x66, 0x6c, 0x79, 0x69, 0x6e, 0x67,
0x00, 0x01, 0x0a, 0x00, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x00, 0x01,
0x0c, 0x00, 0x69, 0x6e, 0x76, 0x75, 0x6c, 0x6e, 0x65, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x00, 0x01,
0x09, 0x00, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x6e, 0x69, 0x6e, 0x67, 0x00, 0x01, 0x06, 0x00, 0x6d,
0x61, 0x79, 0x66, 0x6c, 0x79, 0x00, 0x01, 0x04, 0x00, 0x6d, 0x69, 0x6e, 0x65, 0x01, 0x01, 0x02,
0x00, 0x6f, 0x70, 0x00, 0x01, 0x0e, 0x00, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6f, 0x6e, 0x74, 0x61,
0x69, 0x6e, 0x65, 0x72, 0x73, 0x00, 0x01, 0x08, 0x00, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72,
0x74, 0x00, 0x05, 0x09, 0x00, 0x77, 0x61, 0x6c, 0x6b, 0x53, 0x70, 0x65, 0x65, 0x64, 0xcd, 0xcc,
0xcc, 0x3d, 0x00, 0x01, 0x11, 0x00, 0x62, 0x6f, 0x6e, 0x75, 0x73, 0x43, 0x68, 0x65, 0x73, 0x74,
0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x01, 0x01, 0x11, 0x00, 0x62, 0x6f, 0x6e, 0x75, 0x73,
0x43, 0x68, 0x65, 0x73, 0x74, 0x53, 0x70, 0x61, 0x77, 0x6e, 0x65, 0x64, 0x00, 0x01, 0x0d, 0x00,
0x63, 0x68, 0x65, 0x61, 0x74, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x00, 0x01, 0x12,
0x00, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x6f, 0x75, 0x74,
0x70, 0x75, 0x74, 0x01, 0x01, 0x14, 0x00, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x62, 0x6c,
0x6f, 0x63, 0x6b, 0x73, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x01, 0x01, 0x0f, 0x00, 0x63,
0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x01, 0x04,
0x0b, 0x00, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x0d, 0x00, 0x64, 0x61, 0x79, 0x6c, 0x69, 0x67, 0x68, 0x74,
0x43, 0x79, 0x63, 0x6c, 0x65, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0f, 0x00, 0x64, 0x6f, 0x64, 0x61,
0x79, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x01, 0x01, 0x0d, 0x00, 0x64,
0x6f, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x64, 0x72, 0x6f, 0x70, 0x73, 0x01, 0x01, 0x0a, 0x00,
0x64, 0x6f, 0x66, 0x69, 0x72, 0x65, 0x74, 0x69, 0x63, 0x6b, 0x01, 0x01, 0x12, 0x00, 0x64, 0x6f,
0x69, 0x6d, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x74, 0x65, 0x72, 0x65, 0x73, 0x70, 0x61, 0x77, 0x6e,
0x00, 0x01, 0x0a, 0x00, 0x64, 0x6f, 0x69, 0x6e, 0x73, 0x6f, 0x6d, 0x6e, 0x69, 0x61, 0x01, 0x01,
0x11, 0x00, 0x64, 0x6f, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x65, 0x64, 0x63, 0x72, 0x61, 0x66, 0x74,
0x69, 0x6e, 0x67, 0x00, 0x01, 0x09, 0x00, 0x64, 0x6f, 0x6d, 0x6f, 0x62, 0x6c, 0x6f, 0x6f, 0x74,
0x01, 0x01, 0x0d, 0x00, 0x64, 0x6f, 0x6d, 0x6f, 0x62, 0x73, 0x70, 0x61, 0x77, 0x6e, 0x69, 0x6e,
0x67, 0x01, 0x01, 0x0b, 0x00, 0x64, 0x6f, 0x74, 0x69, 0x6c, 0x65, 0x64, 0x72, 0x6f, 0x70, 0x73,
0x01, 0x01, 0x0e, 0x00, 0x64, 0x6f, 0x77, 0x65, 0x61, 0x74, 0x68, 0x65, 0x72, 0x63, 0x79, 0x63,
0x6c, 0x65, 0x01, 0x01, 0x0e, 0x00, 0x64, 0x72, 0x6f, 0x77, 0x6e, 0x69, 0x6e, 0x67, 0x64, 0x61,
0x6d, 0x61, 0x67, 0x65, 0x01, 0x03, 0x0f, 0x00, 0x65, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x57, 0x6f,
0x72, 0x6c, 0x64, 0x54, 0x79, 0x70, 0x65, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x00, 0x65, 0x64,
0x75, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x00, 0x00, 0x00, 0x00, 0x01, 0x18, 0x00, 0x65, 0x64, 0x75,
0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x45, 0x6e,
0x61, 0x62, 0x6c, 0x65, 0x64, 0x00, 0x0a, 0x0b, 0x00, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x6d,
0x65, 0x6e, 0x74, 0x73, 0x01, 0x24, 0x00, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x64, 0x72, 0x69, 0x76,
0x65, 0x6e, 0x5f, 0x76, 0x61, 0x6e, 0x69, 0x6c, 0x6c, 0x61, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b,
0x73, 0x5f, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x01, 0x01, 0x15, 0x00, 0x65,
0x78, 0x70, 0x65, 0x72, 0x69, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x65, 0x76, 0x65, 0x72, 0x5f,
0x75, 0x73, 0x65, 0x64, 0x00, 0x01, 0x1e, 0x00, 0x73, 0x61, 0x76, 0x65, 0x64, 0x5f, 0x77, 0x69,
0x74, 0x68, 0x5f, 0x74, 0x6f, 0x67, 0x67, 0x6c, 0x65, 0x64, 0x5f, 0x65, 0x78, 0x70, 0x65, 0x72,
0x69, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x00, 0x00, 0x01, 0x0a, 0x00, 0x66, 0x61, 0x6c, 0x6c, 0x64,
0x61, 0x6d, 0x61, 0x67, 0x65, 0x01, 0x01, 0x0a, 0x00, 0x66, 0x69, 0x72, 0x65, 0x64, 0x61, 0x6d,
0x61, 0x67, 0x65, 0x01, 0x01, 0x0c, 0x00, 0x66, 0x72, 0x65, 0x65, 0x7a, 0x65, 0x64, 0x61, 0x6d,
0x61, 0x67, 0x65, 0x01, 0x03, 0x14, 0x00, 0x66, 0x75, 0x6e, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x63,
0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x10, 0x27, 0x00, 0x00, 0x01,
0x17, 0x00, 0x68, 0x61, 0x73, 0x42, 0x65, 0x65, 0x6e, 0x4c, 0x6f, 0x61, 0x64, 0x65, 0x64, 0x49,
0x6e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, 0x76, 0x65, 0x01, 0x01, 0x15, 0x00, 0x68, 0x61, 0x73,
0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x42, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x50, 0x61,
0x63, 0x6b, 0x00, 0x01, 0x15, 0x00, 0x68, 0x61, 0x73, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x61, 0x63, 0x6b, 0x00, 0x01, 0x0e, 0x00, 0x69,
0x6d, 0x6d, 0x75, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x00, 0x01, 0x11,
0x00, 0x69, 0x73, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x45, 0x64, 0x69, 0x74,
0x6f, 0x72, 0x00, 0x01, 0x14, 0x00, 0x69, 0x73, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64,
0x46, 0x72, 0x6f, 0x6d, 0x45, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x00, 0x01, 0x14, 0x00, 0x69, 0x73,
0x46, 0x72, 0x6f, 0x6d, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61,
0x74, 0x65, 0x00, 0x01, 0x13, 0x00, 0x69, 0x73, 0x46, 0x72, 0x6f, 0x6d, 0x57, 0x6f, 0x72, 0x6c,
0x64, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x00, 0x01, 0x13, 0x00, 0x69, 0x73, 0x52,
0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x53, 0x65, 0x65, 0x64, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64,
0x00, 0x01, 0x10, 0x00, 0x69, 0x73, 0x53, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x55, 0x73, 0x65, 0x57,
0x6f, 0x72, 0x6c, 0x64, 0x00, 0x01, 0x1b, 0x00, 0x69, 0x73, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x54,
0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x4c, 0x6f, 0x63,
0x6b, 0x65, 0x64, 0x00, 0x01, 0x0d, 0x00, 0x6b, 0x65, 0x65, 0x70, 0x69, 0x6e, 0x76, 0x65, 0x6e,
0x74, 0x6f, 0x72, 0x79, 0x01, 0x09, 0x15, 0x00, 0x6c, 0x61, 0x73, 0x74, 0x4f, 0x70, 0x65, 0x6e,
0x65, 0x64, 0x57, 0x69, 0x74, 0x68, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x03, 0x05, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x0e, 0x00, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x6e, 0x69,
0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x00, 0x00, 0x00, 0x00, 0x03, 0x0d, 0x00, 0x6c, 0x69,
0x67, 0x68, 0x74, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x69, 0x6d, 0x65, 0x00, 0x77, 0x01, 0x00, 0x03,
0x11, 0x00, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x44, 0x65,
0x70, 0x74, 0x68, 0x10, 0x00, 0x00, 0x00, 0x03, 0x11, 0x00, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x65,
0x64, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x57, 0x69, 0x64, 0x74, 0x68, 0x10, 0x00, 0x00, 0x00, 0x03,
0x15, 0x00, 0x6d, 0x61, 0x78, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x63, 0x68, 0x61, 0x69,
0x6e, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0xff, 0xff, 0x00, 0x00, 0x01, 0x0b, 0x00, 0x6d, 0x6f,
0x62, 0x67, 0x72, 0x69, 0x65, 0x66, 0x69, 0x6e, 0x67, 0x01, 0x01, 0x13, 0x00, 0x6e, 0x61, 0x74,
0x75, 0x72, 0x61, 0x6c, 0x72, 0x65, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x01, 0x01, 0x12, 0x00, 0x6e, 0x65, 0x74, 0x65, 0x61, 0x73, 0x65, 0x45, 0x6e, 0x63, 0x72, 0x79,
0x70, 0x74, 0x46, 0x6c, 0x61, 0x67, 0x00, 0x09, 0x1f, 0x00, 0x6e, 0x65, 0x74, 0x65, 0x61, 0x73,
0x65, 0x53, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x68, 0x6f, 0x6c, 0x64, 0x53, 0x65, 0x6c, 0x65, 0x63,
0x74, 0x65, 0x64, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x10,
0x00, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x4c, 0x65, 0x76, 0x65,
0x6c, 0x01, 0x00, 0x00, 0x00, 0x03, 0x16, 0x00, 0x70, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x50, 0x65,
0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x01, 0x00,
0x00, 0x00, 0x03, 0x19, 0x00, 0x70, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x73, 0x6c, 0x65, 0x65,
0x70, 0x69, 0x6e, 0x67, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x61, 0x67, 0x65, 0x64, 0x00,
0x00, 0x00, 0x08, 0x04, 0x00, 0x70, 0x72, 0x69, 0x64, 0x00, 0x00, 0x01, 0x19, 0x00, 0x70, 0x72,
0x6f, 0x6a, 0x65, 0x63, 0x74, 0x69, 0x6c, 0x65, 0x73, 0x63, 0x61, 0x6e, 0x62, 0x72, 0x65, 0x61,
0x6b, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x01, 0x01, 0x03, 0x00, 0x70, 0x76, 0x70, 0x01, 0x05,
0x09, 0x00, 0x72, 0x61, 0x69, 0x6e, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x00, 0x00, 0x00, 0x00, 0x03,
0x08, 0x00, 0x72, 0x61, 0x69, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x80, 0xbb, 0x00, 0x00, 0x03, 0x0f,
0x00, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x74, 0x69, 0x63, 0x6b, 0x73, 0x70, 0x65, 0x65, 0x64,
0x01, 0x00, 0x00, 0x00, 0x01, 0x0d, 0x00, 0x72, 0x65, 0x63, 0x69, 0x70, 0x65, 0x73, 0x75, 0x6e,
0x6c, 0x6f, 0x63, 0x6b, 0x01, 0x01, 0x1e, 0x00, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73,
0x43, 0x6f, 0x70, 0x69, 0x65, 0x64, 0x50, 0x61, 0x63, 0x6b, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x61,
0x6c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x00, 0x01, 0x14, 0x00, 0x72, 0x65, 0x73, 0x70, 0x61, 0x77,
0x6e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x65, 0x78, 0x70, 0x6c, 0x6f, 0x64, 0x65, 0x01, 0x01,
0x13, 0x00, 0x73, 0x65, 0x6e, 0x64, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x66, 0x65, 0x65,
0x64, 0x62, 0x61, 0x63, 0x6b, 0x01, 0x03, 0x14, 0x00, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43,
0x68, 0x75, 0x6e, 0x6b, 0x54, 0x69, 0x63, 0x6b, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x04, 0x00, 0x00,
0x00, 0x01, 0x10, 0x00, 0x73, 0x68, 0x6f, 0x77, 0x62, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x65, 0x66,
0x66, 0x65, 0x63, 0x74, 0x01, 0x01, 0x0f, 0x00, 0x73, 0x68, 0x6f, 0x77, 0x63, 0x6f, 0x6f, 0x72,
0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x00, 0x01, 0x0e, 0x00, 0x73, 0x68, 0x6f, 0x77, 0x64,
0x61, 0x79, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x65, 0x64, 0x00, 0x01, 0x11, 0x00, 0x73, 0x68, 0x6f,
0x77, 0x64, 0x65, 0x61, 0x74, 0x68, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x01, 0x01,
0x12, 0x00, 0x73, 0x68, 0x6f, 0x77, 0x72, 0x65, 0x63, 0x69, 0x70, 0x65, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x01, 0x01, 0x08, 0x00, 0x73, 0x68, 0x6f, 0x77, 0x74, 0x61, 0x67, 0x73,
0x01, 0x01, 0x09, 0x00, 0x73, 0x70, 0x61, 0x77, 0x6e, 0x4d, 0x6f, 0x62, 0x73, 0x00, 0x03, 0x0b,
0x00, 0x73, 0x70, 0x61, 0x77, 0x6e, 0x72, 0x61, 0x64, 0x69, 0x75, 0x73, 0x0a, 0x00, 0x00, 0x00,
0x01, 0x13, 0x00, 0x73, 0x74, 0x61, 0x72, 0x74, 0x57, 0x69, 0x74, 0x68, 0x4d, 0x61, 0x70, 0x45,
0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x00, 0x01, 0x14, 0x00, 0x74, 0x65, 0x78, 0x74, 0x75, 0x72,
0x65, 0x50, 0x61, 0x63, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x00, 0x01,
0x0b, 0x00, 0x74, 0x6e, 0x74, 0x65, 0x78, 0x70, 0x6c, 0x6f, 0x64, 0x65, 0x73, 0x01, 0x01, 0x15,
0x00, 0x74, 0x6e, 0x74, 0x65, 0x78, 0x70, 0x6c, 0x6f, 0x73, 0x69, 0x6f, 0x6e, 0x64, 0x72, 0x6f,
0x70, 0x64, 0x65, 0x63, 0x61, 0x79, 0x00, 0x01, 0x13, 0x00, 0x75, 0x73, 0x65, 0x4d, 0x73, 0x61,
0x47, 0x61, 0x6d, 0x65, 0x72, 0x74, 0x61, 0x67, 0x73, 0x4f, 0x6e, 0x6c, 0x79, 0x00, 0x04, 0x0f,
0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x72, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74,
0xfe, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x0e, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64,
0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x00, 0x00,
];

178
src/debug/log.rs Normal file
View File

@@ -0,0 +1,178 @@
use std::sync::Mutex;
use regex::{Captures, Regex};
#[cfg(windows)]
use windows_sys::Win32::System::Console::{
CONSOLE_SCREEN_BUFFER_INFO, FOREGROUND_BLUE, FOREGROUND_GREEN, FOREGROUND_INTENSITY,
FOREGROUND_RED, GetConsoleScreenBufferInfo, GetStdHandle, STD_OUTPUT_HANDLE,
SetConsoleTextAttribute,
};
static CONSOLE_LOCK: Mutex<()> = Mutex::new(());
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConsoleColor {
Default,
Green,
Red,
Yellow,
Cyan,
DarkGray,
}
pub struct LineBuffer {
buffer: String,
filter_python: bool,
}
impl LineBuffer {
pub fn new(filter_python: bool) -> Self {
Self {
buffer: String::new(),
filter_python,
}
}
pub fn append<F>(&mut self, bytes: &[u8], mut process_line: F)
where
F: FnMut(String),
{
self.buffer.push_str(&String::from_utf8_lossy(bytes));
while let Some(pos) = self.buffer.find('\n') {
let mut line = self.buffer[..pos].to_string();
self.buffer.drain(..=pos);
if line.ends_with('\r') {
line.pop();
}
if self.filter_python && !line.contains("[Python] ") {
continue;
}
process_line(line);
}
}
pub fn flush<F>(&mut self, mut process_line: F)
where
F: FnMut(String),
{
if self.buffer.is_empty() {
return;
}
let mut line = std::mem::take(&mut self.buffer);
if line.ends_with('\r') {
line.pop();
}
if self.filter_python && !line.contains("[Python] ") {
return;
}
process_line(line);
}
}
pub fn process_stdout_line(line: &str) {
if line.contains(" [INFO][Engine] ") {
return;
}
let color = if line.contains("[INFO][Developer]") {
ConsoleColor::DarkGray
} else if contains_ignore_ascii_case(line, "SUC") {
ConsoleColor::Green
} else if contains_ignore_ascii_case(line, "ERROR") {
ConsoleColor::Red
} else if contains_ignore_ascii_case(line, "WARN") {
ConsoleColor::Yellow
} else if contains_ignore_ascii_case(line, "DEBUG") {
ConsoleColor::Cyan
} else {
ConsoleColor::Default
};
print_colored(line, color);
}
pub fn process_stderr_line(line: &str) {
let line = rewrite_python_traceback_path(line);
print_colored(&line, ConsoleColor::Red);
}
pub fn rewrite_python_traceback_path(line: &str) -> String {
let re = Regex::new(r#"File "([A-Za-z0-9_\.]+)", line (\d+)"#).unwrap();
re.replace_all(line, |caps: &Captures<'_>| {
format!(
"File \"{}.py\", line {}",
caps[1].replace('.', "/"),
&caps[2]
)
})
.into_owned()
}
pub fn print_colored(message: &str, color: ConsoleColor) {
let _guard = CONSOLE_LOCK.lock().unwrap();
#[cfg(windows)]
unsafe {
let handle = GetStdHandle(STD_OUTPUT_HANDLE);
if handle.is_null() {
println!("{}", message);
return;
}
let mut info: CONSOLE_SCREEN_BUFFER_INFO = std::mem::zeroed();
let has_info = GetConsoleScreenBufferInfo(handle, &mut info) != 0;
if color != ConsoleColor::Default {
SetConsoleTextAttribute(handle, color_attr(color));
}
println!("{}", message);
if color != ConsoleColor::Default && has_info {
SetConsoleTextAttribute(handle, info.wAttributes);
}
}
#[cfg(not(windows))]
{
println!("{}", message);
}
}
#[cfg(windows)]
fn color_attr(color: ConsoleColor) -> u16 {
match color {
ConsoleColor::Default => FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE,
ConsoleColor::Green => FOREGROUND_GREEN | FOREGROUND_INTENSITY,
ConsoleColor::Red => FOREGROUND_RED | FOREGROUND_INTENSITY,
ConsoleColor::Yellow => FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_INTENSITY,
ConsoleColor::Cyan => FOREGROUND_GREEN | FOREGROUND_BLUE | FOREGROUND_INTENSITY,
ConsoleColor::DarkGray => FOREGROUND_INTENSITY,
}
}
fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
haystack
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
#[cfg(test)]
mod tests {
use super::{LineBuffer, rewrite_python_traceback_path};
#[test]
fn process_buffer_append_handles_crlf_half_lines_and_flush() {
let mut buffer = LineBuffer::new(true);
let mut lines = Vec::new();
buffer.append(b"noise\r\n[Python] first\r\n[Python] sec", |line| {
lines.push(line)
});
assert_eq!(lines, vec!["[Python] first"]);
buffer.append(b"ond\n", |line| lines.push(line));
buffer.flush(|line| lines.push(line));
assert_eq!(lines, vec!["[Python] first", "[Python] second"]);
}
#[test]
fn rewrite_traceback_module_path_to_file_path() {
assert_eq!(
rewrite_python_traceback_path(r#"Traceback File "a.b", line 3"#),
r#"Traceback File "a/b.py", line 3"#
);
}
}

43
src/debug/mod.rs Normal file
View File

@@ -0,0 +1,43 @@
pub mod addon;
pub mod config;
pub mod env;
pub mod hotreload;
pub mod ipc;
pub mod level;
pub mod log;
pub mod nbt;
pub mod process;
pub mod win;
use std::path::Path;
use crate::error::Result;
pub fn run(project_dir: &Path) -> Result<()> {
let mut config = config::load_or_create(project_dir)?;
config::ensure_game_executable(project_dir, &mut config)?;
let mod_dirs = config.included_mod_dirs(project_dir)?;
let mut linked_packs = Vec::new();
if !config::env_is_subprocess_mode() {
env::clean_runtime_packs()?;
if config.include_debug_mod {
let debug_pack = addon::register_debug_mod(&config, &mod_dirs)?;
println!("[MCDK] 已注册调试MOD{}", debug_pack.uuid);
linked_packs.push(debug_pack);
}
addon::link_user_mod_dirs(&mod_dirs, &mut linked_packs)?;
level::prepare_world(&config, &linked_packs)?;
}
let config_arg = if config.auto_join_game_effective() && !config::env_is_subprocess_mode() {
Some(level::write_dev_config(&config)?)
} else {
None
};
process::launch_game(&config, config_arg.as_deref(), &mod_dirs)
}

495
src/debug/nbt.rs Normal file
View File

@@ -0,0 +1,495 @@
use std::{
collections::HashSet,
time::{SystemTime, UNIX_EPOCH},
};
use rand::RngCore;
use crate::error::{CliError, Result};
use super::config::DebugConfig;
#[path = "level_template.rs"]
mod level_template;
const TAG_END: u8 = 0;
const TAG_BYTE: u8 = 1;
const TAG_SHORT: u8 = 2;
const TAG_INT: u8 = 3;
const TAG_LONG: u8 = 4;
const TAG_FLOAT: u8 = 5;
const TAG_DOUBLE: u8 = 6;
const TAG_BYTE_ARRAY: u8 = 7;
const TAG_STRING: u8 = 8;
const TAG_LIST: u8 = 9;
const TAG_COMPOUND: u8 = 10;
const TAG_INT_ARRAY: u8 = 11;
const TAG_LONG_ARRAY: u8 = 12;
#[derive(Clone, Debug, PartialEq)]
pub enum Tag {
Byte(i8),
Short(i16),
Int(i32),
Long(i64),
Float(f32),
Double(f64),
ByteArray(Vec<i8>),
String(String),
List { element_type: u8, items: Vec<Tag> },
Compound(Vec<(String, Tag)>),
IntArray(Vec<i32>),
LongArray(Vec<i64>),
}
#[derive(Clone, Debug, PartialEq)]
pub struct LevelDat {
version: u32,
root_name: String,
root: Tag,
}
impl LevelDat {
pub fn parse(bytes: &[u8]) -> Result<Self> {
if bytes.len() < 8 {
return Err(CliError::InvalidData(
"level.dat is shorter than Bedrock header".to_string(),
));
}
let version = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
let declared_len = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
let payload = &bytes[8..];
if declared_len != payload.len() {
return Err(CliError::InvalidData(format!(
"level.dat payload length mismatch: header={declared_len}, actual={}",
payload.len()
)));
}
let mut reader = Reader {
bytes: payload,
pos: 0,
};
let tag_type = reader.u8()?;
if tag_type != TAG_COMPOUND {
return Err(CliError::InvalidData(
"level.dat root tag is not a compound".to_string(),
));
}
let root_name = reader.string()?;
let root = reader.tag_payload(TAG_COMPOUND)?;
if reader.pos != payload.len() {
return Err(CliError::InvalidData(
"trailing bytes after level.dat root compound".to_string(),
));
}
Ok(Self {
version,
root_name,
root,
})
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
let mut payload = Vec::new();
payload.push(TAG_COMPOUND);
write_string(&mut payload, &self.root_name)?;
write_tag_payload(&mut payload, &self.root)?;
let mut bytes = Vec::with_capacity(8 + payload.len());
bytes.extend_from_slice(&self.version.to_le_bytes());
bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
bytes.extend_from_slice(&payload);
Ok(bytes)
}
pub fn set_root(&mut self, key: &str, value: Tag) -> Result<()> {
let Tag::Compound(items) = &mut self.root else {
return Err(CliError::InvalidData(
"level.dat root is not compound".to_string(),
));
};
set_compound_item(items, key, value);
Ok(())
}
#[cfg(test)]
pub fn root_value(&self, key: &str) -> Option<&Tag> {
let Tag::Compound(items) = &self.root else {
return None;
};
items
.iter()
.find(|(name, _)| name == key)
.map(|(_, tag)| tag)
}
pub fn root_compound_mut(&mut self, key: &str) -> Result<&mut Vec<(String, Tag)>> {
let Tag::Compound(items) = &mut self.root else {
return Err(CliError::InvalidData(
"level.dat root is not compound".to_string(),
));
};
if let Some(index) = items.iter().position(|(name, _)| name == key) {
if !matches!(items[index].1, Tag::Compound(_)) {
items[index].1 = Tag::Compound(Vec::new());
}
let Tag::Compound(child) = &mut items[index].1 else {
unreachable!()
};
return Ok(child);
}
items.push((key.to_string(), Tag::Compound(Vec::new())));
let Tag::Compound(child) = &mut items.last_mut().unwrap().1 else {
unreachable!()
};
Ok(child)
}
}
pub fn create_level_dat(config: &DebugConfig) -> Result<Vec<u8>> {
let mut level = LevelDat::parse(level_template::LEVEL_DAT_TEMPLATE)?;
apply_config(&mut level, config, true)?;
level.to_bytes()
}
pub fn update_level_dat_world_data(
bytes: &[u8],
config: &DebugConfig,
init: bool,
) -> Result<Vec<u8>> {
let mut level = LevelDat::parse(bytes)?;
apply_config(&mut level, config, init)?;
level.to_bytes()
}
pub fn update_level_dat_last_played(bytes: &[u8]) -> Result<Vec<u8>> {
let mut level = LevelDat::parse(bytes)?;
level.set_root("LastPlayed", Tag::Long(now_seconds()))?;
level.to_bytes()
}
fn apply_config(level: &mut LevelDat, config: &DebugConfig, init: bool) -> Result<()> {
level.set_root("LastPlayed", Tag::Long(now_seconds()))?;
level.set_root("LevelName", Tag::String(config.world_name.clone()))?;
if init && config.world_type != 2 {
level.set_root(
"RandomSeed",
Tag::Long(config.world_seed.unwrap_or_else(random_seed)),
)?;
}
level.set_root("GameType", Tag::Int(config.game_mode))?;
if init {
level.set_root("Generator", Tag::Int(config.world_type))?;
}
level.set_root("keepInventory", Tag::Byte(config.keep_inventory as i8))?;
level.set_root("cheatsEnabled", Tag::Byte(config.enable_cheats as i8))?;
level.set_root("doweathercycle", Tag::Byte(config.do_weather_cycle as i8))?;
level.set_root("dodaylightcycle", Tag::Byte(config.do_daylight_cycle as i8))?;
if let Some(experiments) = &config.experiment_options {
let child = level.root_compound_mut("experiments")?;
set_compound_item(
child,
"data_driven_biomes",
Tag::Byte(experiments.data_driven_biomes as i8),
);
set_compound_item(
child,
"data_driven_items",
Tag::Byte(experiments.data_driven_items as i8),
);
set_compound_item(
child,
"experimental_molang_features",
Tag::Byte(experiments.experimental_molang_features as i8),
);
}
Ok(())
}
fn now_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn random_seed() -> i64 {
rand::thread_rng().next_u64() as i64
}
fn set_compound_item(items: &mut Vec<(String, Tag)>, key: &str, value: Tag) {
if let Some((_, slot)) = items.iter_mut().find(|(name, _)| name == key) {
*slot = value;
} else {
items.push((key.to_string(), value));
}
}
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl Reader<'_> {
fn take(&mut self, len: usize) -> Result<&[u8]> {
let end = self
.pos
.checked_add(len)
.ok_or_else(|| CliError::InvalidData("NBT offset overflow".to_string()))?;
if end > self.bytes.len() {
return Err(CliError::InvalidData(
"unexpected end of NBT data".to_string(),
));
}
let out = &self.bytes[self.pos..end];
self.pos = end;
Ok(out)
}
fn u8(&mut self) -> Result<u8> {
Ok(self.take(1)?[0])
}
fn i8(&mut self) -> Result<i8> {
Ok(self.u8()? as i8)
}
fn i16(&mut self) -> Result<i16> {
Ok(i16::from_le_bytes(self.take(2)?.try_into().unwrap()))
}
fn i32(&mut self) -> Result<i32> {
Ok(i32::from_le_bytes(self.take(4)?.try_into().unwrap()))
}
fn i64(&mut self) -> Result<i64> {
Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap()))
}
fn f32(&mut self) -> Result<f32> {
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
}
fn f64(&mut self) -> Result<f64> {
Ok(f64::from_le_bytes(self.take(8)?.try_into().unwrap()))
}
fn string(&mut self) -> Result<String> {
let len = u16::from_le_bytes(self.take(2)?.try_into().unwrap()) as usize;
let bytes = self.take(len)?;
String::from_utf8(bytes.to_vec())
.map_err(|err| CliError::InvalidData(format!("NBT string is not UTF-8: {err}")))
}
fn tag_payload(&mut self, tag_type: u8) -> Result<Tag> {
match tag_type {
TAG_BYTE => Ok(Tag::Byte(self.i8()?)),
TAG_SHORT => Ok(Tag::Short(self.i16()?)),
TAG_INT => Ok(Tag::Int(self.i32()?)),
TAG_LONG => Ok(Tag::Long(self.i64()?)),
TAG_FLOAT => Ok(Tag::Float(self.f32()?)),
TAG_DOUBLE => Ok(Tag::Double(self.f64()?)),
TAG_BYTE_ARRAY => {
let len = checked_len(self.i32()?)?;
let mut values = Vec::with_capacity(len);
for _ in 0..len {
values.push(self.i8()?);
}
Ok(Tag::ByteArray(values))
}
TAG_STRING => Ok(Tag::String(self.string()?)),
TAG_LIST => {
let element_type = self.u8()?;
let len = checked_len(self.i32()?)?;
let mut items = Vec::with_capacity(len);
for _ in 0..len {
items.push(self.tag_payload(element_type)?);
}
Ok(Tag::List {
element_type,
items,
})
}
TAG_COMPOUND => {
let mut items = Vec::new();
loop {
let child_type = self.u8()?;
if child_type == TAG_END {
break;
}
let name = self.string()?;
let value = self.tag_payload(child_type)?;
items.push((name, value));
}
Ok(Tag::Compound(items))
}
TAG_INT_ARRAY => {
let len = checked_len(self.i32()?)?;
let mut values = Vec::with_capacity(len);
for _ in 0..len {
values.push(self.i32()?);
}
Ok(Tag::IntArray(values))
}
TAG_LONG_ARRAY => {
let len = checked_len(self.i32()?)?;
let mut values = Vec::with_capacity(len);
for _ in 0..len {
values.push(self.i64()?);
}
Ok(Tag::LongArray(values))
}
_ => Err(CliError::InvalidData(format!(
"unsupported NBT tag type {tag_type}"
))),
}
}
}
fn checked_len(value: i32) -> Result<usize> {
usize::try_from(value)
.map_err(|_| CliError::InvalidData(format!("negative NBT collection length {value}")))
}
fn write_named_tag(out: &mut Vec<u8>, name: &str, tag: &Tag) -> Result<()> {
out.push(tag_id(tag));
write_string(out, name)?;
write_tag_payload(out, tag)
}
fn write_string(out: &mut Vec<u8>, value: &str) -> Result<()> {
let len = u16::try_from(value.len())
.map_err(|_| CliError::InvalidData("NBT string is too long".to_string()))?;
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(value.as_bytes());
Ok(())
}
fn write_tag_payload(out: &mut Vec<u8>, tag: &Tag) -> Result<()> {
match tag {
Tag::Byte(value) => out.push(*value as u8),
Tag::Short(value) => out.extend_from_slice(&value.to_le_bytes()),
Tag::Int(value) => out.extend_from_slice(&value.to_le_bytes()),
Tag::Long(value) => out.extend_from_slice(&value.to_le_bytes()),
Tag::Float(value) => out.extend_from_slice(&value.to_le_bytes()),
Tag::Double(value) => out.extend_from_slice(&value.to_le_bytes()),
Tag::ByteArray(values) => {
write_len(out, values.len())?;
out.extend(values.iter().map(|value| *value as u8));
}
Tag::String(value) => write_string(out, value)?,
Tag::List {
element_type,
items,
} => {
out.push(*element_type);
write_len(out, items.len())?;
for item in items {
if tag_id(item) != *element_type {
return Err(CliError::InvalidData(
"NBT list contains mixed element types".to_string(),
));
}
write_tag_payload(out, item)?;
}
}
Tag::Compound(items) => {
let mut names = HashSet::with_capacity(items.len());
for (name, value) in items {
if !names.insert(name) {
return Err(CliError::InvalidData(format!(
"duplicate NBT compound key {name}"
)));
}
write_named_tag(out, name, value)?;
}
out.push(TAG_END);
}
Tag::IntArray(values) => {
write_len(out, values.len())?;
for value in values {
out.extend_from_slice(&value.to_le_bytes());
}
}
Tag::LongArray(values) => {
write_len(out, values.len())?;
for value in values {
out.extend_from_slice(&value.to_le_bytes());
}
}
}
Ok(())
}
fn write_len(out: &mut Vec<u8>, len: usize) -> Result<()> {
let len = i32::try_from(len)
.map_err(|_| CliError::InvalidData("NBT collection is too long".to_string()))?;
out.extend_from_slice(&len.to_le_bytes());
Ok(())
}
fn tag_id(tag: &Tag) -> u8 {
match tag {
Tag::Byte(_) => TAG_BYTE,
Tag::Short(_) => TAG_SHORT,
Tag::Int(_) => TAG_INT,
Tag::Long(_) => TAG_LONG,
Tag::Float(_) => TAG_FLOAT,
Tag::Double(_) => TAG_DOUBLE,
Tag::ByteArray(_) => TAG_BYTE_ARRAY,
Tag::String(_) => TAG_STRING,
Tag::List { .. } => TAG_LIST,
Tag::Compound(_) => TAG_COMPOUND,
Tag::IntArray(_) => TAG_INT_ARRAY,
Tag::LongArray(_) => TAG_LONG_ARRAY,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::debug::config::{DebugConfig, NeteaseConfig};
use serde_json::{Map, Value};
fn config() -> DebugConfig {
DebugConfig {
included_mod_dirs: Vec::new(),
world_seed: Some(123),
reset_world: false,
world_name: "UnitWorld".to_string(),
world_folder_name: "UnitWorld".to_string(),
auto_join_game: true,
include_debug_mod: true,
auto_hot_reload_mods: true,
world_type: 1,
game_mode: 1,
enable_cheats: true,
keep_inventory: true,
do_weather_cycle: true,
do_daylight_cycle: true,
game_executable_path: String::new(),
debug_options: Value::Object(Map::new()),
netease_config: NeteaseConfig::default(),
user_name: "developer".to_string(),
skin_info: None,
experiment_options: None,
}
}
#[test]
fn template_round_trips() {
let level = LevelDat::parse(level_template::LEVEL_DAT_TEMPLATE).unwrap();
let bytes = level.to_bytes().unwrap();
let reparsed = LevelDat::parse(&bytes).unwrap();
assert_eq!(level, reparsed);
}
#[test]
fn updates_required_world_fields() {
let bytes = create_level_dat(&config()).unwrap();
let level = LevelDat::parse(&bytes).unwrap();
assert_eq!(
level.root_value("LevelName"),
Some(&Tag::String("UnitWorld".to_string()))
);
assert_eq!(level.root_value("GameType"), Some(&Tag::Int(1)));
assert_eq!(level.root_value("RandomSeed"), Some(&Tag::Long(123)));
assert!(matches!(level.root_value("LastPlayed"), Some(Tag::Long(value)) if *value > 0));
}
}

250
src/debug/process.rs Normal file
View File

@@ -0,0 +1,250 @@
use std::{io, path::Path, thread};
use crate::error::{CliError, Result};
use super::{
config::{DebugConfig, ResolvedModDir},
hotreload::HotReloadTask,
ipc::DebugIpcServer,
log::{self, LineBuffer},
};
#[cfg(windows)]
use std::{ffi::OsStr, os::windows::ffi::OsStrExt, ptr};
#[cfg(windows)]
use windows_sys::Win32::{
Foundation::{
CloseHandle, ERROR_BROKEN_PIPE, GetLastError, HANDLE, HANDLE_FLAG_INHERIT,
SetHandleInformation,
},
Storage::FileSystem::ReadFile,
System::{
Console::{GetStdHandle, STD_INPUT_HANDLE},
Pipes::CreatePipe,
Threading::{
CREATE_UNICODE_ENVIRONMENT, CreateProcessW, INFINITE, PROCESS_INFORMATION,
STARTF_USESTDHANDLES, STARTUPINFOW, WaitForSingleObject,
},
},
};
#[cfg(windows)]
use windows_sys::Win32::Security::SECURITY_ATTRIBUTES;
#[cfg(windows)]
use super::win::WinHandle;
#[cfg(windows)]
pub fn launch_game(
config: &DebugConfig,
config_arg: Option<&Path>,
mod_dirs: &[ResolvedModDir],
) -> Result<()> {
let enable_ipc = config.auto_hot_reload_mods;
let ipc = if enable_ipc {
let server = DebugIpcServer::start()?;
println!("[MCDK] IPC调试服务器已启动端口{}", server.port());
Some(server)
} else {
None
};
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 mut security = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: ptr::null_mut(),
bInheritHandle: 1,
};
let (out_read, out_write) = create_pipe_pair(&mut security)?;
let (err_read, err_write) = create_pipe_pair(&mut security)?;
let mut startup: STARTUPINFOW = unsafe { std::mem::zeroed() };
startup.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdOutput = out_write.raw();
startup.hStdError = err_write.raw();
startup.hStdInput = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
let mut process_info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
let env_ptr = env_block
.as_ref()
.map(|block| block.as_ptr().cast_mut().cast())
.unwrap_or(ptr::null_mut());
let creation_flags = if env_block.is_some() {
CREATE_UNICODE_ENVIRONMENT
} else {
0
};
let ok = unsafe {
CreateProcessW(
ptr::null(),
command_w.as_mut_ptr(),
ptr::null_mut(),
ptr::null_mut(),
1,
creation_flags,
env_ptr,
ptr::null(),
&startup,
&mut process_info,
)
};
if ok == 0 {
let err = io::Error::last_os_error();
return Err(CliError::Io(io::Error::new(
err.kind(),
format!("CreateProcessW failed for command {command:?}: {err}"),
)));
}
drop(out_write);
drop(err_write);
let pid = process_info.dwProcessId;
let stdout_filter = config.include_debug_mod;
let stderr_filter = config.include_debug_mod;
let stdout_thread =
thread::spawn(move || read_pipe(out_read, stdout_filter, log::process_stdout_line));
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
};
unsafe { WaitForSingleObject(process_info.hProcess, INFINITE) };
if let Some(task) = &mut hotreload {
task.safe_exit();
}
if let Some(server) = &ipc {
server.safe_exit();
}
let _ = stdout_thread.join();
let _ = stderr_thread.join();
unsafe {
CloseHandle(process_info.hProcess);
CloseHandle(process_info.hThread);
}
Ok(())
}
#[cfg(not(windows))]
pub fn launch_game(
_config: &DebugConfig,
_config_arg: Option<&Path>,
_mod_dirs: &[ResolvedModDir],
) -> Result<()> {
Err(CliError::InvalidInput(
"debug launch is only supported on Windows".to_string(),
))
}
#[cfg(windows)]
fn build_command(config: &DebugConfig, config_arg: Option<&Path>) -> String {
let mut command = String::new();
command.push('"');
command.push_str(&config.game_executable_path);
command.push('"');
if !config.netease_config.chat_extension {
command.push_str(" chatExtension=false");
}
if let Some(path) = config_arg {
command.push_str(" config=\"");
command.push_str(&path.to_string_lossy().replace('\\', "/"));
command.push('"');
}
command
}
#[cfg(windows)]
fn create_pipe_pair(security: &mut SECURITY_ATTRIBUTES) -> Result<(WinHandle, WinHandle)> {
let mut read: HANDLE = ptr::null_mut();
let mut write: HANDLE = ptr::null_mut();
let ok = unsafe { CreatePipe(&mut read, &mut write, security, 0) };
if ok == 0 {
return Err(CliError::Io(io::Error::last_os_error()));
}
let read = WinHandle::new(read)?;
let write = WinHandle::new(write)?;
let ok = unsafe { SetHandleInformation(read.raw(), HANDLE_FLAG_INHERIT, 0) };
if ok == 0 {
return Err(CliError::Io(io::Error::last_os_error()));
}
Ok((read, write))
}
#[cfg(windows)]
fn read_pipe<F>(pipe: WinHandle, filter_python: bool, process_line: F)
where
F: Fn(&str) + Send + 'static,
{
const BUFSZ: usize = 4096;
let mut line_buffer = LineBuffer::new(filter_python);
let mut buffer = [0u8; BUFSZ];
loop {
let mut bytes_read = 0u32;
let ok = unsafe {
ReadFile(
pipe.raw(),
buffer.as_mut_ptr().cast(),
buffer.len() as u32,
&mut bytes_read,
ptr::null_mut(),
)
};
if ok == 0 {
let err = unsafe { GetLastError() };
if err == ERROR_BROKEN_PIPE {
line_buffer.flush(|line| process_line(&line));
}
break;
}
if bytes_read == 0 {
line_buffer.flush(|line| process_line(&line));
break;
}
line_buffer.append(&buffer[..bytes_read as usize], |line| process_line(&line));
}
}
#[cfg(windows)]
fn build_environment_block(ipc_port: u16) -> Vec<u16> {
let mut pairs: Vec<(Vec<u16>, Vec<u16>)> = std::env::vars_os()
.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(),
));
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let mut block = Vec::new();
for (key, value) in pairs {
block.extend(key);
block.push('=' as u16);
block.extend(value);
block.push(0);
}
block.push(0);
block
}
#[cfg(windows)]
fn wide_null(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(std::iter::once(0)).collect()
}

157
src/debug/win.rs Normal file
View File

@@ -0,0 +1,157 @@
use std::{ffi::OsStr, io, path::Path};
#[cfg(windows)]
use std::{mem, os::windows::ffi::OsStrExt, ptr};
#[cfg(windows)]
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE},
Storage::FileSystem::{
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, OPEN_EXISTING,
},
System::IO::DeviceIoControl,
};
#[cfg(windows)]
const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA0000003;
#[cfg(windows)]
const FSCTL_SET_REPARSE_POINT: u32 = 0x0009_00A4;
#[cfg(windows)]
const GENERIC_WRITE_ACCESS: u32 = 0x4000_0000;
#[cfg(windows)]
#[repr(C)]
struct ReparseDataBufferHeader {
reparse_tag: u32,
reparse_data_length: u16,
reserved: u16,
substitute_name_offset: u16,
substitute_name_length: u16,
print_name_offset: u16,
print_name_length: u16,
}
pub struct WinHandle(#[cfg(windows)] pub HANDLE);
#[cfg(windows)]
unsafe impl Send for WinHandle {}
#[cfg(windows)]
impl WinHandle {
pub fn new(handle: HANDLE) -> io::Result<Self> {
if handle == INVALID_HANDLE_VALUE || handle.is_null() {
Err(io::Error::last_os_error())
} else {
Ok(Self(handle))
}
}
pub fn raw(&self) -> HANDLE {
self.0
}
}
#[cfg(windows)]
impl Drop for WinHandle {
fn drop(&mut self) {
if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE {
unsafe { CloseHandle(self.0) };
self.0 = ptr::null_mut();
}
}
}
#[cfg(windows)]
pub fn wide_null(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(std::iter::once(0)).collect()
}
#[cfg(windows)]
pub fn create_junction(target: &Path, link: &Path) -> io::Result<()> {
std::fs::create_dir_all(link.parent().unwrap_or_else(|| Path::new(".")))?;
if link.exists() {
std::fs::remove_dir_all(link)?;
}
std::fs::create_dir(link)?;
let link_w = wide_null(link.as_os_str());
let handle = unsafe {
CreateFileW(
link_w.as_ptr(),
GENERIC_WRITE_ACCESS,
0,
ptr::null_mut(),
OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
ptr::null_mut(),
)
};
let handle = WinHandle::new(handle)?;
let real = std::fs::canonicalize(target)?;
let real_full_w: Vec<u16> = real.as_os_str().encode_wide().collect();
// Mount point reparse data uses `\??\` for SubstituteName and the
// plain DOS path for PrintName; `canonicalize` returns `\\?\...`.
const VERBATIM_PREFIX: [u16; 4] = [b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16];
let real_w = if real_full_w.starts_with(&VERBATIM_PREFIX) {
&real_full_w[VERBATIM_PREFIX.len()..]
} else {
real_full_w.as_slice()
};
const SUBSTITUTE_PREFIX: [u16; 4] = [b'\\' as u16, b'?' as u16, b'?' as u16, b'\\' as u16];
let mut substitute_w = SUBSTITUTE_PREFIX.to_vec();
substitute_w.extend_from_slice(real_w);
let subst_len = substitute_w.len() * mem::size_of::<u16>();
let print_len = real_w.len() * mem::size_of::<u16>();
let header_len = mem::size_of::<ReparseDataBufferHeader>();
let total_len = header_len + subst_len + 2 + print_len + 2;
let mut buffer = vec![0u8; total_len];
unsafe {
let header = buffer.as_mut_ptr() as *mut ReparseDataBufferHeader;
(*header).reparse_tag = IO_REPARSE_TAG_MOUNT_POINT;
(*header).reparse_data_length = (total_len - 8) as u16;
(*header).reserved = 0;
(*header).substitute_name_offset = 0;
(*header).substitute_name_length = subst_len as u16;
(*header).print_name_offset = (subst_len + 2) as u16;
(*header).print_name_length = print_len as u16;
let path_buffer = buffer.as_mut_ptr().add(header_len) as *mut u16;
ptr::copy_nonoverlapping(substitute_w.as_ptr(), path_buffer, substitute_w.len());
ptr::copy_nonoverlapping(
real_w.as_ptr(),
path_buffer.add(substitute_w.len() + 1),
real_w.len(),
);
}
let mut bytes_returned = 0u32;
let ok = unsafe {
DeviceIoControl(
handle.raw(),
FSCTL_SET_REPARSE_POINT,
buffer.as_mut_ptr().cast(),
buffer.len() as u32,
ptr::null_mut(),
0,
&mut bytes_returned,
ptr::null_mut(),
)
};
if ok == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(not(windows))]
pub fn create_junction(_target: &Path, _link: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"debug launch is only supported on Windows",
))
}

View File

@@ -1,4 +1,5 @@
mod commands;
mod debug;
mod entity;
mod error;
mod template;
@@ -14,5 +15,6 @@ fn main() {
Commands::Create(args) => commands::create::execute(args),
Commands::Init(args) => commands::init::execute(args),
Commands::Components(args) => commands::components::execute(args),
Commands::Debug(args) => commands::debug::execute(args),
}
}