Files
emod-cli/src/debug/nbt.rs
Blank038 011b59c948 feat(debug): 支持网易 Minecraft 调试启动
新增 debug 子命令,自动准备开发世界、注册内置调试 MOD,并将项目行为包和资源包链接到网易运行目录,方便启动游戏后直接进入调试世界。

调试 MOD 资源随仓库一起嵌入,避免依赖本机绝对路径;Windows junction 写入剥离 verbatim 前缀后的 DOS 路径,保证 Minecraft 能正确读取链接包。
2026-05-16 00:59:51 +08:00

496 lines
16 KiB
Rust

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