Compare commits

...

12 Commits

Author SHA1 Message Date
410b2e64e9 feat(bbmodel): 支持转换 Blockbench 方块模型
新增 bbmodel 子命令,将 .bbmodel 转成网易 netease:block_geometry,并支持独立输出目录。

同时导出内嵌 PNG 贴图并在资源包模式下更新 terrain_texture.json,减少手工导入方块模型和材质的重复操作。
2026-05-22 01:37:42 +08:00
37cd8cc160 docs: 重写 README,补全全部子命令用法文档
原文档仅列出 create/release 两个命令且标注 WIP,
现覆盖 create/init/debug/release/components 五个子命令,
补充参数表、用法示例、项目结构和开发指南。
2026-05-16 22:16:59 +08:00
fd79a99477 feat(debug): 支持 --new 参数创建全新调试存档
添加  选项,启动调试时自动生成带时间戳的新存档目录
(格式 MC_DEV_WORLD_YYYYMMDD_HHMMSS)并持久化到 .mcdev.json。

- 新增 local_timestamp_compact() 跨平台本地时间格式化
- 新增 allocate_new_world() 更新配置并落盘
- 补充单元测试验证存档名格式与持久化正确性
2026-05-16 17:24:13 +08:00
011b59c948 feat(debug): 支持网易 Minecraft 调试启动
新增 debug 子命令,自动准备开发世界、注册内置调试 MOD,并将项目行为包和资源包链接到网易运行目录,方便启动游戏后直接进入调试世界。

调试 MOD 资源随仓库一起嵌入,避免依赖本机绝对路径;Windows junction 写入剥离 verbatim 前缀后的 DOS 路径,保证 Minecraft 能正确读取链接包。
2026-05-16 00:59:51 +08:00
de2b804aad feat(init): 支持补齐内置模板空目录
新增 init 子命令,根据项目中的 world_*_packs.json 解析实际包目录并创建标准空目录。

改用 .empty-dirs 维护内置模板空目录清单,删除会污染用户项目和网易 Bedrock 加载流程的 .gitkeep 占位文件。
2026-05-14 22:16:18 +08:00
55e92c4b4f fix(release): 保留空目录到 ZIP 产物
移除 count_files 和 dir_has_included_files 两个预检函数,不再在打包时
过滤空目录。所有遍历到的子目录(包括空目录和仅含 .gitkeep 的目录)
现在都会写入 ZIP 条目,确保项目目录结构完整保留。

新增三个测试覆盖:空子目录、仅 .gitkeep 目录、emod-package 匹配的
空目录。
2026-05-10 02:08:01 +08:00
02e72fc9d8 style: 应用 rustfmt 格式化
整理 components、commands、entity 和 utils 中的 import 顺序、尾逗号、换行与文件末尾换行。

这保持代码风格与 rustfmt 输出一致,减少后续功能提交里的格式噪音。
2026-05-09 22:02:15 +08:00
405cdaab81 feat(release): 支持自定义打包产物目录
release 现在会在设置 EMOD_ARTIFACTS_DIR 时将 ZIP 输出到按项目名隔离的 artifacts 子目录,未设置时保持写入项目目录。

新增单元测试覆盖默认输出和环境变量输出两条路径,避免发布产物污染源项目或路径回退失效。
2026-05-09 22:01:47 +08:00
933aa295b2 fix(release): zip 文件名带项目名前缀
发布包现在从 canonicalize 后的项目目录提取项目名,并生成 <项目名>_release_<版本>.zip。这样在项目根目录直接执行 release 时也能得到稳定、可区分的产物名称。
2026-05-09 17:13:29 +08:00
85aa369793 feat(release): 增强打包流程, 支持 --pin、.emod-ignore、.emod-package
- 新增 --pin/-P 参数: 保留当前版本号不打补丁, 用于失败后重试发布
- 新增 .emod-ignore: gitignore 风格的打包排除规则, 支持通配符和取反
- 新增 .emod-package: 自定义打包包含规则, 支持通配符匹配
- 添加 preflight_pack_dirs 预检: 在打包前验证行为包/资源包目录和清单文件
- 引入 PackDirs 结构体, 消除重复的目录拼接逻辑
- 修复 ZIP 条目路径在 Windows 使用反斜杠导致网易审核工具报错
- 新增 zip_entry_path 统一使用正斜杠
- 添加 10 个单元测试覆盖核心场景
2026-05-04 01:31:44 +08:00
2af7d3fc2f feat(utils): 添加 io_error 辅助函数用于 IO 错误上下文包装
将匿名 os error 包装为包含操作描述和路径的友好错误信息,
避免出现无法定位问题来源的原始系统错误。
2026-05-04 01:31:27 +08:00
11576a8693 Fix built-in template rename order 2026-04-26 20:48:26 +08:00
83 changed files with 6639 additions and 1736 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.idea/ .idea/
.claude/ .claude/
.qoder/ .qoder/
.mcdev.json

1352
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,13 +9,26 @@ edition = "2024"
[dependencies] [dependencies]
clap = { version = "4.5.32", features = ["derive"] } clap = { version = "4.5.32", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] } uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
base64 = "0.22"
toml = "0.8" toml = "0.8"
zip = "2.2.2" zip = "2.2.2"
walkdir = "2" walkdir = "2"
anyhow = "1.0.97"
dirs = "5.0"
regex = "1.10" regex = "1.10"
rand = "0.8"
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Console",
"Win32_System_Environment",
"Win32_System_IO",
"Win32_System_Pipes",
"Win32_System_SystemServices",
"Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_UI_WindowsAndMessaging",
] }

119
README.md
View File

@@ -1,21 +1,114 @@
# emod-cli # emod-cli
> 🚧 WIP 请勿使用,项目处于快速原型开发实验中 网易我的世界 Bedrock 组件开发 CLI 工具,用于项目创建、初始化、调试与打包发布
## 介绍 ## 安装
基于网易我的世界组件开发的 cli 工具,用于管理组件的创建、打包等
## 如何使用
```bash ```bash
# 创建一个 Addon 项目 cargo build --release
emod-cli create --name <项目名> --target [目标例子] # 产物位于 target/release/emod-cli
# 打包一个 Addon 项目
emod-cli release --path <项目路径> --version [发布版本]
``` ```
## 未来计划 ## 命令
- [ ] 重构代码,使代码结构更加合理 ### create
- [ ] 更新预设命令,可一行创建预设资源
从内置模板创建新的 Addon 项目。
```bash
emod-cli create --name <项目名>
emod-cli create --name <项目名> --target <模板名>
```
| 参数 | 说明 |
|------|------|
| `-n, --name` | 项目名(必填) |
| `-t, --target` | 模板名,默认 `default` |
### init
为已有项目补齐模板中的空目录(基于 `.empty-dirs` 清单)。
```bash
emod-cli init
emod-cli init --path <项目路径> --target <模板名>
```
### debug
启动网易 MC 并加载调试 MOD支持 IPC 日志和热重载。
```bash
emod-cli debug
emod-cli debug --path <项目路径>
emod-cli debug --new # 创建全新调试存档
```
| 参数 | 说明 |
|------|------|
| `-p, --path` | 项目路径,默认当前目录 |
| `-n, --new` | 创建带时间戳的新存档并持久化到 `.mcdev.json` |
调试流程:
1. 读取/生成 `.mcdev.json` 配置
2. 清理运行时旧包链接
3. 注册调试 MOD 并链接用户 MOD 目录
4. 准备开发世界(含自动加入游戏配置)
5. 启动游戏进程,挂载 IPC 日志与热重载
### release
打包 Addon 为可发布的 ZIP 产物。
```bash
emod-cli release
emod-cli release --path <项目路径>
emod-cli release --ver 1.2.0
emod-cli release --pin
```
| 参数 | 说明 |
|------|------|
| `-p, --path` | 项目路径,默认当前目录 |
| `-v, --ver` | 指定发布版本(与 `--pin` 互斥) |
| `-P, --pin` | 锁定当前版本不变,适用于重试失败打包 |
支持通过环境变量 `EMOD_ARTIFACTS_DIR` 指定产物输出目录。
支持 `.emod-ignore``.emod-package` 自定义打包规则。
### components
创建组件资源(如 3D 物品模型)。
```bash
emod-cli components --component 3ditem
emod-cli components --component 3ditem --geo ./model.geo.json --texture ./texture.png
```
| 参数 | 说明 |
|------|------|
| `-p, --path` | 项目路径 |
| `-c, --component` | 组件类型,当前支持 `3ditem` |
| `-g, --geo` | geo 文件路径 |
| `-t, --texture` | 贴图文件路径 |
| `-i, --identifier` | 组件标识符 |
## 项目结构
```
<项目根目录>/
├── behavior_pack/ # 行为包
├── resource_pack/ # 资源包
├── template.toml # 项目模板配置
├── .mcdev.json # 调试配置debug 自动生成)
├── .emod-ignore # 打包排除规则(可选)
└── .emod-package # 打包包含规则(可选)
```
## 开发
```bash
cargo build
cargo test
cargo fmt --check
```

189
build.rs Normal file
View File

@@ -0,0 +1,189 @@
use std::{
collections::BTreeMap,
env, fs, io,
path::{Path, PathBuf},
};
const EMPTY_DIRS_FILE: &str = ".empty-dirs";
const DEBUG_MOD_ROOT: &str = "debug_mod";
fn main() {
println!("cargo:rerun-if-changed=examples");
println!("cargo:rerun-if-changed={}", DEBUG_MOD_ROOT);
let examples_root = PathBuf::from("examples");
let debug_mod_root = PathBuf::from(DEBUG_MOD_ROOT);
if !debug_mod_root.is_dir() {
panic!(
"required debug MOD resource directory was not found: {}",
DEBUG_MOD_ROOT
);
}
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is not set"));
let out_file = out_dir.join("embedded_examples.rs");
let mut dirs = Vec::new();
let mut files = Vec::new();
let mut empty_dirs_by_example = BTreeMap::new();
let mut debug_mod_files = Vec::new();
if examples_root.is_dir() {
collect_examples(
&examples_root,
&examples_root,
&mut dirs,
&mut files,
&mut empty_dirs_by_example,
)
.expect("failed to collect example templates");
}
collect_files(&debug_mod_root, &debug_mod_root, &mut debug_mod_files)
.expect("failed to collect debug MOD resources");
dirs.sort();
dirs.dedup();
files.sort_by(|a, b| a.0.cmp(&b.0));
for empty_dirs in empty_dirs_by_example.values_mut() {
empty_dirs.sort();
empty_dirs.dedup();
}
debug_mod_files.sort_by(|a, b| a.0.cmp(&b.0));
let mut generated = String::new();
generated.push_str(
"#[allow(dead_code)]\nstruct EmbeddedFile {\n path: &'static str,\n contents: &'static [u8],\n}\n\n",
);
generated.push_str("#[allow(dead_code)]\nstatic EMBEDDED_EXAMPLE_DIRS: &[&str] = &[\n");
for dir in &dirs {
generated.push_str(&format!(" {:?},\n", dir));
}
generated.push_str("];\n\n");
generated
.push_str("#[allow(dead_code)]\nstatic EMBEDDED_EXAMPLE_FILES: &[EmbeddedFile] = &[\n");
for (relative_path, absolute_path) in &files {
generated.push_str(&format!(
" EmbeddedFile {{ path: {:?}, contents: include_bytes!(r#\"{}\"#) }},\n",
relative_path,
absolute_path.display()
));
}
generated.push_str("];\n\n");
generated.push_str(
"#[allow(dead_code)]\nstruct EmbeddedExampleEmptyDirs {\n example: &'static str,\n dirs: &'static [&'static str],\n}\n\n",
);
generated.push_str(
"#[allow(dead_code)]\nstatic EMBEDDED_EXAMPLE_EMPTY_DIRS: &[EmbeddedExampleEmptyDirs] = &[\n",
);
for (example, empty_dirs) in &empty_dirs_by_example {
generated.push_str(&format!(
" EmbeddedExampleEmptyDirs {{ example: {:?}, dirs: &[\n",
example
));
for dir in empty_dirs {
generated.push_str(&format!(" {:?},\n", dir));
}
generated.push_str(" ] },\n");
}
generated.push_str("];\n\n");
generated
.push_str("#[allow(dead_code)]\nstatic EMBEDDED_DEBUG_MOD_FILES: &[EmbeddedFile] = &[\n");
for (relative_path, absolute_path) in &debug_mod_files {
generated.push_str(&format!(
" EmbeddedFile {{ path: {:?}, contents: include_bytes!(r#\"{}\"#) }},\n",
relative_path,
absolute_path.display()
));
}
generated.push_str("];\n");
fs::write(out_file, generated).expect("failed to write embedded resource list");
}
fn collect_files(root: &Path, dir: &Path, files: &mut Vec<(String, PathBuf)>) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_files(root, &path, files)?;
} else if path.is_file() {
let relative_path = path
.strip_prefix(root)
.expect("embedded resource path is outside root")
.to_string_lossy()
.replace('\\', "/");
files.push((relative_path, path.canonicalize()?));
}
}
Ok(())
}
fn collect_examples(
root: &Path,
dir: &Path,
dirs: &mut Vec<String>,
files: &mut Vec<(String, PathBuf)>,
empty_dirs_by_example: &mut BTreeMap<String, Vec<String>>,
) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let relative_path = path
.strip_prefix(root)
.expect("example path is outside examples root")
.to_string_lossy()
.replace('\\', "/");
if path.is_dir() {
dirs.push(relative_path);
collect_examples(root, &path, dirs, files, empty_dirs_by_example)?;
} else if path.is_file() {
if path.file_name().and_then(|name| name.to_str()) == Some(EMPTY_DIRS_FILE) {
collect_empty_dirs(&relative_path, &path, dirs, empty_dirs_by_example)?;
} else {
files.push((relative_path, path.canonicalize()?));
}
}
}
Ok(())
}
fn collect_empty_dirs(
relative_path: &str,
path: &Path,
dirs: &mut Vec<String>,
empty_dirs_by_example: &mut BTreeMap<String, Vec<String>>,
) -> io::Result<()> {
let (example, file_name) = relative_path.split_once('/').ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{} must be inside an example directory", EMPTY_DIRS_FILE),
)
})?;
if file_name != EMPTY_DIRS_FILE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected {} path: {}", EMPTY_DIRS_FILE, relative_path),
));
}
let contents = fs::read_to_string(path)?;
let empty_dirs = empty_dirs_by_example
.entry(example.to_string())
.or_insert_with(Vec::new);
for line in contents.lines() {
let dir = line.trim();
if dir.is_empty() || dir.starts_with('#') {
continue;
}
dirs.push(format!("{}/{}", example, dir));
empty_dirs.push(dir.to_string());
}
Ok(())
}

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from json import loads
_DEBUG_INFO = "{#debug_options}"
_TARGET_MOD_DIRS = "{#target_mod_dirs}"
try:
DEBUG_CONFIG = loads(_DEBUG_INFO) if not isinstance(_DEBUG_INFO, dict) else _DEBUG_INFO
except:
DEBUG_CONFIG = {}
try:
TARGET_MOD_DIRS = loads(_TARGET_MOD_DIRS) if not isinstance(_TARGET_MOD_DIRS, list) else _TARGET_MOD_DIRS
except:
TARGET_MOD_DIRS = []
def GET_DEBUG_IPC_PORT():
import os
port = os.getenv("MCDEV_DEBUG_IPC_PORT")
if port is None:
return None
return int(port)

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
from common.utils import xupdate
import mod.client.extraClientApi as clientApi
from .Config import TARGET_MOD_DIRS
def _RELOAD_MOD():
state = False
for rootModDir in TARGET_MOD_DIRS:
try:
if xupdate.updata_all(rootModDir):
state = True
except Exception:
import traceback
traceback.print_exc()
return state
def INIT_RELOAD_TIME():
return xupdate.set_load_time()
def SEND_CLIENT_MSG(msg):
import gui
print(msg)
gui.set_left_corner_notify_msg(msg)
def RELOAD_MOD():
import gui
msg = "[Dev] Scripts reloaded successfully."
if not _RELOAD_MOD():
msg = "[Dev] No script updates found."
SEND_CLIENT_MSG(msg)
def RELOAD_ONCE_MODULE(moduleName):
return xupdate.update(moduleName)
def RELOAD_ADDON():
import gui
import clientlevel
clientlevel.refresh_addons()
SEND_CLIENT_MSG("[Dev] Add-ons reloaded successfully.")
def RELOAD_WORLD():
import clientlevel
clientlevel.restart_local_game()
def RELOAD_SHADERS():
import gui
if clientApi.ReloadAllShaders():
SEND_CLIENT_MSG("[Dev] Shaders reloaded successfully.")
return
SEND_CLIENT_MSG("[Dev] No shader updates found.")

View File

@@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
import mod.server.extraServerApi as serverApi
import mod.client.extraClientApi as clientApi
from .Config import GET_DEBUG_IPC_PORT
import socket
import threading
import json
def U16_BE(b):
# type: (bytearray | str) -> int
if isinstance(b, bytearray):
return (b[0] << 8) | b[1]
return (ord(b[0]) << 8) | ord(b[1])
def U32_BE(b):
# type: (bytearray | str) -> int
if isinstance(b, bytearray):
return (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]
return (ord(b[0]) << 24) | (ord(b[1]) << 16) | (ord(b[2]) << 8) | ord(b[3])
class IPCSystem:
def __init__(self, port=None):
# type: (int | None) -> None
self.port = port
self.sock = None
self.mLock = threading.Lock()
self.handers = {}
def registerHandler(self, typeID, handler):
# type: (int, callable) -> None
self.handers[typeID] = handler
def updateHandlers(self, handlers):
# type: (dict[int, callable]) -> None
self.handers.update(handlers)
def start(self):
if self.sock or not self.port:
return
threading.Thread(target=self._threadListenLoop).start()
def close(self):
sock = None
with self.mLock:
sock = self.sock
self.sock = None
if sock:
sock.shutdown(socket.SHUT_RDWR)
sock.close()
def _threadListenLoop(self):
with self.mLock:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock = self.sock
sock.connect(("localhost", self.port))
sock.settimeout(0.05)
print("[IPCSystem] 已连接到调试服务器,端口:" + str(self.port))
# [2B TypeID][4B DataLength][Data]
def _recvAll(sock, length):
# type: (socket.socket, int) -> bytearray
buf = bytearray()
while len(buf) < length:
more = sock.recv(length - len(buf))
if not more:
raise EOFError("Socket closed before receiving all data")
buf.extend(more)
return buf
while 1:
try:
header = _recvAll(sock, 6)
typeID = U16_BE(header[0:2])
dataLength = U32_BE(header[2:6])
data = _recvAll(sock, dataLength)
except socket.timeout:
continue
except EOFError:
break
except socket.error:
break
except Exception:
import traceback
traceback.print_exc()
break
if typeID in self.handers:
try:
self.handers[typeID](data)
except Exception:
import traceback
traceback.print_exc()
else:
print("[IPCSystem] 未知的TypeID数据包" + str(typeID))
with self.mLock:
self.sock = None
print("[IPCSystem] 连接已关闭")
_CL_GAME_COMP = None
_SR_GAME_COMP = None
def AUTO_RELOAD(_=None):
from .Game import RELOAD_MOD
if _CL_GAME_COMP:
_CL_GAME_COMP.AddTimer(0, lambda: RELOAD_MOD())
return
def FAST_RELOAD(data):
from .Game import RELOAD_ONCE_MODULE
pathList = json.loads(str(data))
def _FAST_RELOAD():
for path in pathList:
if RELOAD_ONCE_MODULE(path):
print("[FAST_RELOAD] Reloaded module successfully: \"" + path + "\"")
if _CL_GAME_COMP:
_CL_GAME_COMP.AddTimer(0, _FAST_RELOAD)
return
def EXEC_CLIENT_CODE(data):
code = compile(str(data), "<string>", "exec")
def _EXEC_CODE():
print("[CLIENT_CODE] Executed successfully: " + str(eval(code)))
_CL_GAME_COMP.AddTimer(0, _EXEC_CODE)
def EXEC_SERVER_CODE(data):
code = compile(str(data), "<string>", "exec")
def _EXEC_CODE():
print("[SERVER_CODE] Executed successfully: " + str(eval(code)))
_SR_GAME_COMP.AddTimer(0, _EXEC_CODE)
def RELOAD_GAME(_=None):
def _RELOAD_GAME():
from .Game import RELOAD_WORLD
print("[RELOAD_GAME] Reloading the game...")
RELOAD_WORLD()
_CL_GAME_COMP.AddTimer(0, _RELOAD_GAME)
def RELOAD_SHADERS(_=None):
def _RELOAD_SHADERS():
from .Game import RELOAD_SHADERS
RELOAD_SHADERS()
_CL_GAME_COMP.AddTimer(0, _RELOAD_SHADERS)
def RELOAD_ONCE_SHADERS(fileName):
def _RELOAD_ONCE_SHADERS():
if clientApi.ReloadOneShader(str(fileName)):
print("[RELOAD_ONCE_SHADERS] Reloaded shaders successfully.")
return
print("[RELOAD_ONCE_SHADERS] Failed to reload shaders.")
_CL_GAME_COMP.AddTimer(0, _RELOAD_ONCE_SHADERS)
def RELOAD_ADDON_AND_GAME(_=None):
def _RELOAD_ADDON_AND_GAME():
from .Game import RELOAD_WORLD, RELOAD_ADDON
print("[RELOAD_ADDON_AND_GAME] Reloading the addon and the game...")
RELOAD_ADDON()
RELOAD_WORLD()
_CL_GAME_COMP.AddTimer(0, _RELOAD_ADDON_AND_GAME)
_IPCSYSTEM = IPCSystem(GET_DEBUG_IPC_PORT())
_IPCSYSTEM.updateHandlers(
{
1: AUTO_RELOAD,
2: FAST_RELOAD,
3: EXEC_CLIENT_CODE,
4: EXEC_SERVER_CODE,
5: RELOAD_GAME,
6: RELOAD_SHADERS,
7: RELOAD_ONCE_SHADERS,
8: RELOAD_ADDON_AND_GAME,
}
)
def ON_CLIENT_INIT():
global _CL_GAME_COMP
_CL_GAME_COMP = clientApi.GetEngineCompFactory().CreateGame(clientApi.GetLevelId())
_IPCSYSTEM.start()
def ON_CLIENT_EXIT():
_IPCSYSTEM.close()
def ON_SERVER_INIT():
global _SR_GAME_COMP
_SR_GAME_COMP = serverApi.GetEngineCompFactory().CreateGame(serverApi.GetLevelId())

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from .Util import SystemSide
lambda: "By Zero123"
IsServerUser = False
ModDirName = SystemSide.__module__.split(".")[0]
QuModLibsPath = SystemSide.__module__[:SystemSide.__module__.rfind(".")]
class RuntimeService:
_serverSystemList = []
_clientSystemList = []
_serverStarting = False
_clientStarting = False
# LOADER SYSTEM
_serverLoadBefore = [] # type: list[function]
_clientLoadBefore = [] # type: list[function]
_serverLoadFinish = [] # type: list[function]
_clientLoadFinish = [] # type: list[function]
# THREAD ID
_serverThreadID = None
_clientThreadID = None
_envPlayerId = None
def getUnderlineModDirName():
# type: () -> str
""" 获取下划线MOD目录名称 返回结果与preset内置变量__LQuModName__一致 (仅支持ascii字符串) """
newStr = [] # type: list[int]
for i, _charStr in enumerate(ModDirName):
_char = ord(_charStr)
if (_char >= 65 and _char <= 90):
# 大写内容 进行处理
if i > 0:
newStr.append(ord("_"))
newStr.append(_char + (97 - 65))
continue
# 常规小写内容 直接追加
newStr.append(_char)
return "".join((chr(x) for x in newStr))
def GET_THREAD_ID():
""" 获取当前线程ID """
from threading import current_thread
return current_thread().ident
def IS_SERVER_THREAD():
""" 检查是不是服务端线程 """
return RuntimeService._serverThreadID != None and GET_THREAD_ID() == RuntimeService._serverThreadID
def IS_CLIENT_THREAD():
""" 检查是不是客户端线程 """
return RuntimeService._clientThreadID != None and GET_THREAD_ID() == RuntimeService._clientThreadID
def GET_THREAD_TYPE():
""" 获取线程类型 -1.主线程 0.服务端线程 1.客户端线程 """
tid = GET_THREAD_ID()
if tid == RuntimeService._serverThreadID:
return 0
elif tid == RuntimeService._clientThreadID:
return 1
return -1

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
Version = "1.4.1" # 版本信息
ApiVersion = 4 # API版本
Author = "Zero123" # 创作者
ContactInformation = "QQ:913702423" # 联系方式
Other = """
# QuModLibs By Zero123(网易:游趣开发组) 别名:一灵 | h2v-wither123... BilBil-UID:456549011
# 开源协议: BSD(3)
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
"""

View File

@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
from mod.common.mod import Mod
import mod.server.extraServerApi as serverApi
import mod.client.extraClientApi as clientApi
from .Util import TRY_EXEC_FUN
from . import IN
class _TempData:
""" modMain临时数据储存 包含了初始化注册的一些信息 """
_nativePyServer = []
_nativePyClient = []
_serverInitCall = []
_clientInitCall = []
_threadAnalysis = False
@Mod.Binding(name = "QuMod_"+IN.ModDirName, version = "1.0.0")
class QMain(object):
""" QuMod MAIN入口逻辑 """
def __init__(self):
pass
@Mod.InitServer()
def serverInit(self):
# 服务端初始化
IN.IsServerUser = True
self._regNativePyServer()
self._loadServerInitFuncs()
if _TempData._threadAnalysis:
from threading import current_thread
IN.RuntimeService._serverThreadID = current_thread().ident
if IN.RuntimeService._serverSystemList or IN.RuntimeService._serverLoadBefore:
from .Systems.Loader.Server import LoaderSystem
LoaderSystem.getSystem() # 初始化服务端加载器
@Mod.InitClient()
def clientInit(self):
IN.RuntimeService._envPlayerId = clientApi.GetLocalPlayerId()
self._regNativePyClient()
self._loadClientInitFuncs()
if _TempData._threadAnalysis:
from threading import current_thread
IN.RuntimeService._clientThreadID = current_thread().ident
if IN.RuntimeService._clientSystemList or IN.RuntimeService._clientLoadBefore:
from .Systems.Loader.Client import LoaderSystem
LoaderSystem.getSystem() # 初始化客户端加载器
def _regNativePyClient(self):
""" 加载原版Python客户端系统注册 """
for args in _TempData._nativePyClient:
clientApi.RegisterSystem(*args)
_TempData._nativePyClient = []
def _regNativePyServer(self):
""" 加载原版Python服务端系统注册 """
for args in _TempData._nativePyServer:
serverApi.RegisterSystem(*args)
_TempData._nativePyServer = []
def _loadServerInitFuncs(self):
""" 加载服务端初始化函数 """
for funObj in _TempData._serverInitCall:
TRY_EXEC_FUN(funObj)
_TempData._serverInitCall = []
def _loadClientInitFuncs(self):
""" 加载客户端初始化函数 """
for funObj in _TempData._clientInitCall:
TRY_EXEC_FUN(funObj)
_TempData._clientInitCall = []
@Mod.DestroyServer()
def serverDestroy(self):
pass
@Mod.DestroyClient()
def clientDestroy(self):
pass
class EasyMod:
""" 简易Mod构造器 """
def __init__(self, modDirName=None):
# type: (str | None) -> None
self._modDirName = modDirName if modDirName else IN.ModDirName
""" Mod目录名 """
def regServer(self, relPath="", systemName=None):
# type: (str, str | None) -> EasyMod
""" 注册服务端(相对目录) """
REG_SERVER_MODULE("{}.{}".format(self._modDirName, relPath), systemName)
return self
def Server(self, relPath="", systemName=None):
# type: (str, str | None) -> EasyMod
""" 便捷式服务端注册 """
return self.regServer(relPath, systemName)
def Client(self, relPath="", systemName=None):
# type: (str, str | None) -> EasyMod
""" 便捷式客户端注册 """
return self.regClient(relPath, systemName)
def regClient(self, relPath="", systemName=None):
# type: (str, str | None) -> EasyMod
""" 注册客户端(相对目录) """
REG_CLIENT_MODULE("{}.{}".format(self._modDirName, relPath), systemName)
return self
def addServerInitCallFunc(self, callFunc=lambda: None):
# type: (object) -> EasyMod
""" 添加服务端初始化调用方法 """
REG_SERVER_INIT_CALL(callFunc)
return self
def addClientInitCallFunc(self, callFunc=lambda: None):
# type: (object) -> EasyMod
""" 添加客户端初始化调用方法 """
REG_CLIENT_INIT_CALL(callFunc)
return self
def START_THREAD_ANALYSIS():
""" 启用线程分析 """
_TempData._threadAnalysis = True
def STOP_THREAD_ANALYSIS():
""" 禁用线程分析 """
_TempData._threadAnalysis = False
def REG_SERVER_MODULE(absPath, systemName=None, _index=-1):
# type: (str, str | None, int) -> None
""" 注册服务端模块 (绝对路径) """
if _index < 0:
return IN.RuntimeService._serverSystemList.append((absPath, systemName))
return IN.RuntimeService._serverSystemList.insert(_index, (absPath, systemName))
def REG_CLIENT_MODULE(absPath, systemName=None, _index=-1):
# type: (str, str | None, int) -> None
""" 注册客户端模块 (绝对路径) """
if _index < 0:
return IN.RuntimeService._clientSystemList.append((absPath, systemName))
return IN.RuntimeService._clientSystemList.insert(_index, (absPath, systemName))
def REG_SERVER_INIT_CALL(func=lambda: None):
# type: (function) -> function
""" 注册服务端初始化调用函数 """
_TempData._serverInitCall.append(func)
return func
def REG_CLIENT_INIT_CALL(func=lambda: None):
# type: (function) -> function
""" 注册客户端初始化调用函数 """
_TempData._clientInitCall.append(func)
return func
def PRE_SERVER_LOADER_HOOK(func=lambda: None):
# type: (function) -> function
""" 注册服务端加载器处理前的前置逻辑 (此时依然可以注册文件 该功能用于前置关联的校验处理) """
IN.RuntimeService._serverLoadBefore.append(func)
return func
def PRE_CLIENT_LOADER_HOOK(func=lambda: None):
# type: (function) -> function
""" 注册客户端加载器处理前的前置逻辑 (此时依然可以注册文件 该功能用于前置关联的校验处理) """
IN.RuntimeService._clientLoadBefore.append(func)
return func

View File

@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
import mod.client.extraClientApi as clientApi
from ...Util import errorPrint, TRY_EXEC_FUN, getObjectPathName
from ...IN import RuntimeService
from .SharedRes import (
CallObjData,
EasyListener,
SERVER_CALL_EVENT,
CLIENT_CALL_EVENT,
NAMESPACE,
SYSTEMNAME
)
lambda: "By Zero123"
ClientSystem = clientApi.GetClientSystemCls()
engineSpaceName, engineSystemName = clientApi.GetEngineNamespace(), clientApi.GetEngineSystemName()
def clientImportModule(filePath):
""" 客户端文件导入 """
return clientApi.ImportModule(filePath)
class LoaderSystem(ClientSystem, EasyListener):
""" QuMod加载器系统
加载器承担了系统文件的加载以及事件监听 系统通信
"""
@staticmethod
def getSystem():
# type: () -> LoaderSystem
""" 获取加载器系统 如果未注册将会自动注册并返回 """
system = clientApi.GetSystem(NAMESPACE, SYSTEMNAME)
if system:
return system
return clientApi.RegisterSystem(NAMESPACE, SYSTEMNAME, LoaderSystem.__module__ + "." + LoaderSystem.__name__)
_REG_CALL_FUNCS = {}
_REG_STATIC_LISTEN_FUNCS = {}
_DY_IMP_CACHE = {}
@staticmethod
def dyImportModule(modulePath):
if not modulePath in LoaderSystem._DY_IMP_CACHE:
LoaderSystem._DY_IMP_CACHE[modulePath] = clientImportModule(modulePath)
return LoaderSystem._DY_IMP_CACHE[modulePath]
@staticmethod
def REG_DESTROY_CALL_FUNC(func=lambda: None):
""" 适用于静态函数的注册销毁时回调 """
keyName = getObjectPathName(func)
if not keyName in LoaderSystem._REG_CALL_FUNCS:
# callFunc = lambda: LoaderSystem._REG_CALL_FUNCS[keyName]()
LoaderSystem.getSystem().addDestroyCall(func)
LoaderSystem._REG_CALL_FUNCS[keyName] = func
@staticmethod
def REG_STATIC_LISTEN_FUNC(eventName="", funcObj=lambda: None):
""" 注册静态监听函数 """
keyName = getObjectPathName(funcObj)
if not keyName in LoaderSystem._REG_STATIC_LISTEN_FUNCS:
# callFunc = lambda *args: LoaderSystem._REG_STATIC_LISTEN_FUNCS[keyName](*args)
LoaderSystem.getSystem().nativeStaticListen(eventName, funcObj)
LoaderSystem._REG_STATIC_LISTEN_FUNCS[keyName] = funcObj
def __init__(self, namespace, systemName):
ClientSystem.__init__(self, namespace, systemName)
EasyListener.__init__(self)
RuntimeService._clientStarting = True
self.namespace = namespace
self.systemName = systemName
self._systemList = RuntimeService._clientSystemList
self._initState = False
self._regInitState = False
self._waitTime = 0.0
self._callQueue = [] # type: list[CallObjData]
self._onDestroyCall = []
self._onDestroyCall_LAST = []
""" 后置销毁触发 通常是内部使用确保在用户业务之后执行 """
self._initSystemListen()
self.systemInit()
def _initSystemListen(self):
self.ListenForEvent(NAMESPACE, SYSTEMNAME, SERVER_CALL_EVENT, self, self._systemCallListener)
def _easyListenForEvent(self, eventName="", parent=None, func=lambda: None):
return self.ListenForEvent(engineSpaceName, engineSystemName, eventName, parent, func)
def _easyUnListenForEvent(self, eventName="", parent=None, func=lambda: None):
return self.UnListenForEvent(engineSpaceName, engineSystemName, eventName, parent, func)
def sendCall(self, apiName="", args=tuple(), kwargs=dict()):
""" 向服务器端请求调用 """
sendData = self._packageCallArgs(apiName, args, kwargs)
self.NotifyToServer(CLIENT_CALL_EVENT, sendData)
def addDestroyCall(self, funObj, doubleCheck=True):
""" 添加销毁触发 """
if doubleCheck and funObj in self._onDestroyCall:
return
self._onDestroyCall.append(funObj)
def removeDestroyCall(self, funObj):
""" 移除销毁触发 """
if funObj in self._onDestroyCall:
self._onDestroyCall.remove(funObj)
def Destroy(self):
# 用户级destroy执行
for obj in self._onDestroyCall:
TRY_EXEC_FUN(obj)
self._onDestroyCall = []
# 高权限destroy执行
for obj in self._onDestroyCall_LAST:
TRY_EXEC_FUN(obj)
self._onDestroyCall_LAST = []
RuntimeService._clientStarting = False
def getSystemList(self):
# type: () -> list[tuple[str, str | None]]
return self._systemList
def removeCallObjByUid(self, _uid = ""):
""" 尝试移除特定uid的callObj 如果存在 """
for i, obj in enumerate(self._callQueue):
if obj._uid == _uid:
del self._callQueue[i]
break
def proxyRegister(self, funcObj):
""" 代理注册 """
from functools import wraps
@wraps(funcObj)
def newFun(*args, **kwargs):
callObj = CallObjData(funcObj, args, kwargs)
self._callQueue.append(callObj)
return callObj
return newFun
def unsafeUpdate(self, callObjData):
# type: (CallObjData) -> bool
""" 不安全的强制刷新 """
if callObjData in self._callQueue:
self._callQueue.remove(callObjData)
callObjData.callObj(*callObjData.args, **callObjData.kwargs)
return True
return False
def Update(self):
self.regSystemInit()
if self._callQueue:
for obj in self._callQueue:
try:
obj.callObj(*obj.args, **obj.kwargs)
except Exception as e:
errorPrint("{} call执行异常 {}".format(obj.callObj, e))
import traceback
traceback.print_exc()
self._callQueue = []
return ClientSystem.Update(self)
def systemInit(self):
self._initState = True
def regSystemInit(self):
""" 系统信息注册初始化 """
if self._regInitState:
return
# 加载Before事件
for funcObj in RuntimeService._clientLoadBefore:
TRY_EXEC_FUN(funcObj)
# 因历史原因systemName已废弃 此处仅兼容旧版项目
for path, _ in self._systemList:
sysObj = None
try:
sysObj = clientImportModule(path)
if sysObj == None:
errorPrint("[客户端] 系统文件加载失败(API异常): {}".format(path))
continue
except Exception as e:
errorPrint("[客户端] 系统文件错误: {} ({})".format(path, e))
import traceback
traceback.print_exc()
continue
# if not systemName: systemName = uuid4().hex
self._regInitState = True
# 加载Finish事件
for funcObj in RuntimeService._clientLoadFinish:
TRY_EXEC_FUN(funcObj)

View File

@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
import mod.server.extraServerApi as serverApi
from ...Util import errorPrint, TRY_EXEC_FUN, getObjectPathName
from ...IN import RuntimeService
from .SharedRes import (
CallObjData,
EasyListener,
SERVER_CALL_EVENT,
CLIENT_CALL_EVENT,
NAMESPACE,
SYSTEMNAME
)
lambda: "By Zero123"
ServerSystem = serverApi.GetServerSystemCls()
engineSpaceName, engineSystemName = serverApi.GetEngineNamespace(), serverApi.GetEngineSystemName()
def serverImportModule(filePath):
""" 服务端文件导入 """
return serverApi.ImportModule(filePath)
class LoaderSystem(ServerSystem, EasyListener):
""" QuMod加载器系统
加载器承担了系统文件的加载以及事件监听 系统通信
"""
@staticmethod
def getSystem():
# type: () -> LoaderSystem
""" 获取加载器系统 如果未注册将会自动注册并返回 """
system = serverApi.GetSystem(NAMESPACE, SYSTEMNAME)
if system:
return system
return serverApi.RegisterSystem(NAMESPACE, SYSTEMNAME, LoaderSystem.__module__ + "." + LoaderSystem.__name__)
_REG_CALL_FUNCS = {}
_REG_STATIC_LISTEN_FUNCS = {}
_DY_IMP_CACHE = {}
@staticmethod
def dyImportModule(modulePath):
if not modulePath in LoaderSystem._DY_IMP_CACHE:
LoaderSystem._DY_IMP_CACHE[modulePath] = serverImportModule(modulePath)
return LoaderSystem._DY_IMP_CACHE[modulePath]
@staticmethod
def REG_DESTROY_CALL_FUNC(func=lambda: None):
""" 适用于静态函数的注册销毁时回调 """
keyName = getObjectPathName(func)
if not keyName in LoaderSystem._REG_CALL_FUNCS:
# callFunc = lambda: LoaderSystem._REG_CALL_FUNCS[keyName]()
LoaderSystem.getSystem().addDestroyCall(func)
LoaderSystem._REG_CALL_FUNCS[keyName] = func
@staticmethod
def REG_STATIC_LISTEN_FUNC(eventName="", funcObj=lambda: None):
""" 注册静态监听函数 """
keyName = getObjectPathName(funcObj)
if not keyName in LoaderSystem._REG_STATIC_LISTEN_FUNCS:
# callFunc = lambda *args: LoaderSystem._REG_STATIC_LISTEN_FUNCS[keyName](*args)
LoaderSystem.getSystem().nativeStaticListen(eventName, funcObj)
LoaderSystem._REG_STATIC_LISTEN_FUNCS[keyName] = funcObj
def __init__(self, namespace, systemName):
ServerSystem.__init__(self, namespace, systemName)
EasyListener.__init__(self)
RuntimeService._serverStarting = True
self.namespace = namespace
self.systemName = systemName
self.rpcPlayerId = None
self._systemList = RuntimeService._serverSystemList
self._initState = False
self._regInitState = False
self._waitTime = 0.0
self._onDestroyCall = []
self._onDestroyCall_LAST = []
""" 后置销毁触发 通常是内部使用确保在用户业务之后执行 """
self._initSystemListen()
self.systemInit()
def _systemCallListenerHook(self, args={}):
target = "__id__"
if target in args:
self.rpcPlayerId = args[target]
return
self.rpcPlayerId = None
def _initSystemListen(self):
self.ListenForEvent(NAMESPACE, SYSTEMNAME, CLIENT_CALL_EVENT, self, self._systemCallListener)
def _easyListenForEvent(self, eventName="", parent=None, func=lambda: None):
return self.ListenForEvent(engineSpaceName, engineSystemName, eventName, parent, func)
def _easyUnListenForEvent(self, eventName="", parent=None, func=lambda: None):
return self.UnListenForEvent(engineSpaceName, engineSystemName, eventName, parent, func)
def sendCall(self, playerId="", apiName="", args=tuple(), kwargs=dict()):
""" 向指定玩家客户端请求调用 当playerId声明为*时代表全体玩家 """
sendData = self._packageCallArgs(apiName, args, kwargs)
if playerId == "*":
self.BroadcastToAllClient(SERVER_CALL_EVENT, sendData)
return
self.NotifyToClient(playerId, SERVER_CALL_EVENT, sendData)
def sendMultiClientsCall(self, playerListId=[], apiName="", args=tuple(), kwargs=dict()):
""" 批量向多个玩家客户端发包相同的调用数据 """
sendData = self._packageCallArgs(apiName, args, kwargs)
self.NotifyToMultiClients(playerListId, SERVER_CALL_EVENT, sendData)
def addDestroyCall(self, funObj, doubleCheck=True):
""" 添加销毁触发 """
if doubleCheck and funObj in self._onDestroyCall:
return
self._onDestroyCall.append(funObj)
def removeDestroyCall(self, funObj):
""" 移除销毁触发 """
if funObj in self._onDestroyCall:
self._onDestroyCall.remove(funObj)
def Destroy(self):
# 用户级destroy执行
for obj in self._onDestroyCall:
TRY_EXEC_FUN(obj)
self._onDestroyCall = []
# 高权限destroy执行
for obj in self._onDestroyCall_LAST:
TRY_EXEC_FUN(obj)
self._onDestroyCall_LAST = []
RuntimeService._serverStarting = False
def getSystemList(self):
# type: () -> list[tuple[str, str | None]]
return self._systemList
def removeCallObjByUid(self, _uid = ""):
""" 尝试移除特定uid的callObj 如果存在 """
for i, obj in enumerate(self._callQueue):
if obj._uid == _uid:
del self._callQueue[i]
break
def proxyRegister(self, funcObj):
""" 代理注册 """
from functools import wraps
@wraps(funcObj)
def newFun(*args, **kwargs):
callObj = CallObjData(funcObj, args, kwargs)
self._callQueue.append(callObj)
return callObj
return newFun
def unsafeUpdate(self, callObjData):
# type: (CallObjData) -> bool
""" 不安全的强制刷新 """
if callObjData in self._callQueue:
self._callQueue.remove(callObjData)
callObjData.callObj(*callObjData.args, **callObjData.kwargs)
return True
return False
def Update(self):
self.regSystemInit()
if self._callQueue:
for obj in self._callQueue:
try:
obj.callObj(*obj.args, **obj.kwargs)
except Exception as e:
errorPrint("{} call执行异常 {}".format(obj.callObj, e))
import traceback
traceback.print_exc()
self._callQueue = []
return ServerSystem.Update(self)
def systemInit(self):
self._initState = True
def regSystemInit(self):
""" 系统信息注册初始化 """
if self._regInitState:
return
# 加载Before事件
for funcObj in RuntimeService._serverLoadBefore:
TRY_EXEC_FUN(funcObj)
# 因历史原因systemName已废弃 此处仅兼容旧版项目
for path, _ in self._systemList:
sysObj = None
try:
sysObj = serverImportModule(path)
if sysObj == None:
errorPrint("[服务端] 系统文件加载失败(API异常): {}".format(path))
continue
except Exception as e:
errorPrint("[服务端] 系统文件错误: {} ({})".format(path, e))
import traceback
traceback.print_exc()
continue
# if not systemName: systemName = uuid4().hex
self._regInitState = True
# 加载Finish事件
for funcObj in RuntimeService._serverLoadFinish:
TRY_EXEC_FUN(funcObj)

View File

@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
from uuid import uuid4
from ...IN import ModDirName
from ...Util import QStruct
class CallObjData:
def __init__(self, callObj, args = tuple(), kwargs = {}):
self.callObj = callObj
self.args = args
self.kwargs = kwargs
self._uid = None
class EmptyContext:
pass
NAMESPACE = "Qu_" + ModDirName
SYSTEMNAME = "{}_QLoader_system".format(ModDirName)
SERVER_CALL_EVENT = "{}_QServer".format(ModDirName)
CLIENT_CALL_EVENT = "{}_QClient".format(ModDirName)
class EasyListener:
def __init__(self):
self._callQueue = [] # type: list[CallObjData]
self._emptyContext = EmptyContext()
self._QCustomAPI = {} # type: dict[str, function]
def regCustomApi(self, apiName="", func=lambda: None):
""" 注册自定义API """
self._QCustomAPI[apiName] = func
def removeCustomApi(self, apiName=""):
""" 删除指定API如果存在 """
if apiName in self._QCustomAPI:
del self._QCustomAPI[apiName]
def getCustomApi(self, apiName=""):
""" 获取自定义API如果存在 """
return self._QCustomAPI.get(apiName)
def localCall(self, apiName="", *args, **kwargs):
""" 本地调用 请确保API函数存在注册 否则抛出异常 """
return self._QCustomAPI[apiName](*args, **kwargs)
def _systemCallListener(self, args={}):
""" 系统call机制监听器(接收消息处理) """
api = args["api"]
ag = EasyListener._unPackRefArgs(args["args"])
kwargs = EasyListener._unPackRefDictArgs(args["kw"])
self._systemCallListenerHook(args)
return self.localCall(api, *ag, **kwargs)
def _systemCallListenerHook(self, _={}):
pass
@staticmethod
def _unPackRefArgs(data):
# type: (list) -> list
""" Ref解包Args数据 """
for i, v in enumerate(data):
if QStruct.isSignData(v):
data[i] = QStruct.loadSignData(v).onNetUnPack()
return data
@staticmethod
def _unPackRefDictArgs(data):
# type: (dict) -> dict
""" Ref解包Dict Args数据 """
for k, v in data.items():
if QStruct.isSignData(v):
data[k] = QStruct.loadSignData(v).onNetUnPack()
return data
@staticmethod
def _packArgs(data):
# type: (tuple | list) -> list
""" 打包Args数据 """
newDataList = []
for v in data:
if isinstance(v, QStruct):
newDataList.append(v.signDumps())
continue
newDataList.append(v)
return newDataList
@staticmethod
def _packDictArgs(data):
# type: (dict) -> dict
""" 打包Dict数据 keyName=xxx """
newDict = {}
for k, v in data.items():
if isinstance(v, QStruct):
newDict[k] = v.signDumps()
continue
newDict[k] = v
return newDict
def _packageCallArgs(self, apiName="", args=tuple(), kwargs=dict()):
""" 打包API参数(发送消息处理) """
return {"api":apiName,"args":EasyListener._packArgs(args),"kw":EasyListener._packDictArgs(kwargs)}
def mallocRandomMetName(self):
""" 动态分配随机方法名 """
randomName = ""
while not randomName or hasattr(self, randomName):
randomName = "Q_{}".format(uuid4().hex)
return randomName
def _allocMethodWithOUTFunction(self, callFunc=lambda *_: None):
""" 基于外部函数分配一个映射的内部方法(介于网易Listen必须依赖内部方法 故有了该方法) """
newFuncName = self.mallocRandomMetName()
newFunc = lambda *args, **kwargs: callFunc(*args, **kwargs)
newFunc.__name__ = newFuncName
setattr(self, newFuncName, newFunc)
return newFunc
def _delMethod(self, methodFunc=lambda *_: None):
""" 对照与_allocMethodWithOUTFunction的反向删除 """
try:
delattr(self, methodFunc.__name__)
except Exception:
pass
def nativeStaticListen(self, eventName="", callFunc=lambda *_: None):
""" 原生静态监听注册 不支持运行时注销 """
def _reg():
self._easyListenForEvent(eventName, self, self._allocMethodWithOUTFunction(callFunc))
self._callQueue.append(CallObjData(_reg))
def nativeListen(self, eventName="", parent=None, callFunc=lambda *_: None, updateNow=False):
# type: (str, object, object, bool) -> CallObjData | None
""" 原生动态监听 当updateNow声明为False时将会添加到系统队列安全的等待注册 """
if not parent:
parent = self._emptyContext
newFuncName = "QListen{}_{}".format(id(parent), callFunc.__name__)
newFunc = lambda *args: callFunc(*args)
newFunc.__name__ = newFuncName
if hasattr(self, newFuncName):
print("[Error] 请勿在单个可执行对象上监听重复的事件")
return None
def _reg():
setattr(self, newFuncName, newFunc)
self._easyListenForEvent(eventName, self, newFunc)
waitCallObj = CallObjData(_reg)
waitCallObj._uid = newFuncName
self._callQueue.append(waitCallObj)
if updateNow:
self.unsafeUpdate(waitCallObj)
return waitCallObj
def unNativeListen(self, eventName="", parent=None, callFunc=lambda *_: None):
# type: (str, object, object) -> None
""" 取消特定方法的原生动态监听 """
if not parent:
parent = self._emptyContext
newFuncName = "QListen{}_{}".format(id(parent), callFunc.__name__)
if hasattr(self, newFuncName):
# 已注册完毕的监听处理
self._easyUnListenForEvent(eventName, self, getattr(self, newFuncName))
delattr(self, newFuncName)
return
# 在队列中等待注册的监听处理
self.removeCallObjByUid(newFuncName)
def unsafeUpdate(self, callObjData):
# type: (CallObjData) -> bool
pass
def _easyListenForEvent(self, eventName="", parent=None, func=lambda: None):
pass
def _easyUnListenForEvent(self, eventName="", parent=None, func=lambda: None):
pass
def removeCallObjByUid(self, _uid = ""):
pass

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
from functools import wraps
from time import time
import pickle as _pickle
class SystemSide(object):
def __init__(self, Path, SystemName = None):
self.SystemName = SystemName # 绑定系统
self.Path = Path
ModDirName = SystemSide.__module__.split(".")[0]
def errorPrint(charPtr):
""" 异常输出 """
print("[Error] "+str(charPtr))
def TRY_EXEC_FUN(funObj, *args, **kwargs):
try:
return funObj(*args, **kwargs)
except Exception as e:
import traceback
print("TRY_EXEC发生异常: {}".format(e))
traceback.print_exc()
def getObjectPathName(_callObj = lambda: None):
# type: (object) -> str
""" 获取可执行对象的目录名 """
return ".".join((_callObj.__module__, _callObj.__name__))
class QStruct:
""" 结构体 用于通用数据模型约定(即不涉及任何API) 应定义在Server/Client以外的通用文件 同理Struct也不应该持有任何涉及端侧API的内容 """
_SIGN_FORMAT = "_QSTRUCT[{}]"
def dumps(self):
""" 序列化对象 """
return _pickle.dumps(self)
def signDumps(self):
""" 带有特征签名的序列化 """
data = self.dumps()
return [QStruct._SIGN_FORMAT.format(hex(hash(data))), data]
@staticmethod
def isSignData(data):
""" 校验数据 """
if not isinstance(data, list) or len(data) != 2:
return False
signKey = data[0]
if isinstance(signKey, str):
dataObj = data[1]
return signKey == QStruct._SIGN_FORMAT.format(hex(hash(dataObj)))
return False
@staticmethod
def loads(data):
# type: (str) -> QStruct
""" 反序列化加载对象 """
return _pickle.loads(data)
@staticmethod
def loadSignData(data):
# type: (list) -> QStruct
""" 反序列化加载Sign对象表(不会校验) """
return _pickle.loads(data[1])
def onNetUnPack(self):
return self
class QRefStruct(QStruct):
""" 万能引用 """
def __init__(self, refObject):
self.ref = refObject
def onNetUnPack(self):
return self.ref
class QListStruct(QStruct, list):
""" List容器结构 """
def onNetUnPack(self):
return list(self)
class QDictStruct(QStruct, dict):
""" Dict容器结构 """
def onNetUnPack(self):
return dict(self)
class QTupleStruct(QStruct, tuple):
""" Tuple容器结构 """
def onNetUnPack(self):
return tuple(self)

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
from .QuModLibs.QuMod import *
from .Game import (
RELOAD_MOD,
INIT_RELOAD_TIME,
RELOAD_ADDON,
RELOAD_WORLD,
RELOAD_SHADERS,
)
from .Config import DEBUG_CONFIG
import threading
import sys
lambda: "By Zero123"
REF = 0
class STD_OUT_WRAPPER(object):
def __init__(self, baseIO):
self.baseIO = baseIO
self.writeLock = threading.Lock()
self._buffer = []
def __getattr__(self, name):
return getattr(self.baseIO, name)
def write(self, data):
with self.writeLock:
parts = data.splitlines(True)
for part in parts:
if part.endswith("\n"):
if self._buffer:
line = "".join(self._buffer) + part
self._buffer = []
else:
line = part
self.baseIO.write("[Python] " + line)
else:
self._buffer.append(part)
def close(self):
return self.baseIO.close()
def writelines(self, lines):
for line in lines:
self.write(line)
def fileno(self):
return self.baseIO.fileno()
stdout = sys.stdout
stderr = sys.stderr
def REST_STDOUT():
sys.stdout = stdout
sys.stderr = stderr
sys.stdout = STD_OUT_WRAPPER(sys.stdout)
sys.stderr = STD_OUT_WRAPPER(sys.stderr)
@PRE_SERVER_LOADER_HOOK
def SERVER_INIT():
global REF
REF += 1
def _DESTROY():
global REF
REF -= 1
if REF != 0:
return
REST_STDOUT()
from .QuModLibs.Systems.Loader.Server import LoaderSystem
LoaderSystem.REG_DESTROY_CALL_FUNC(_DESTROY)
from . import IPCSystem
IPCSystem.ON_SERVER_INIT()
def CLOnKeyPressInGame(args={}):
if args["isDown"] != "0":
return
if args["screenName"] != "hud_screen" and not DEBUG_CONFIG.get(
"reload_key_global", False
):
return
key = args["key"]
if key == str(DEBUG_CONFIG.get("reload_key", "82")):
RELOAD_MOD()
elif key == str(DEBUG_CONFIG.get("reload_world_key", "")):
RELOAD_WORLD()
elif key == str(DEBUG_CONFIG.get("reload_addon_key", "")):
RELOAD_ADDON()
elif key == str(DEBUG_CONFIG.get("reload_shaders_key", "")):
RELOAD_SHADERS()
@PRE_CLIENT_LOADER_HOOK
def CLIENT_INIT():
global REF
REF += 1
from . import IPCSystem
def _DESTROY():
global REF
REF -= 1
IPCSystem.ON_CLIENT_EXIT()
if REF != 0:
return
REST_STDOUT()
from .QuModLibs.Systems.Loader.Client import LoaderSystem
LoaderSystem.REG_DESTROY_CALL_FUNC(_DESTROY)
LoaderSystem.getSystem().nativeStaticListen("OnKeyPressInGame", CLOnKeyPressInGame)
IPCSystem.ON_CLIENT_INIT()
try:
INIT_RELOAD_TIME()
except:
pass

1
debug_mod/manifest.json Normal file
View File

@@ -0,0 +1 @@
{"format_version":1,"header":{"description":"","name":"","uuid":"c329b06f-7492-4457-9af3-b19ff29999f9","version":[1,0,0],"min_engine_version":[1,18,0]},"modules":[{"description":"","type":"data","uuid":"96b5e924-153e-460d-8e57-10463173510b","version":[1,0,0]}],"dependencies":[{"uuid":"48199e63-9544-494a-90ab-2864e8f37f39","version":[1,0,0]}]}

View File

@@ -0,0 +1,37 @@
behavior_pack/BoxData
behavior_pack/entities
behavior_pack/items
behavior_pack/loot_tables
behavior_pack/netease_feature_rules
behavior_pack/netease_features
behavior_pack/netease_items_beh
behavior_pack/Parts
behavior_pack/Presets
behavior_pack/recipes
behavior_pack/spawn_rules
behavior_pack/structures
behavior_pack/trading
behavior_pack/Galaxy/Macro
behavior_pack/Galaxy/Template
behavior_pack/storyline/level
resource_pack/animation_controllers
resource_pack/animations
resource_pack/attachables
resource_pack/effects
resource_pack/entity
resource_pack/font
resource_pack/materials
resource_pack/models/animation
resource_pack/models/editor_materials
resource_pack/models/effect
resource_pack/models/geometry
resource_pack/models/mesh
resource_pack/models/netease_block
resource_pack/render_controllers
resource_pack/shaders/glsl
resource_pack/sounds
resource_pack/textures/entity
resource_pack/textures/models
resource_pack/textures/particle
resource_pack/textures/ui
resource_pack/ui

776
src/commands/bbmodel.rs Normal file
View File

@@ -0,0 +1,776 @@
use crate::commands::BbmodelArgs;
use crate::entity;
use crate::error::{CliError, Result};
use crate::utils::file;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde_json::{Map, Number, Value, json};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_INPUT: &str = "./model.bbmodel";
const TEXTURE_DATA_PREFIX: &str = "data:image/png;base64,";
struct TextureDesc {
identifier: String,
width: u64,
height: u64,
terrain_texture: Option<String>,
}
struct CommandOutput {
model_path: PathBuf,
texture_count: usize,
terrain_updated: bool,
}
pub fn execute(args: &BbmodelArgs) {
match run_bbmodel(args) {
Ok(output) => {
println!("成功: 方块模型已生成到 {}", output.model_path.display());
if output.texture_count > 0 {
println!("成功: 已导出 {} 张贴图", output.texture_count);
}
if output.terrain_updated {
println!("成功: terrain_texture.json 已更新");
}
}
Err(e) => {
eprintln!("错误: {}", e);
}
}
}
fn run_bbmodel(args: &BbmodelArgs) -> Result<CommandOutput> {
let (namespace, short_name) = parse_identifier(&args.identifier)?;
let input_path = PathBuf::from(args.input.as_deref().unwrap_or(DEFAULT_INPUT));
let doc = parse_bbmodel(&input_path)?;
let texture_count = texture_count(&doc)?;
let bones = flatten_bones(&doc, texture_count)?;
let target = resolve_target(args, short_name)?;
if let Some(parent) = target.model_path.parent() {
fs::create_dir_all(parent).map_err(|e| file::io_error("创建模型目录", parent, e))?;
}
let texture_descs = if args.no_textures {
collect_texture_descriptions(&doc, namespace, short_name, None)?
} else {
let texture_dir = target
.texture_dir
.as_ref()
.ok_or_else(|| CliError::InvalidData("贴图输出目录未初始化".into()))?;
extract_textures(
&doc,
texture_dir,
namespace,
short_name,
target.terrain_base.as_deref(),
)?
};
let model_json = assemble_model_json(&args.identifier, bones, &texture_descs);
file::write_json_to_file(&target.model_path, &model_json)?;
let mut terrain_updated = false;
if !args.no_textures {
if let Some(res_pack) = target.resource_pack.as_deref() {
update_terrain_texture(res_pack, &texture_descs)?;
terrain_updated = !texture_descs.is_empty();
}
}
Ok(CommandOutput {
model_path: target.model_path,
texture_count: if args.no_textures {
0
} else {
texture_descs.len()
},
terrain_updated,
})
}
struct TargetPaths {
model_path: PathBuf,
texture_dir: Option<PathBuf>,
resource_pack: Option<PathBuf>,
terrain_base: Option<String>,
}
fn resolve_target(args: &BbmodelArgs, short_name: &str) -> Result<TargetPaths> {
if let Some(output) = args.output.as_deref() {
let output_dir = PathBuf::from(output);
return Ok(TargetPaths {
model_path: output_dir.join(format!("{}.json", short_name)),
texture_dir: Some(output_dir),
resource_pack: None,
terrain_base: None,
});
}
let project_path = file::find_project_dir(&args.path)?;
let project_info = entity::get_current_release_info(&project_path)?;
let res_pack = project_path.join(format!(
"resource_pack_{}",
project_info.resource_identifier
));
Ok(TargetPaths {
model_path: res_pack
.join("models")
.join("netease_block")
.join(format!("{}.json", short_name)),
texture_dir: Some(res_pack.join("textures").join("blocks")),
resource_pack: Some(res_pack),
terrain_base: Some("textures/blocks".to_string()),
})
}
fn parse_identifier(identifier: &str) -> Result<(&str, &str)> {
let (namespace, short_name) = identifier.split_once(':').ok_or_else(|| {
CliError::InvalidInput(format!(
"方块标识符 '{}' 无效,应为 <namespace>:<name>",
identifier
))
})?;
if namespace.is_empty() || short_name.is_empty() || short_name.contains(':') {
return Err(CliError::InvalidInput(format!(
"方块标识符 '{}' 无效,应为 <namespace>:<name>",
identifier
)));
}
if short_name.chars().any(|c| c == '/' || c == '\\') {
return Err(CliError::InvalidInput(format!(
"方块标识符 '{}' 不能包含路径分隔符",
identifier
)));
}
Ok((namespace, short_name))
}
fn parse_bbmodel(path: &Path) -> Result<Value> {
if !path.exists() {
return Err(CliError::NotFound(format!(
".bbmodel 文件 {} 不存在",
path.display()
)));
}
let path_buf = path.to_path_buf();
let doc = file::read_file_to_json(&path_buf)?;
required_array_field(&doc, "outliner")?;
optional_array_field(&doc, "groups")?;
required_array_field(&doc, "elements")?;
if doc
.get("meta")
.and_then(|meta| meta.get("box_uv"))
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Err(CliError::InvalidData(
"当前只支持 per-face UV不支持 box_uv 模式".into(),
));
}
Ok(doc)
}
fn texture_count(doc: &Value) -> Result<usize> {
Ok(optional_array_field(doc, "textures")?.map_or(0, Vec::len))
}
fn flatten_bones(doc: &Value, texture_count: usize) -> Result<Vec<Value>> {
let groups = match optional_array_field(doc, "groups")? {
Some(groups) => build_uuid_lookup(groups, "groups")?,
None => HashMap::new(),
};
let elements = build_uuid_lookup(required_array_field(doc, "elements")?, "elements")?;
let outliner = required_array_field(doc, "outliner")?;
let mut bones = Vec::new();
for node in outliner {
visit_outliner_node(
node,
None,
None,
&groups,
&elements,
texture_count,
&mut bones,
)?;
}
Ok(bones)
}
fn build_uuid_lookup<'a>(items: &'a [Value], field: &str) -> Result<HashMap<&'a str, &'a Value>> {
let mut lookup = HashMap::with_capacity(items.len());
for item in items {
let uuid = item
.get("uuid")
.and_then(Value::as_str)
.ok_or_else(|| CliError::InvalidData(format!("{} 中存在缺少 uuid 的条目", field)))?;
lookup.insert(uuid, item);
}
Ok(lookup)
}
fn visit_outliner_node<'a>(
node: &'a Value,
parent_name: Option<&'a str>,
current_bone_index: Option<usize>,
groups: &HashMap<&'a str, &'a Value>,
elements: &HashMap<&'a str, &'a Value>,
texture_count: usize,
bones: &mut Vec<Value>,
) -> Result<()> {
match node {
Value::String(uuid) => {
let element = elements.get(uuid.as_str()).ok_or_else(|| {
CliError::InvalidData(format!("outliner 引用了不存在的 element uuid {}", uuid))
})?;
if let Some(cube) = convert_element(element, texture_count)? {
let bone_index = current_bone_index.ok_or_else(|| {
CliError::InvalidData(format!(
"cube {} 不在任何 group/bone 下,无法生成 netease:block_geometry",
uuid
))
})?;
append_cube(&mut bones[bone_index], cube)?;
}
}
Value::Object(outliner_group) => {
let uuid = outliner_group
.get("uuid")
.and_then(Value::as_str)
.ok_or_else(|| CliError::InvalidData("outliner group 缺少 uuid".into()))?;
let group = groups.get(uuid).copied().unwrap_or(node);
let group_name = required_string_field(group, "name")?;
let mut bone = Map::new();
bone.insert("name".to_string(), Value::String(group_name.to_string()));
if let Some(parent) = parent_name {
bone.insert("parent".to_string(), Value::String(parent.to_string()));
}
bone.insert("pivot".to_string(), convert_pivot(group, "group")?);
bone.insert("rotation".to_string(), convert_rotation(group, "group")?);
bones.push(Value::Object(bone));
let bone_index = bones.len() - 1;
let children = outliner_group
.get("children")
.and_then(Value::as_array)
.ok_or_else(|| {
CliError::InvalidData(format!("outliner group {} 缺少 children", uuid))
})?;
for child in children {
match child {
Value::String(_) => visit_outliner_node(
child,
Some(group_name),
Some(bone_index),
groups,
elements,
texture_count,
bones,
)?,
Value::Object(_) => {}
_ => {
visit_outliner_node(
child,
Some(group_name),
Some(bone_index),
groups,
elements,
texture_count,
bones,
)?;
}
}
}
for child in children.iter().rev() {
if child.is_object() {
visit_outliner_node(
child,
Some(group_name),
Some(bone_index),
groups,
elements,
texture_count,
bones,
)?;
}
}
}
_ => {
return Err(CliError::InvalidData(
"outliner 节点必须是 group 对象或 element uuid 字符串".into(),
));
}
}
Ok(())
}
fn append_cube(bone: &mut Value, cube: Value) -> Result<()> {
let bone_object = bone
.as_object_mut()
.ok_or_else(|| CliError::InvalidData("bone 不是 JSON 对象".into()))?;
if !bone_object.contains_key("cubes") {
bone_object.insert("cubes".to_string(), Value::Array(Vec::new()));
}
let cubes = bone_object
.get_mut("cubes")
.and_then(Value::as_array_mut)
.ok_or_else(|| CliError::InvalidData("bone.cubes 不是数组".into()))?;
cubes.push(cube);
Ok(())
}
fn convert_element(element: &Value, texture_count: usize) -> Result<Option<Value>> {
let element_type = required_string_field(element, "type")?;
if element_type != "cube" {
let name = element
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>");
eprintln!(
"警告: 跳过不支持的 Blockbench 元素 '{}'type={}",
name, element_type
);
return Ok(None);
}
if element
.get("box_uv")
.and_then(Value::as_bool)
.unwrap_or(false)
{
let name = element
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>");
return Err(CliError::InvalidData(format!(
"元素 '{}' 使用 box_uv当前只支持 per-face UV",
name
)));
}
Ok(Some(convert_cube(element, texture_count)?))
}
fn convert_cube(element: &Value, texture_count: usize) -> Result<Value> {
let from = required_vec3(element, "from")?;
let to = required_vec3(element, "to")?;
let origin = required_vec3(element, "origin")?;
let rotation = optional_vec3(element, "rotation")?;
let faces = required_object_field(element, "faces")?;
let mut cube = Map::new();
cube.insert(
"origin".to_string(),
number_array(&[-to[0], from[1], from[2]])?,
);
cube.insert(
"pivot".to_string(),
number_array(&[-origin[0], origin[1], origin[2]])?,
);
cube.insert(
"rotation".to_string(),
number_array(&[-rotation[0], -rotation[1], rotation[2]])?,
);
cube.insert(
"size".to_string(),
number_array(&[to[0] - from[0], to[1] - from[1], to[2] - from[2]])?,
);
let mut uv = Map::new();
for (face_name, face) in faces {
uv.insert(
face_name.to_string(),
convert_face(face_name, face, texture_count)?,
);
}
cube.insert("uv".to_string(), Value::Object(uv));
Ok(Value::Object(cube))
}
fn convert_face(face_name: &str, face: &Value, texture_count: usize) -> Result<Value> {
let uv_values = required_array_field(face, "uv")?;
if uv_values.len() != 4 {
return Err(CliError::InvalidData(format!(
"face.uv 长度应为 4实际为 {}",
uv_values.len()
)));
}
let u1 = number_as_f64(&uv_values[0], "face.uv[0]")?;
let v1 = number_as_f64(&uv_values[1], "face.uv[1]")?;
let u2 = number_as_f64(&uv_values[2], "face.uv[2]")?;
let v2 = number_as_f64(&uv_values[3], "face.uv[3]")?;
let texture = face
.get("texture")
.ok_or_else(|| CliError::InvalidData("face 缺少 texture 字段".into()))?;
let texture_index = number_as_u64(texture, "face.texture")?;
let texture_index_usize = usize::try_from(texture_index).map_err(|_| {
CliError::InvalidData(format!("face.texture {} 超出平台索引范围", texture_index))
})?;
if texture_index_usize >= texture_count {
return Err(CliError::InvalidData(format!(
"face.texture {} 超出 textures 数组范围 0..{}",
texture_index,
texture_count.saturating_sub(1)
)));
}
let mut out = Map::new();
out.insert(
"texture".to_string(),
Value::Number(Number::from(texture_index)),
);
if face_name == "up" {
out.insert(
"uv".to_string(),
Value::Array(vec![uv_values[2].clone(), uv_values[3].clone()]),
);
out.insert("uv_size".to_string(), number_array(&[u1 - u2, v1 - v2])?);
} else {
out.insert(
"uv".to_string(),
Value::Array(vec![uv_values[0].clone(), uv_values[1].clone()]),
);
out.insert("uv_size".to_string(), number_array(&[u2 - u1, v2 - v1])?);
}
if let Some(rotation) = face.get("rotation") {
let rot = number_as_i64(rotation, "face.rotation")?;
match rot {
0 => {}
90 | 180 | 270 => {
out.insert("rot".to_string(), Value::Number(Number::from(rot)));
}
_ => {
return Err(CliError::InvalidData(format!(
"face.rotation 只支持 0/90/180/270实际为 {}",
rot
)));
}
}
}
Ok(Value::Object(out))
}
fn convert_pivot(value: &Value, owner: &str) -> Result<Value> {
let origin = required_vec3(value, "origin")?;
number_array(&[-origin[0], origin[1], origin[2]])
.map_err(|e| CliError::InvalidData(format!("{} pivot 转换失败: {}", owner, e)))
}
fn convert_rotation(value: &Value, owner: &str) -> Result<Value> {
let rotation = required_vec3(value, "rotation")?;
number_array(&[-rotation[0], -rotation[1], rotation[2]])
.map_err(|e| CliError::InvalidData(format!("{} rotation 转换失败: {}", owner, e)))
}
fn collect_texture_descriptions(
doc: &Value,
namespace: &str,
short_name: &str,
terrain_base: Option<&str>,
) -> Result<Vec<TextureDesc>> {
let Some(textures) = optional_array_field(doc, "textures")? else {
return Ok(Vec::new());
};
let mut descs = Vec::with_capacity(textures.len());
for (index, texture) in textures.iter().enumerate() {
let texture_index = index + 1;
let width = required_u64_field(texture, "width")?;
let height = required_u64_field(texture, "height")?;
let terrain_texture =
terrain_base.map(|base| format!("{}/{}_{}", base, short_name, texture_index));
descs.push(TextureDesc {
identifier: format!("{}:{}_{}", namespace, short_name, texture_index),
width,
height,
terrain_texture,
});
}
Ok(descs)
}
fn extract_textures(
doc: &Value,
out_dir: &Path,
namespace: &str,
short_name: &str,
terrain_base: Option<&str>,
) -> Result<Vec<TextureDesc>> {
fs::create_dir_all(out_dir).map_err(|e| file::io_error("创建贴图目录", out_dir, e))?;
let Some(textures) = optional_array_field(doc, "textures")? else {
return Ok(Vec::new());
};
let mut descs = Vec::with_capacity(textures.len());
for (index, texture) in textures.iter().enumerate() {
let texture_index = index + 1;
let width = required_u64_field(texture, "width")?;
let height = required_u64_field(texture, "height")?;
let source = required_string_field(texture, "source")?;
let encoded = source.strip_prefix(TEXTURE_DATA_PREFIX).ok_or_else(|| {
CliError::InvalidData(format!("textures[{}].source 不是内嵌 PNG data URL", index))
})?;
let bytes = STANDARD.decode(encoded).map_err(|e| {
CliError::InvalidData(format!("textures[{}].source base64 解码失败: {}", index, e))
})?;
validate_png(texture_index, &bytes, width, height)?;
let png_path = out_dir.join(format!("{}_{}.png", short_name, texture_index));
fs::write(&png_path, bytes).map_err(|e| file::io_error("写入贴图", &png_path, e))?;
let terrain_texture =
terrain_base.map(|base| format!("{}/{}_{}", base, short_name, texture_index));
descs.push(TextureDesc {
identifier: format!("{}:{}_{}", namespace, short_name, texture_index),
width,
height,
terrain_texture,
});
}
Ok(descs)
}
fn validate_png(texture_index: usize, bytes: &[u8], width: u64, height: u64) -> Result<()> {
const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
if bytes.len() < 24 || &bytes[..8] != PNG_SIGNATURE || &bytes[12..16] != b"IHDR" {
return Err(CliError::InvalidData(format!(
"textures[{}].source 解码结果不是有效 PNG",
texture_index - 1
)));
}
let actual_width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) as u64;
let actual_height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]) as u64;
if actual_width != width || actual_height != height {
return Err(CliError::InvalidData(format!(
"textures[{}] 尺寸不一致:字段为 {}x{}PNG IHDR 为 {}x{}",
texture_index - 1,
width,
height,
actual_width,
actual_height
)));
}
Ok(())
}
fn update_terrain_texture(res_pack: &Path, entries: &[TextureDesc]) -> Result<()> {
if entries.is_empty() {
return Ok(());
}
let terrain_path = res_pack.join("textures").join("terrain_texture.json");
if let Some(parent) = terrain_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| file::io_error("创建 terrain_texture 目录", parent, e))?;
}
let mut terrain = if terrain_path.exists() {
file::read_file_to_json(&terrain_path)?
} else {
json!({
"resource_pack_name": "vanilla",
"texture_data": {},
"texture_name": "atlas.terrain"
})
};
let terrain_obj = terrain.as_object_mut().ok_or_else(|| {
CliError::InvalidData(format!("{} 顶层必须是 JSON 对象", terrain_path.display()))
})?;
if !terrain_obj.contains_key("texture_data") {
terrain_obj.insert("texture_data".to_string(), json!({}));
}
let texture_data = terrain_obj
.get_mut("texture_data")
.and_then(Value::as_object_mut)
.ok_or_else(|| {
CliError::InvalidData(format!(
"{} 的 texture_data 必须是 JSON 对象",
terrain_path.display()
))
})?;
for entry in entries {
let texture_path = entry.terrain_texture.as_ref().ok_or_else(|| {
CliError::InvalidData(format!("{} 缺少 terrain_texture 路径", entry.identifier))
})?;
texture_data.insert(
entry.identifier.clone(),
json!({
"textures": texture_path
}),
);
}
file::write_json_to_file(&terrain_path, &terrain)
}
fn assemble_model_json(
identifier: &str,
bones: Vec<Value>,
texture_descs: &[TextureDesc],
) -> Value {
let textures = texture_descs
.iter()
.map(|texture| Value::String(texture.identifier.clone()))
.collect::<Vec<_>>();
let texture_descriptions = texture_descs
.iter()
.map(|texture| {
json!({
"width": texture.width,
"length": texture.height
})
})
.collect::<Vec<_>>();
json!({
"format_version": "1.13.0",
"netease:block_geometry": {
"bones": bones,
"description": {
"identifier": identifier,
"textures": textures,
"textures_descriptions": texture_descriptions,
"use_ao": false
}
}
})
}
fn required_string_field<'a>(value: &'a Value, field: &str) -> Result<&'a str> {
value
.get(field)
.and_then(Value::as_str)
.ok_or_else(|| CliError::InvalidData(format!("缺少字符串字段 {}", field)))
}
fn required_u64_field(value: &Value, field: &str) -> Result<u64> {
value
.get(field)
.and_then(Value::as_u64)
.ok_or_else(|| CliError::InvalidData(format!("缺少无符号整数字段 {}", field)))
}
fn required_array_field<'a>(value: &'a Value, field: &str) -> Result<&'a Vec<Value>> {
value
.get(field)
.and_then(Value::as_array)
.ok_or_else(|| CliError::InvalidData(format!("缺少数组字段 {}", field)))
}
fn optional_array_field<'a>(value: &'a Value, field: &str) -> Result<Option<&'a Vec<Value>>> {
match value.get(field) {
Some(Value::Array(items)) => Ok(Some(items)),
Some(_) => Err(CliError::InvalidData(format!("字段 {} 必须是数组", field))),
None => Ok(None),
}
}
fn required_object_field<'a>(value: &'a Value, field: &str) -> Result<&'a Map<String, Value>> {
value
.get(field)
.and_then(Value::as_object)
.ok_or_else(|| CliError::InvalidData(format!("缺少对象字段 {}", field)))
}
fn required_vec3(value: &Value, field: &str) -> Result<[f64; 3]> {
let array = required_array_field(value, field)?;
if array.len() != 3 {
return Err(CliError::InvalidData(format!(
"字段 {} 长度应为 3实际为 {}",
field,
array.len()
)));
}
Ok([
number_as_f64(&array[0], field)?,
number_as_f64(&array[1], field)?,
number_as_f64(&array[2], field)?,
])
}
fn optional_vec3(value: &Value, field: &str) -> Result<[f64; 3]> {
match value.get(field) {
Some(_) => required_vec3(value, field),
None => Ok([0.0, 0.0, 0.0]),
}
}
fn number_array(values: &[f64]) -> Result<Value> {
values
.iter()
.map(|value| number_value(*value))
.collect::<Result<Vec<_>>>()
.map(Value::Array)
}
fn number_value(value: f64) -> Result<Value> {
let value = if value == 0.0 { 0.0 } else { value };
Number::from_f64(value)
.map(Value::Number)
.ok_or_else(|| CliError::InvalidData(format!("无法写入非有限数字 {}", value)))
}
fn number_as_f64(value: &Value, field: &str) -> Result<f64> {
value
.as_f64()
.ok_or_else(|| CliError::InvalidData(format!("字段 {} 必须是数字", field)))
}
fn number_as_u64(value: &Value, field: &str) -> Result<u64> {
value
.as_u64()
.ok_or_else(|| CliError::InvalidData(format!("字段 {} 必须是非负整数", field)))
}
fn number_as_i64(value: &Value, field: &str) -> Result<i64> {
if let Some(value) = value.as_i64() {
return Ok(value);
}
let Some(value) = value.as_u64() else {
return Err(CliError::InvalidData(format!("字段 {} 必须是整数", field)));
};
i64::try_from(value).map_err(|_| CliError::InvalidData(format!("字段 {} 超出 i64 范围", field)))
}

View File

@@ -1,7 +1,7 @@
use crate::commands::ComponentsArgs; use crate::commands::ComponentsArgs;
use crate::entity; use crate::entity;
use crate::utils::file;
use crate::error::Result; use crate::error::Result;
use crate::utils::file;
use serde_json::{json, to_string_pretty}; use serde_json::{json, to_string_pretty};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -28,11 +28,12 @@ fn run_components(args: &ComponentsArgs) -> Result<()> {
args.geo.as_deref().unwrap_or("./model.geo.json"), args.geo.as_deref().unwrap_or("./model.geo.json"),
args.texture.as_deref().unwrap_or("./texture.png"), args.texture.as_deref().unwrap_or("./texture.png"),
identifier, identifier,
&project_path &project_path,
), ),
_ => Err(crate::error::CliError::NotFound( _ => Err(crate::error::CliError::NotFound(format!(
format!("组件 '{}' 不存在", args.component) "组件 '{}' 不存在",
)), args.component
))),
} }
} }
@@ -41,15 +42,17 @@ fn validate_input_files(geo: &Option<String>, texture: &Option<String>) -> Resul
let texture_path = texture.as_deref().unwrap_or("./texture.png"); let texture_path = texture.as_deref().unwrap_or("./texture.png");
if !PathBuf::from(geo_path).exists() { if !PathBuf::from(geo_path).exists() {
return Err(crate::error::CliError::NotFound( return Err(crate::error::CliError::NotFound(format!(
format!("几何文件 {} 不存在", geo_path) "几何文件 {} 不存在",
)); geo_path
)));
} }
if !PathBuf::from(texture_path).exists() { if !PathBuf::from(texture_path).exists() {
return Err(crate::error::CliError::NotFound( return Err(crate::error::CliError::NotFound(format!(
format!("材质文件 {} 不存在", texture_path) "材质文件 {} 不存在",
)); texture_path
)));
} }
Ok(()) Ok(())
@@ -137,12 +140,7 @@ fn create_resource_item_json(identifier: &str) -> serde_json::Value {
}) })
} }
fn copy_assets( fn copy_assets(res_path: &PathBuf, geo: &str, texture: &str, identifier: &str) -> Result<()> {
res_path: &PathBuf,
geo: &str,
texture: &str,
identifier: &str,
) -> Result<()> {
let f_identifier = identifier.replace(":", "_"); let f_identifier = identifier.replace(":", "_");
copy_texture(res_path, texture, &f_identifier)?; copy_texture(res_path, texture, &f_identifier)?;

View File

@@ -1,25 +1,20 @@
use crate::{ use crate::{
config::Config, commands::CreateArgs, entity::project::ProjectInfo, error::Result, template::TemplateEngine,
entity::project::ProjectInfo,
template::TemplateEngine,
utils::{file, http::HttpClient},
}; };
use std::{fs, path::PathBuf}; use std::{fs, path::PathBuf};
use crate::commands::CreateArgs;
use crate::error::Result;
use crate::utils::git;
use uuid::Uuid; use uuid::Uuid;
pub fn execute(args: &CreateArgs, temp_dir: &PathBuf) { include!(concat!(env!("OUT_DIR"), "/embedded_examples.rs"));
if let Err(e) = create_project(&args.name, args.target.as_deref(), temp_dir) {
eprintln!("错误: {}", e); pub fn execute(args: &CreateArgs) {
if let Err(e) = create_project(&args.name, args.target.as_deref()) {
eprintln!("Error: {}", e);
return; return;
} }
println!("成功: 项目已创建"); println!("Success: project created.");
} }
fn create_project(name: &str, target: Option<&str>, temp_dir: &PathBuf) -> Result<()> { fn create_project(name: &str, target: Option<&str>) -> Result<()> {
let target = target.unwrap_or("default"); let target = target.unwrap_or("default");
check_example_exists(target)?; check_example_exists(target)?;
@@ -27,30 +22,16 @@ fn create_project(name: &str, target: Option<&str>, temp_dir: &PathBuf) -> Resul
let local_dir = PathBuf::from(format!("./{}", name)); let local_dir = PathBuf::from(format!("./{}", name));
fs::create_dir(&local_dir)?; fs::create_dir(&local_dir)?;
let template_dir = clone_and_copy_template(target, temp_dir, &local_dir)?; copy_embedded_template(target, &local_dir)?;
initialize_project_with_template(&local_dir, &local_dir, name)?;
initialize_project_with_template(&template_dir, &local_dir, name)?;
Ok(()) Ok(())
} }
fn check_example_exists(target: &str) -> Result<()> { fn check_example_exists(target: &str) -> Result<()> {
let check_url = format!( if !embedded_example_exists(target) {
"https://api.github.com/repos/AiYo-Studio/emod-cli/contents/examples/{}",
target
);
let client = if cfg!(debug_assertions) {
HttpClient::new_with_proxy("http://127.0.0.1:1080")?
} else {
HttpClient::new()?
};
let resp = client.get(&check_url)?;
if !resp.status().is_success() {
return Err(crate::error::CliError::NotFound(format!( return Err(crate::error::CliError::NotFound(format!(
"示例模板 '{}' 不存在", "example target '{}' was not found in the built-in examples",
target target
))); )));
} }
@@ -58,21 +39,47 @@ fn check_example_exists(target: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn clone_and_copy_template( fn embedded_example_exists(target: &str) -> bool {
target: &str, let target = normalize_embedded_path(target);
temp_dir: &PathBuf, let prefix = format!("{}/", target);
local_dir: &PathBuf,
) -> Result<PathBuf> {
let _ = fs::remove_dir_all(format!("{}/tmp", temp_dir.display()));
let config = Config::load(); EMBEDDED_EXAMPLE_DIRS
let url = &config.repo_url; .iter()
git::clone_remote_project(url.to_string(), temp_dir)?; .any(|dir| *dir == target || dir.starts_with(&prefix))
|| EMBEDDED_EXAMPLE_FILES
.iter()
.any(|file| file.path.starts_with(&prefix))
}
let target_dir = PathBuf::from(format!("{}/tmp/examples/{}", temp_dir.display(), target)); fn copy_embedded_template(target: &str, local_dir: &PathBuf) -> Result<()> {
file::copy_folder(&target_dir, local_dir)?; let target = normalize_embedded_path(target);
let prefix = format!("{}/", target);
Ok(target_dir) for dir in EMBEDDED_EXAMPLE_DIRS {
if let Some(relative_path) = dir.strip_prefix(&prefix) {
if !relative_path.is_empty() {
fs::create_dir_all(local_dir.join(relative_path))?;
}
}
}
for file in EMBEDDED_EXAMPLE_FILES {
let Some(relative_path) = file.path.strip_prefix(&prefix) else {
continue;
};
let output_path = local_dir.join(relative_path);
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(output_path, file.contents)?;
}
Ok(())
}
fn normalize_embedded_path(path: &str) -> String {
path.trim_matches(['/', '\\']).replace('\\', "/")
} }
fn initialize_project_with_template( fn initialize_project_with_template(
@@ -80,14 +87,10 @@ fn initialize_project_with_template(
local_dir: &PathBuf, local_dir: &PathBuf,
name: &str, name: &str,
) -> Result<()> { ) -> Result<()> {
let lower_name = format!( let lower_name = lower_first_char(name)?;
"{}{}",
name.chars().next().unwrap().to_lowercase(),
&name[1..]
);
println!("项目名称: {}", name); println!("Project name: {}", name);
println!("标识名称: {}", lower_name); println!("Lower project name: {}", lower_name);
let project_info = generate_project_info(name, &lower_name); let project_info = generate_project_info(name, &lower_name);
@@ -124,10 +127,30 @@ fn initialize_project_with_template(
); );
engine.process_directory(local_dir)?; engine.process_directory(local_dir)?;
remove_template_config(local_dir)?;
Ok(()) Ok(())
} }
fn remove_template_config(local_dir: &PathBuf) -> Result<()> {
let template_config = local_dir.join("template.toml");
if template_config.exists() {
fs::remove_file(template_config)?;
}
Ok(())
}
fn lower_first_char(name: &str) -> Result<String> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return Err(crate::error::CliError::InvalidInput(
"project name cannot be empty".to_string(),
));
};
Ok(format!("{}{}", first.to_lowercase(), chars.as_str()))
}
fn generate_project_info(name: &str, lower_name: &str) -> ProjectInfo { fn generate_project_info(name: &str, lower_name: &str) -> ProjectInfo {
ProjectInfo { ProjectInfo {
name: name.to_string(), name: name.to_string(),

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, args.new_world) {
eprintln!("Error: {}", err);
}
}

67
src/commands/init.rs Normal file
View File

@@ -0,0 +1,67 @@
use std::fs;
use crate::commands::InitArgs;
use crate::entity;
use crate::error::{CliError, Result};
use crate::utils::file;
include!(concat!(env!("OUT_DIR"), "/embedded_examples.rs"));
pub fn execute(args: &InitArgs) {
if let Err(e) = run_init(args) {
eprintln!("❌ 项目初始化失败: {}", e);
return;
}
println!("🍀 项目初始化完成");
}
fn run_init(args: &InitArgs) -> Result<()> {
let project_dir = file::find_project_dir(&args.path)?;
let target = args.target.as_deref().unwrap_or("default");
let empty_dirs = lookup_empty_dirs(target)?;
let release_info = entity::get_current_release_info(&project_dir)?;
let behavior_pack = format!("behavior_pack_{}", release_info.behavior_identifier);
let resource_pack = format!("resource_pack_{}", release_info.resource_identifier);
let mut created = 0usize;
let mut skipped = 0usize;
for rel in empty_dirs {
let rewritten = rewrite_pack_path(rel, &behavior_pack, &resource_pack);
let abs = project_dir.join(&rewritten);
if abs.is_dir() {
skipped += 1;
continue;
}
fs::create_dir_all(&abs).map_err(|e| file::io_error("创建目录", &abs, e))?;
println!("📁 创建目录: {}", rewritten);
created += 1;
}
println!("✅ 新建 {} 个目录, 跳过 {} 个已存在目录", created, skipped);
Ok(())
}
fn lookup_empty_dirs(target: &str) -> Result<&'static [&'static str]> {
EMBEDDED_EXAMPLE_EMPTY_DIRS
.iter()
.find(|e| e.example == target)
.map(|e| e.dirs)
.ok_or_else(|| {
CliError::NotFound(format!(
"example target '{}' was not found in the built-in examples",
target
))
})
}
fn rewrite_pack_path(rel: &str, behavior: &str, resource: &str) -> String {
if let Some(rest) = rel.strip_prefix("behavior_pack/") {
format!("{}/{}", behavior, rest)
} else if let Some(rest) = rel.strip_prefix("resource_pack/") {
format!("{}/{}", resource, rest)
} else {
rel.to_string()
}
}

View File

@@ -1,7 +1,10 @@
use clap::{arg, Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand, arg};
pub mod bbmodel;
pub mod components; pub mod components;
pub mod create; pub mod create;
pub mod debug;
pub mod init;
pub mod release; pub mod release;
#[derive(Parser)] #[derive(Parser)]
@@ -24,8 +27,14 @@ pub enum Commands {
Release(ReleaseArgs), Release(ReleaseArgs),
/// Create a new mod project /// Create a new mod project
Create(CreateArgs), Create(CreateArgs),
/// Initialize standard empty directories for an existing project
Init(InitArgs),
/// Create a new component /// Create a new component
Components(ComponentsArgs), Components(ComponentsArgs),
/// Launch NetEase Minecraft with debug MOD, IPC logging, and hot reload
Debug(DebugArgs),
/// Convert Blockbench .bbmodel to NetEase block geometry
Bbmodel(BbmodelArgs),
} }
#[derive(Args)] #[derive(Args)]
@@ -34,8 +43,12 @@ pub struct ReleaseArgs {
#[arg(short, long)] #[arg(short, long)]
pub path: Option<String>, pub path: Option<String>,
/// The version of the project /// The version of the project
#[arg(short, long)] #[arg(short, long, conflicts_with = "pin")]
pub ver: Option<String>, pub ver: Option<String>,
/// Reuse the current version without auto-incrementing.
/// Useful when retrying after a failed release that already wrote new version files.
#[arg(short = 'P', long, conflicts_with = "ver")]
pub pin: bool,
} }
#[derive(Args)] #[derive(Args)]
@@ -48,6 +61,16 @@ pub struct CreateArgs {
pub target: Option<String>, pub target: Option<String>,
} }
#[derive(Args)]
pub struct InitArgs {
/// The path of the project (default: current directory)
#[arg(short, long)]
pub path: Option<String>,
/// Example target whose layout to apply
#[arg(short, long)]
pub target: Option<String>,
}
#[derive(Args)] #[derive(Args)]
pub struct ComponentsArgs { pub struct ComponentsArgs {
/// The path of the project /// The path of the project
@@ -64,5 +87,34 @@ pub struct ComponentsArgs {
pub texture: Option<String>, pub texture: Option<String>,
/// The item's identifier /// The item's identifier
#[arg(short, long)] #[arg(short, long)]
pub identifier: Option<String> pub identifier: Option<String>,
}
#[derive(Args)]
pub struct BbmodelArgs {
/// The path of the project
#[arg(short, long)]
pub path: Option<String>,
/// Import the path of the .bbmodel file.
#[arg(short, long)]
pub input: Option<String>,
/// The block identifier, for example abyss:tree_spirit_block
#[arg(short = 'd', long)]
pub identifier: String,
/// Standalone output directory. When set, resource pack files are not updated.
#[arg(short, long)]
pub output: Option<String>,
/// Skip texture export and terrain_texture.json registration.
#[arg(long)]
pub no_textures: bool,
}
#[derive(Args)]
pub struct DebugArgs {
/// The path of the project (default: current directory)
#[arg(short, long)]
pub path: Option<String>,
/// Create a fresh debug world and persist it in .mcdev.json
#[arg(short = 'n', long = "new")]
pub new_world: bool,
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
#[serde(default = "default_repo_url")]
pub repo_url: String,
}
fn default_repo_url() -> String {
"https://github.com/AiYo-Studio/emod-cli.git".to_string()
}
impl Default for Config {
fn default() -> Self {
Self {
repo_url: default_repo_url(),
}
}
}
impl Config {
pub fn load() -> Self {
let config_path = Self::config_path();
if let Ok(content) = fs::read_to_string(&config_path) {
if let Ok(config) = serde_json::from_str(&content) {
return config;
}
}
Self::default()
}
fn config_path() -> PathBuf {
let home = dirs::home_dir().expect("无法获取用户主目录");
home.join(".emod-cli.json")
}
}

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}"
);
}
}

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

@@ -0,0 +1,425 @@
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(())
}
pub fn allocate_new_world(project_dir: &Path, config: &mut DebugConfig) -> Result<()> {
let world_name = format!(
"MC_DEV_WORLD_{}",
crate::debug::env::local_timestamp_compact()
);
config.world_folder_name = world_name.clone();
config.world_name = world_name;
write_config(project_dir, config)
}
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 std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use super::{allocate_new_world, resolve_mod_dir, strip_json_comments};
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-cli-debug-config-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);
}
}
#[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 allocate_new_world_updates_folder_and_name() {
let project = TempProject::new();
let mut config = super::create_default_config();
allocate_new_world(&project.path, &mut config).unwrap();
let suffix = config
.world_folder_name
.strip_prefix("MC_DEV_WORLD_")
.expect("world folder should use debug prefix");
assert_eq!(suffix.len(), "YYYYMMDD_HHMMSS".len());
assert_eq!(suffix.as_bytes()[8], b'_');
assert!(
suffix
.bytes()
.enumerate()
.all(|(index, byte)| index == 8 || byte.is_ascii_digit())
);
assert_eq!(config.world_name, config.world_folder_name);
let persisted = super::load_or_create(&project.path).unwrap();
assert_eq!(persisted.world_folder_name, config.world_folder_name);
assert_eq!(persisted.world_name, config.world_folder_name);
}
#[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);
}
}

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

@@ -0,0 +1,157 @@
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")
}
#[cfg(windows)]
pub fn local_timestamp_compact() -> String {
use windows_sys::Win32::{Foundation::SYSTEMTIME, System::SystemInformation::GetLocalTime};
let mut now = SYSTEMTIME {
wYear: 0,
wMonth: 0,
wDayOfWeek: 0,
wDay: 0,
wHour: 0,
wMinute: 0,
wSecond: 0,
wMilliseconds: 0,
};
unsafe {
GetLocalTime(&mut now);
}
format!(
"{:04}{:02}{:02}_{:02}{:02}{:02}",
now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute, now.wSecond
)
}
#[cfg(not(windows))]
pub fn local_timestamp_compact() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = (now / 86_400) as i64;
let seconds_of_day = now % 86_400;
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
format!("{year:04}{month:02}{day:02}_{hour:02}{minute:02}{second:02}")
}
#[cfg(not(windows))]
fn civil_from_days(days_since_unix_epoch: i64) -> (i32, u32, u32) {
let z = days_since_unix_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let day_of_era = z - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_part = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_part + 2) / 5 + 1;
let month = month_part + if month_part < 10 { 3 } else { -9 };
let year = year + if month <= 2 { 1 } else { 0 };
(year as i32, month as u32, day as u32)
}
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"#
);
}
}

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

@@ -0,0 +1,49 @@
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, new_world: bool) -> Result<()> {
let mut config = config::load_or_create(project_dir)?;
if new_world && !config::env_is_subprocess_mode() {
config::allocate_new_world(project_dir, &mut config)?;
println!("[MCDK] 创建新存档:{}", config.world_folder_name);
}
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,13 +1,11 @@
use std::io;
use std::fmt; use std::fmt;
use std::io;
use std::num::ParseIntError; use std::num::ParseIntError;
#[derive(Debug)] #[derive(Debug)]
pub enum CliError { pub enum CliError {
Io(io::Error), Io(io::Error),
Json(serde_json::Error), Json(serde_json::Error),
Network(reqwest::Error),
Anyhow(anyhow::Error),
Zip(zip::result::ZipError), Zip(zip::result::ZipError),
Walkdir(walkdir::Error), Walkdir(walkdir::Error),
Parse(ParseIntError), Parse(ParseIntError),
@@ -20,17 +18,15 @@ pub enum CliError {
impl fmt::Display for CliError { impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
CliError::Io(e) => write!(f, "IO错误: {}", e), CliError::Io(e) => write!(f, "I/O error: {}", e),
CliError::Json(e) => write!(f, "JSON解析错误: {}", e), CliError::Json(e) => write!(f, "JSON parse error: {}", e),
CliError::Network(e) => write!(f, "网络错误: {}", e), CliError::Zip(e) => write!(f, "ZIP error: {}", e),
CliError::Anyhow(e) => write!(f, "{}", e), CliError::Walkdir(e) => write!(f, "directory traversal error: {}", e),
CliError::Zip(e) => write!(f, "压缩错误: {}", e), CliError::Parse(e) => write!(f, "parse error: {}", e),
CliError::Walkdir(e) => write!(f, "目录遍历错误: {}", e), CliError::Toml(e) => write!(f, "TOML parse error: {}", e),
CliError::Parse(e) => write!(f, "解析错误: {}", e), CliError::NotFound(msg) => write!(f, "not found: {}", msg),
CliError::Toml(e) => write!(f, "TOML解析错误: {}", e), CliError::InvalidData(msg) => write!(f, "invalid data: {}", msg),
CliError::NotFound(msg) => write!(f, "未找到: {}", msg), CliError::InvalidInput(msg) => write!(f, "invalid input: {}", msg),
CliError::InvalidData(msg) => write!(f, "无效数据: {}", msg),
CliError::InvalidInput(msg) => write!(f, "无效输入: {}", msg),
} }
} }
} }
@@ -49,18 +45,6 @@ impl From<serde_json::Error> for CliError {
} }
} }
impl From<reqwest::Error> for CliError {
fn from(err: reqwest::Error) -> Self {
CliError::Network(err)
}
}
impl From<anyhow::Error> for CliError {
fn from(err: anyhow::Error) -> Self {
CliError::Anyhow(err)
}
}
impl From<zip::result::ZipError> for CliError { impl From<zip::result::ZipError> for CliError {
fn from(err: zip::result::ZipError) -> Self { fn from(err: zip::result::ZipError) -> Self {
CliError::Zip(err) CliError::Zip(err)

View File

@@ -1,29 +1,21 @@
mod commands; mod commands;
mod debug;
mod entity; mod entity;
mod utils;
mod error; mod error;
mod config;
mod template; mod template;
mod utils;
use crate::commands::{Cli, Commands}; use crate::commands::{Cli, Commands};
use clap::Parser; use clap::Parser;
use std::{env, fs, path::PathBuf};
fn main() { fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let temp_dir = check_temp_dir();
match &cli.command { match &cli.command {
Commands::Release(args) => commands::release::execute(args), Commands::Release(args) => commands::release::execute(args),
Commands::Create(args) => commands::create::execute(args, &temp_dir), Commands::Create(args) => commands::create::execute(args),
Commands::Init(args) => commands::init::execute(args),
Commands::Components(args) => commands::components::execute(args), Commands::Components(args) => commands::components::execute(args),
Commands::Debug(args) => commands::debug::execute(args),
Commands::Bbmodel(args) => commands::bbmodel::execute(args),
} }
} }
fn check_temp_dir() -> PathBuf {
let mut temp_dir = env::temp_dir();
temp_dir.push("emod-cli");
if let Err(e) = fs::create_dir_all(&temp_dir) {
eprintln!("Error: Failed to create temp directory: {}", e);
}
temp_dir
}

View File

@@ -1,8 +1,8 @@
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use regex::Regex;
use walkdir::WalkDir; use walkdir::WalkDir;
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
@@ -62,7 +62,7 @@ impl TemplateEngine {
for (key, var_config) in &self.config.variables { for (key, var_config) in &self.config.variables {
if var_config.required && !self.variables.contains_key(key) { if var_config.required && !self.variables.contains_key(key) {
return Err(crate::error::CliError::InvalidInput(format!( return Err(crate::error::CliError::InvalidInput(format!(
"缺少必需的变量: {} ({})", "missing required template variable: {} ({})",
key, var_config.description key, var_config.description
))); )));
} }
@@ -72,31 +72,19 @@ impl TemplateEngine {
pub fn process_directory(&self, dir: &Path) -> crate::error::Result<()> { pub fn process_directory(&self, dir: &Path) -> crate::error::Result<()> {
self.validate_variables()?; self.validate_variables()?;
self.replace_in_files(dir)?; self.replace_in_files(dir)?;
self.apply_renames(dir)?; self.apply_renames(dir)?;
self.verify_no_placeholders(dir)?; self.verify_no_placeholders(dir)?;
Ok(()) Ok(())
} }
fn replace_in_files(&self, dir: &Path) -> crate::error::Result<()> { fn replace_in_files(&self, dir: &Path) -> crate::error::Result<()> {
let placeholder_regex = Regex::new(r"\{\{(\w+)\}\}").unwrap(); let placeholder_regex = placeholder_regex();
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path(); let path = entry.path();
if !path.is_file() { if !path.is_file() || !self.should_process(path) {
continue;
}
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if !self.config.process.file_extensions.contains(&ext.to_string()) {
continue;
}
} else {
continue; continue;
} }
@@ -105,7 +93,7 @@ impl TemplateEngine {
for cap in placeholder_regex.captures_iter(&content) { for cap in placeholder_regex.captures_iter(&content) {
let placeholder = &cap[0]; let placeholder = &cap[0];
let var_name = &cap[1]; let var_name = placeholder_name(&cap);
if let Some(value) = self.variables.get(var_name) { if let Some(value) = self.variables.get(var_name) {
updated = updated.replace(placeholder, value); updated = updated.replace(placeholder, value);
@@ -114,7 +102,7 @@ impl TemplateEngine {
if updated != content { if updated != content {
fs::write(path, updated)?; fs::write(path, updated)?;
println!(" - 处理文件: {}", path.display()); println!(" - processed template file: {}", path.display());
} }
} }
@@ -122,7 +110,7 @@ impl TemplateEngine {
} }
fn apply_renames(&self, dir: &Path) -> crate::error::Result<()> { fn apply_renames(&self, dir: &Path) -> crate::error::Result<()> {
for rule in self.config.renames.iter().rev() { for rule in &self.config.renames {
let from_path = dir.join(&rule.from); let from_path = dir.join(&rule.from);
if !from_path.exists() { if !from_path.exists() {
@@ -137,19 +125,19 @@ impl TemplateEngine {
} }
fs::rename(&from_path, &to_path)?; fs::rename(&from_path, &to_path)?;
println!(" - 重命名: {} -> {}", rule.from, to_path_str); println!(" - renamed: {} -> {}", rule.from, to_path_str);
} }
Ok(()) Ok(())
} }
fn replace_placeholders(&self, text: &str) -> String { fn replace_placeholders(&self, text: &str) -> String {
let placeholder_regex = Regex::new(r"\{\{(\w+)\}\}").unwrap(); let placeholder_regex = placeholder_regex();
let mut result = text.to_string(); let mut result = text.to_string();
for cap in placeholder_regex.captures_iter(text) { for cap in placeholder_regex.captures_iter(text) {
let placeholder = &cap[0]; let placeholder = &cap[0];
let var_name = &cap[1]; let var_name = placeholder_name(&cap);
if let Some(value) = self.variables.get(var_name) { if let Some(value) = self.variables.get(var_name) {
result = result.replace(placeholder, value); result = result.replace(placeholder, value);
@@ -160,39 +148,58 @@ impl TemplateEngine {
} }
fn verify_no_placeholders(&self, dir: &Path) -> crate::error::Result<()> { fn verify_no_placeholders(&self, dir: &Path) -> crate::error::Result<()> {
let placeholder_regex = Regex::new(r"\{\{(\w+)\}\}").unwrap(); let placeholder_regex = placeholder_regex();
let mut found_placeholders = Vec::new(); let mut found_placeholders = Vec::new();
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path(); let path = entry.path();
if !path.is_file() { if !path.is_file() || !self.should_process(path) {
continue;
}
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if !self.config.process.file_extensions.contains(&ext.to_string()) {
continue;
}
} else {
continue; continue;
} }
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
for cap in placeholder_regex.captures_iter(&content) { for cap in placeholder_regex.captures_iter(&content) {
let var_name = &cap[1]; let var_name = placeholder_name(&cap);
if !self.config.variables.contains_key(var_name) {
continue;
}
found_placeholders.push(format!("{}:{}", path.display(), var_name)); found_placeholders.push(format!("{}:{}", path.display(), var_name));
} }
} }
if !found_placeholders.is_empty() { if !found_placeholders.is_empty() {
eprintln!("警告: 发现未替换的占位符:"); return Err(crate::error::CliError::InvalidData(format!(
for placeholder in found_placeholders { "unresolved template placeholders: {}",
eprintln!(" - {}", placeholder); found_placeholders.join(", ")
} )));
} }
Ok(()) Ok(())
} }
fn should_process(&self, path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.map(|ext| {
self.config
.process
.file_extensions
.iter()
.any(|configured| configured == ext)
})
.unwrap_or(false)
}
}
fn placeholder_regex() -> Regex {
Regex::new(r"\{\{(\w+)\}\}|__(\w+)__").unwrap()
}
fn placeholder_name<'a>(cap: &'a regex::Captures<'a>) -> &'a str {
cap.get(1)
.or_else(|| cap.get(2))
.expect("placeholder capture is missing")
.as_str()
} }

View File

@@ -1,39 +1,21 @@
use crate::error::Result; use crate::error::{CliError, Result};
use serde_json::Value; use serde_json::Value;
use std::{fs, path::PathBuf}; use std::{
fs, io,
pub fn copy_folder(src: &PathBuf, dest: &PathBuf) -> Result<()> { path::{Path, PathBuf},
if !src.exists() || !src.is_dir() { };
return Err(crate::error::CliError::NotFound(format!(
"源目录不存在: {}",
src.display()
)));
}
if !dest.exists() {
fs::create_dir_all(dest)?;
}
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dest_path = dest.join(src_path.file_name().unwrap());
if src_path.is_file() {
fs::copy(&src_path, &dest_path)?;
} else if src_path.is_dir() {
copy_folder(&src_path, &dest_path)?;
}
}
Ok(())
}
pub fn read_file_to_json(path: &PathBuf) -> Result<Value> { pub fn read_file_to_json(path: &PathBuf) -> Result<Value> {
let file = fs::read_to_string(path)?; let content = fs::read_to_string(path).map_err(|e| io_error("读取文件", path, e))?;
let json: Value = serde_json::from_str(&file)?; serde_json::from_str(&content)
Ok(json) .map_err(|e| CliError::InvalidData(format!("解析 JSON '{}' 失败: {}", path.display(), e)))
} }
pub fn write_json_to_file(path: &PathBuf, value: &Value) -> Result<()> { pub fn write_json_to_file(path: &PathBuf, value: &Value) -> Result<()> {
let content = serde_json::to_string_pretty(value)?; let content = serde_json::to_string_pretty(value).map_err(|e| {
fs::write(path, content)?; CliError::InvalidData(format!("序列化 JSON '{}' 失败: {}", path.display(), e))
})?;
fs::write(path, content).map_err(|e| io_error("写入文件", path, e))?;
Ok(()) Ok(())
} }
@@ -51,3 +33,12 @@ pub fn find_project_dir(path: &Option<String>) -> Result<PathBuf> {
let path = path.as_deref().unwrap_or("."); let path = path.as_deref().unwrap_or(".");
Ok(PathBuf::from(path)) Ok(PathBuf::from(path))
} }
/// Wraps an [`io::Error`] with the operation name and path so error messages
/// stop being anonymous "os error 3" strings.
pub fn io_error(op: &str, path: &Path, err: io::Error) -> CliError {
CliError::Io(io::Error::new(
err.kind(),
format!("{} '{}' 失败: {}", op, path.display(), err),
))
}

View File

@@ -1,11 +0,0 @@
use std::path::PathBuf;
use crate::error::Result;
pub fn clone_remote_project(url: String, temp_dir: &PathBuf) -> Result<()> {
std::process::Command::new("git")
.arg("clone")
.arg(url)
.arg(format!("{}/tmp", temp_dir.display()))
.output()?;
Ok(())
}

View File

@@ -1,26 +0,0 @@
use reqwest::blocking::Client;
use crate::error::Result;
pub struct HttpClient {
client: Client,
}
impl HttpClient {
pub fn new() -> Result<Self> {
let client = Client::builder().build()?;
Ok(Self { client })
}
pub fn new_with_proxy(proxy_url: &str) -> Result<Self> {
let proxy = reqwest::Proxy::all(proxy_url)?;
let client = Client::builder().proxy(proxy).build()?;
Ok(Self { client })
}
pub fn get(&self, url: &str) -> Result<reqwest::blocking::Response> {
Ok(self.client
.get(url)
.header("User-Agent", "emod-cli")
.send()?)
}
}

View File

@@ -1,3 +1 @@
pub mod git;
pub mod file; pub mod file;
pub mod http;