Complete local voxel MVP with server, client, MCP, packages and world interchange

This commit is contained in:
Emil
2026-09-14 17:35:17 +03:00
parent cf8d1aae2e
commit b6ba064339
98 changed files with 58116 additions and 194 deletions
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "shacraft-compat"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
clap.workspace = true
shacraft-core.workspace = true
flate2 = "1"
sha2 = "0.10"
tempfile = "3"
cesu8 = "1"
lz4_flex = "0.13"
xxhash-rust = { version = "0.8", features = ["xxh32"] }
rustix = { version = "1", features = ["fs"] }
rusqlite = { version = "=0.40.2", features = ["bundled"] }
[dev-dependencies]
@@ -0,0 +1 @@
own fixture; retain unknown file
@@ -0,0 +1,55 @@
{
"schema_version": 1,
"source": "Own minimal fixtures; generated through public Java26.2 APIs and nbtlib2.0.4, no game assets",
"minecraft_version": "26.2",
"data_version": 4903,
"java_runtime": "Microsoft OpenJDK25.0.1+8",
"server_jar_sha1": "823e2250d24b3ddac457a60c92a6a941943fcd6a",
"server_jar_sha256": "cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5",
"generator_files": [
"scripts/compat_java.java",
"scripts/compat_fixtures.py"
],
"files": {
"java26_2-world/custom-metadata.txt": {
"bytes": 33,
"sha256": "9d6851a4b4ad57e9a1a49d3e709cfe4de698ddb1c9add85c3afd2e38bac90ac5"
},
"java26_2-world/data/minecraft/game_rules.dat": {
"bytes": 821,
"sha256": "566d8bf7c24388101264e650bcfad24f79446758adcaf505fe5276aa0484b9ba"
},
"java26_2-world/data/minecraft/world_gen_settings.dat": {
"bytes": 301,
"sha256": "0c100210ac72c9b390c1d64287e3a57bfb40cb39c2651948d38b4d223d5c296d"
},
"java26_2-world/entities/r.-1.0.mca": {
"bytes": 12288,
"sha256": "c53c1c8c0e75ad16d3a2c7744fc92e2df4115a0423972846796d8385be831a80"
},
"java26_2-world/level.dat": {
"bytes": 309,
"sha256": "e22ec9c3da109bdb6f6c0677712d8a7767eee0d4b2b45dc953113d34b40885df"
},
"java26_2-world/region/r.-1.0.mca": {
"bytes": 12288,
"sha256": "6fc56ee0177ca0ab8eab1c1858a0641ed2efaa8230f25e6f012ec4ff42dd9424"
},
"sponge-v3.schem": {
"bytes": 648,
"sha256": "cd4041dce3e12af60c80931b730d89b8fef844fe65e1c5dcca12ba6f656d5801"
},
"world-template/data/minecraft/game_rules.dat": {
"bytes": 821,
"sha256": "a2fe317c7838ab52ac17d1d4ad74491351783bdf71de5707d2a47328e849e646"
},
"world-template/data/minecraft/world_gen_settings.dat": {
"bytes": 301,
"sha256": "0c100210ac72c9b390c1d64287e3a57bfb40cb39c2651948d38b4d223d5c296d"
},
"world-template/level.dat": {
"bytes": 312,
"sha256": "b97ea770237150db83d5ed398086b1ca5d4d9de5a5a25fd167f971a89ae59cf4"
}
}
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
+677
View File
@@ -0,0 +1,677 @@
//! Offline bridge to the server's persistent entity metadata. Original typed
//! NBT is pinned per UUID; a bounded visual subset is editable by the server.
use crate::{
Mode, Provenance, Report, io,
nbt::{self, Compound, Tag, compound, get},
};
use anyhow::{Context, Result, ensure};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, fs, path::Path};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct Entity {
pub id: String,
pub world: String,
pub kind: String,
#[serde(rename = "type")]
pub entity_type: String,
pub position: [f64; 3],
pub yaw: f64,
pub properties: Value,
}
type Entities = BTreeMap<String, Entity>;
const MAX_ENTITIES: usize = 4096;
pub(crate) fn fingerprint(entities: &Entities) -> Result<String> {
Ok(format!(
"{:x}",
Sha256::digest(serde_json::to_vec(entities)?)
))
}
pub(crate) fn read_native(root: &Path) -> Result<Entities> {
let p = root.join("server.sqlite3");
if !p.exists() {
return Ok(BTreeMap::new());
}
let conn = Connection::open_with_flags(p, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
let data: String = conn.query_row(
"SELECT json FROM metadata WHERE id=1 AND length(json)<=8388608",
[],
|r| r.get(0),
)?;
let value: Value = serde_json::from_str(&data)?;
let entities: Entities =
serde_json::from_value(value.get("entities").cloned().unwrap_or_else(|| json!({})))?;
ensure!(
entities.len() <= MAX_ENTITIES,
"entity_limit: native entity count"
);
for (id, e) in &entities {
ensure!(
id.len() == 36
&& id
.bytes()
.enumerate()
.all(|(i, b)| if [8, 13, 18, 23].contains(&i) {
b == b'-'
} else {
b.is_ascii_hexdigit()
}),
"entity_id: UUID string required"
);
ensure!(
id == &e.id
&& e.position
.iter()
.all(|v| v.is_finite() && v.abs() <= 30_000_000.)
&& e.yaw.is_finite(),
"entity_invalid: identity/coordinates"
);
ensure!(
serde_json::to_vec(&e.properties)?.len() <= 16384,
"entity_limit: properties"
);
}
Ok(entities)
}
pub(crate) fn changed(root: &Path, p: &Provenance) -> Result<bool> {
if let Some(hash) = &p.entity_fingerprint {
Ok(&fingerprint(&read_native(root)?)? != hash)
} else {
Ok(!read_native(root)?.is_empty())
}
}
fn baseline(root: &Path, p: Option<&Provenance>) -> Result<Entities> {
let path = root.join("compat/native-entities.json");
if !path.exists() {
return Ok(BTreeMap::new());
}
let result: Entities = serde_json::from_slice(&io::read_limited(&path, 8 * 1024 * 1024)?)?;
ensure!(result.len() <= MAX_ENTITIES, "entity_limit: baseline");
if let Some(hash) = p.and_then(|p| p.entity_fingerprint.as_ref()) {
ensure!(
&fingerprint(&result)? == hash,
"provenance_corrupt: entity baseline hash"
);
}
Ok(result)
}
fn uuid(tag: &Tag) -> Result<String> {
let c = tag.compound()?;
let bytes = if let Some(Tag::IntArray(uuid)) = c.get("UUID") {
if uuid.len() == 4 {
uuid.iter()
.flat_map(|n| n.to_be_bytes())
.collect::<Vec<_>>()
} else {
anyhow::bail!("entity_uuid: length")
}
} else {
Sha256::digest(nbt::encode("", tag)?)[..16].to_vec()
};
let hex = bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
Ok(format!(
"{}-{}-{}-{}-{}",
&hex[..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..]
))
}
fn uuid_tag(id: &str) -> Tag {
let hex = id.replace('-', "");
let bytes = if hex.len() == 32 {
(0..32)
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap_or_else(|_| Sha256::digest(id.as_bytes())[..16].to_vec())
} else {
Sha256::digest(id.as_bytes())[..16].to_vec()
};
Tag::IntArray(
bytes
.chunks_exact(4)
.map(|b| i32::from_be_bytes(b.try_into().expect("four bytes")))
.collect(),
)
}
fn number(t: &Tag) -> Result<f64> {
match t {
Tag::Double(v) => Ok(f64::from_bits(*v)),
Tag::Float(v) => Ok(f32::from_bits(*v) as f64),
_ => anyhow::bail!("entity_position: floating point expected"),
}
}
fn from_tag(tag: &Tag, world: &str) -> Result<Entity> {
let c = tag.compound()?;
let kind = get(c, "id")?.string()?.to_owned();
ensure!(kind.len() <= 512, "entity_limit: kind identifier");
let pos = get(c, "Pos")?.list(6)?;
ensure!(pos.len() == 3, "entity_position: 3 coordinates required");
let position = [number(&pos[0])?, number(&pos[1])?, number(&pos[2])?];
ensure!(
position
.iter()
.all(|v| v.is_finite() && v.abs() <= 30_000_000.),
"entity_position: range"
);
let yaw = c
.get("Rotation")
.map(|r| r.list(5))
.transpose()?
.and_then(|r| r.first())
.map(number)
.transpose()?
.unwrap_or(0.)
.to_radians();
ensure!(yaw.is_finite(), "entity_yaw: finite required");
let mut properties = serde_json::Map::new();
for key in [
"Health",
"CustomName",
"NoGravity",
"Invisible",
"Invulnerable",
"Glowing",
"Silent",
"CustomNameVisible",
] {
if let Some(t) = c.get(key) {
let v = match t {
Tag::Byte(v) => json!(*v != 0),
Tag::Float(v) => json!(f32::from_bits(*v)),
Tag::String(v) => json!(v),
_ => continue,
};
properties.insert(key.into(), v);
}
}
let id = uuid(tag)?;
ensure!(
serde_json::to_vec(&properties)?.len() <= 16384,
"entity_limit: visual properties"
);
Ok(Entity {
id,
world: world.into(),
entity_type: kind.clone(),
kind,
position,
yaw,
properties: Value::Object(properties),
})
}
fn keep(
root: &Path,
tag: &Tag,
world: &str,
entities: &mut Entities,
report: &mut Report,
) -> Result<()> {
let entity = match from_tag(tag, world) {
Ok(e) => e,
Err(e) => {
report.issue(
"entity_visual_unavailable",
"preserved",
world,
format!("Typed NBT retained; cannot bridge entity: {e}"),
);
return Ok(());
}
};
let ambiguous = root.join("compat/ambiguous-uuids").join(&entity.id);
if ambiguous.exists() {
report.issue(
"entity_duplicate_uuid",
"preserved",
&entity.id,
"Ambiguous UUID excluded from editable subset; all records retained in source",
);
return Ok(());
}
if let Some(previous) = entities.remove(&entity.id) {
report.native_entity_bytes = report
.native_entity_bytes
.saturating_sub((serde_json::to_vec(&previous)?.len() + previous.id.len() + 8) as u64);
fs::create_dir_all(ambiguous.parent().context("ambiguous UUID parent")?)?;
fs::write(&ambiguous, b"ambiguous UUID; preserve source records\n")?;
fs::remove_file(
root.join("compat/entities")
.join(format!("{}.nbt", entity.id)),
)?;
report.issue(
"entity_duplicate_uuid",
"preserved",
&entity.id,
"All duplicate UUID records remain in source; excluded from editable subset",
);
return Ok(());
}
if entities.len() >= MAX_ENTITIES {
report.issue("entity_visual_limit","preserved",&entity.id,"More than 4096 entities; additional records remain in disk source and roundtrip unchanged");
return Ok(());
}
let bytes = (serde_json::to_vec(&entity)?.len() + entity.id.len() + 8) as u64;
if report.native_entity_bytes + bytes > 6 * 1024 * 1024 {
report.issue("entity_visual_byte_limit","preserved",&entity.id,"Native entity subset exceeds 6 MiB; source NBT remains on disk. Reserved space keeps full server metadata below 8 MiB.");
return Ok(());
}
report.native_entity_bytes += bytes;
let path = root.join("compat/entities");
fs::create_dir_all(&path)?;
fs::write(
path.join(format!("{}.nbt", entity.id)),
nbt::encode("", tag)?,
)?;
entities.insert(entity.id.clone(), entity);
Ok(())
}
fn save(root: &Path, p: &mut Provenance, entities: Entities) -> Result<()> {
let worlds = p
.worlds
.keys()
.map(|w| {
let spawn = if let (Some(min), Some(max)) = (p.schem_min, p.schem_max) {
[min[0] as f64 + 0.5, max[1] as f64 + 2., min[2] as f64 + 2.5]
} else {
[0., 80., 0.]
};
(w.clone(), json!({"spawn":spawn,"mode":"creative"}))
})
.collect::<serde_json::Map<_, _>>();
let value = json!({"worlds":worlds,"entities":entities,"revision":0});
ensure!(
serde_json::to_vec(&value)?.len() <= 8 * 1024 * 1024,
"entity_limit: server metadata exceeds 8 MiB"
);
let conn = Connection::open(root.join("server.sqlite3"))?;
conn.execute_batch("PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; CREATE TABLE metadata(id INTEGER PRIMARY KEY CHECK(id=1),json TEXT NOT NULL);")?;
conn.execute(
"INSERT INTO metadata(id,json)VALUES(1,?1)",
[serde_json::to_string(&value)?],
)?;
p.entity_fingerprint = Some(fingerprint(&entities)?);
for id in entities.keys() {
let bytes = io::read_limited(
&root.join("compat/entities").join(format!("{id}.nbt")),
nbt::MAX_BYTES,
)?;
p.entity_blob_hashes
.insert(id.clone(), format!("{:x}", Sha256::digest(bytes)));
}
io::json(&root.join("compat/native-entities.json"), &entities)?;
Ok(())
}
pub(crate) fn import_anvil(
root: &Path,
original: &Path,
p: &mut Provenance,
report: &mut Report,
) -> Result<()> {
let mut entities = BTreeMap::new();
for (world, dim) in &p.worlds {
let base = if dim.path.is_empty() {
original.to_owned()
} else {
io::safe_path(original, &dim.path)?
};
let folder = base.join("entities");
if !folder.exists() {
continue;
}
for e in fs::read_dir(folder)? {
let path = e?.path();
if path.extension().and_then(|s| s.to_str()) != Some("mca") {
continue;
}
let mut r = crate::anvil::Region::open(&path, false)?;
for i in 0..1024 {
if let Some((_, tag)) = r.read(i)? {
let tag = tag.compound()?;
let version = get(tag, "DataVersion")?.int()?;
ensure!(
report.mode != Mode::Exact || version == crate::DATA_VERSION,
"unsupported_data_version: entity chunk uses {version}"
);
if version != crate::DATA_VERSION {
report.issue(
"unverified_entity_version",
"unsupported",
world,
format!(
"Entity chunk DataVersion {version} is preserved without migration"
),
);
}
if let Some(list) = tag.get("Entities") {
for entity in list.list(10)? {
keep(root, entity, world, &mut entities, report)?
}
}
}
}
}
}
report.issue("entity_visual_bridge","preserved","server.sqlite3",format!("{} entities are editable through server/MCP; full original typed NBT is pinned on disk",entities.len()));
save(root, p, entities)
}
fn schem_tag(t: &Tag, offset: [i32; 3]) -> Result<Tag> {
let c = t.compound()?;
let mut data = c
.get("Data")
.cloned()
.unwrap_or_else(|| compound([] as [(&str, Tag); 0]));
let data = data.compound_mut()?;
data.insert("id".into(), get(c, "Id")?.clone());
let list = get(c, "Pos")?.list(6)?;
ensure!(list.len() == 3, "schem_entity_position");
let mut pos = Vec::new();
for i in 0..3 {
pos.push(Tag::Double(
(number(&list[i])? + f64::from(offset[i])).to_bits(),
))
}
data.insert("Pos".into(), Tag::List(6, pos));
Ok(Tag::Compound(data.clone()))
}
pub(crate) fn import_schem(
root: &Path,
c: &Compound,
p: &mut Provenance,
report: &mut Report,
) -> Result<()> {
let mut entities = BTreeMap::new();
let world = p.worlds.keys().next().context("schem world")?;
if let Some(t) = c.get("Entities") {
for entity in t.list(10)? {
let tag = schem_tag(entity, p.schem_min.unwrap_or([0; 3]))?;
keep(root, &tag, world, &mut entities, report)?
}
}
save(root, p, entities)
}
fn materialize(
root: &Path,
e: &Entity,
base: Option<&Entity>,
p: Option<&Provenance>,
report: &mut Report,
) -> Result<Tag> {
let mut tag = if base.is_some() {
{
let bytes = io::read_limited(
&root.join("compat/entities").join(format!("{}.nbt", e.id)),
nbt::MAX_BYTES,
)?;
let hash = format!("{:x}", Sha256::digest(&bytes));
ensure!(
p.and_then(|p| p.entity_blob_hashes.get(&e.id)) == Some(&hash),
"provenance_corrupt: entity blob hash"
);
nbt::decode(&bytes)?.1
}
} else {
compound([("UUID", uuid_tag(&e.id))])
};
if base == Some(e) {
return Ok(tag);
}
let c = tag.compound_mut()?;
if let Some(base) = base
&& let Some(old) = base.properties.as_object()
{
for key in old.keys() {
if e.properties.get(key).is_none() {
c.remove(key);
}
}
}
c.insert("id".into(), Tag::String(e.kind.clone()));
c.insert(
"Pos".into(),
Tag::List(
6,
e.position
.iter()
.map(|v| Tag::Double(v.to_bits()))
.collect(),
),
);
let pitch = c
.get("Rotation")
.and_then(|t| t.list(5).ok())
.and_then(|v| v.get(1))
.cloned()
.unwrap_or(Tag::Float(0));
c.insert(
"Rotation".into(),
Tag::List(
5,
vec![Tag::Float((e.yaw.to_degrees() as f32).to_bits()), pitch],
),
);
if let Some(props) = e.properties.as_object() {
for (k, v) in props {
let tag = match k.as_str() {
"Health" => v
.as_f64()
.filter(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
.map(|v| Tag::Float((v as f32).to_bits())),
"CustomName" => v.as_str().map(|v| Tag::String(v.into())),
"NoGravity" | "Invisible" | "Invulnerable" | "Glowing" | "Silent"
| "CustomNameVisible" => v.as_bool().map(|v| Tag::Byte(v as i8)),
_ => None,
};
if let Some(tag) = tag {
c.insert(k.clone(), tag);
} else {
ensure!(
report.mode != Mode::Exact,
"exact_entity_property: unsupported {k}"
);
report.issue("entity_property_unmapped","unsupported",&e.id,format!("Property {k} retained in shacraft-native-entities.json; not translated into vanilla NBT"));
}
}
}
if base.is_some() {
report.issue("entity_edited","approximated",&e.id,"Position/yaw/type and supported scalar properties applied; unknown original NBT retained; passenger/tether/AI relationships not simulated")
} else {
report.issue("entity_created","preserved",&e.id,"UUID, position, rotation, supported properties emitted; vanilla initializes omitted defaults")
}
Ok(tag)
}
pub(crate) fn export_anvil(
root: &Path,
out: &Path,
p: Option<&Provenance>,
worlds: &BTreeMap<String, String>,
report: &mut Report,
) -> Result<()> {
let current = read_native(root)?;
let base = baseline(root, p)?;
let source_is_anvil = p.is_some_and(|p| p.format == "anvil");
let scratch = tempfile::Builder::new()
.prefix(".entity-export-")
.tempdir_in(out)?;
// Remove only the editable baseline subset. Unbridged records remain intact.
for (world, dim) in worlds {
let dir = if dim.is_empty() {
out.to_owned()
} else {
io::safe_path(out, dim)?
};
let folder = dir.join("entities");
if !folder.exists() {
continue;
}
for e in fs::read_dir(&folder)? {
let path = e?.path();
if path.extension().and_then(|s| s.to_str()) != Some("mca") {
continue;
}
let mut r = crate::anvil::Region::open(&path, true)?;
for i in 0..1024 {
let Some((name, mut tag)) = r.read(i)? else {
continue;
};
if let Some(Tag::List(_, list)) = tag.compound_mut()?.get_mut("Entities") {
let before = list.len();
list.retain(|t| {
uuid(t)
.ok()
.and_then(|id| base.get(&id))
.is_none_or(|b| &b.world != world || current.get(&b.id) == Some(b))
});
if list.len() != before {
r.write(i, &name, &tag)?;
}
}
}
}
}
for (id, e) in &current {
let Some(dim) = worlds.get(&e.world) else {
continue;
};
if source_is_anvil && base.get(id) == Some(e) {
continue;
}
let tag = materialize(root, e, base.get(id), p, report)?;
if !e.kind.starts_with("minecraft:") {
report.issue(
"nonvanilla_entity",
"unsupported",
id,
"Preserved entity id requires matching target mod; vanilla may discard it",
);
}
fs::write(
scratch.path().join(format!("{id}.nbt")),
nbt::encode("", &tag)?,
)?;
let (x, z) = (
(e.position[0].floor() as i32).div_euclid(16),
(e.position[2].floor() as i32).div_euclid(16),
);
let dir = if dim.is_empty() {
out.to_owned()
} else {
io::safe_path(out, dim)?
};
let path =
dir.join("entities")
.join(format!("r.{}.{}.mca", x.div_euclid(32), z.div_euclid(32)));
let mut r = if path.exists() {
crate::anvil::Region::open(&path, true)?
} else {
crate::anvil::Region::create(&path)?
};
let index = (x.rem_euclid(32) + z.rem_euclid(32) * 32) as usize;
let (name, mut chunk) = r.read(index)?.unwrap_or_else(|| {
(
"".into(),
compound([
("DataVersion", Tag::Int(crate::DATA_VERSION)),
("Position", Tag::IntArray(vec![x, z])),
("Entities", Tag::List(10, vec![])),
]),
)
});
let c = chunk.compound_mut()?;
let list = c.entry("Entities".into()).or_insert(Tag::List(10, vec![]));
if let Tag::List(kind, list) = list {
ensure!(*kind == 10 || list.is_empty(), "entity_list_type");
*kind = 10;
list.push(tag);
} else {
anyhow::bail!("entity_list_type")
};
r.write(index, &name, &chunk)?;
}
for (id, e) in &base {
if worlds.contains_key(&e.world) && !current.contains_key(id) {
report.issue(
"entity_removed",
"dropped",
id,
"Removed via native metadata; original typed NBT remains in source sidecar",
)
}
}
let saved = io::available(out, "shacraft-native-entities", ".json")?;
io::json(&saved, &current)?;
Ok(())
}
pub(crate) fn export_schem(
root: &Path,
world: &str,
c: &mut Compound,
offset: [i32; 3],
p: Option<&Provenance>,
report: &mut Report,
) -> Result<()> {
let current = read_native(root)?;
let base = baseline(root, p)?;
let had_entities = c.contains_key("Entities");
let mut source = match c.remove("Entities") {
Some(Tag::List(_, v)) => v,
None => vec![],
_ => anyhow::bail!("schem_entities_type"),
};
let mut out = Vec::new();
let mut original_wrappers = BTreeMap::new();
for entity in source.drain(..) {
let tag = schem_tag(&entity, offset)?;
let id = uuid(&tag)?;
if base.contains_key(&id) {
original_wrappers.insert(id, entity);
continue;
}
out.push(entity);
}
for (id, e) in &current {
if e.world != world {
continue;
}
if base.get(id) == Some(e)
&& let Some(wrapper) = original_wrappers.remove(id)
{
out.push(wrapper);
continue;
}
let tag = materialize(root, e, base.get(id), p, report)?;
let mut data = tag.compound()?.clone();
data.remove("id");
data.remove("Pos");
let mut wrapper = original_wrappers
.remove(id)
.unwrap_or_else(|| compound([] as [(&str, Tag); 0]));
let updated = compound([
("Id", Tag::String(e.kind.clone())),
(
"Pos",
Tag::List(
6,
e.position
.iter()
.enumerate()
.map(|(i, v)| Tag::Double((v - f64::from(offset[i])).to_bits()))
.collect(),
),
),
("Data", Tag::Compound(data)),
]);
wrapper.compound_mut()?.extend(updated.compound()?.clone());
out.push(wrapper);
}
if !out.is_empty() || had_entities || !base.is_empty() {
c.insert("Entities".into(), Tag::List(10, out));
}
Ok(())
}
+306
View File
@@ -0,0 +1,306 @@
use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{self, File, OpenOptions},
io::{BufRead, BufReader, Read, Write},
path::{Component, Path, PathBuf},
};
pub(crate) fn read_limited(p: &Path, max: usize) -> Result<Vec<u8>> {
let mut b = Vec::new();
File::open(p)?.take(max as u64 + 1).read_to_end(&mut b)?;
ensure!(b.len() <= max, "input_limit: file {}", p.display());
Ok(b)
}
pub(crate) fn inflate(r: impl Read) -> Result<Vec<u8>> {
let mut b = Vec::new();
r.take(super::nbt::MAX_BYTES as u64 + 1)
.read_to_end(&mut b)?;
ensure!(
b.len() <= super::nbt::MAX_BYTES,
"input_limit: decompressed NBT exceeds16MiB"
);
Ok(b)
}
pub(crate) fn gzip_read(p: &Path) -> Result<Vec<u8>> {
inflate(flate2::read::GzDecoder::new(File::open(p)?))
}
pub(crate) fn gzip_write(p: &Path, b: &[u8]) -> Result<()> {
let f = OpenOptions::new().write(true).create_new(true).open(p)?;
let mut w = flate2::write::GzEncoder::new(f, flate2::Compression::default());
w.write_all(b)?;
w.finish()?.sync_all()?;
Ok(())
}
pub(crate) fn json(p: &Path, v: &impl Serialize) -> Result<()> {
let mut f = OpenOptions::new().write(true).create_new(true).open(p)?;
serde_json::to_writer_pretty(&mut f, v)?;
f.write_all(b"\n")?;
f.sync_all()?;
Ok(())
}
pub(crate) fn available(root: &Path, stem: &str, extension: &str) -> Result<PathBuf> {
for i in 0..10000 {
let suffix = if i == 0 {
String::new()
} else {
format!("-{i}")
};
let p = root.join(format!("{stem}{suffix}{extension}"));
if !p.exists() {
return Ok(p);
}
}
bail!("output_limit: too many reserved filenames")
}
pub(crate) fn stage(dest: &Path) -> Result<tempfile::TempDir> {
ensure!(
!dest.exists(),
"destination_exists: refusing overwrite {}",
dest.display()
);
let parent = dest
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
fs::create_dir_all(parent)?;
Ok(tempfile::Builder::new()
.prefix(".shacraft-convert-")
.tempdir_in(parent)?)
}
pub(crate) fn publish(stage: tempfile::TempDir, dest: &Path) -> Result<()> {
sync_tree(stage.path(), 0)?;
let parent = dest
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
rustix::fs::renameat_with(
rustix::fs::CWD,
stage.path(),
rustix::fs::CWD,
dest,
rustix::fs::RenameFlags::NOREPLACE,
)
.context("publish: atomic no-overwrite rename")?;
File::open(parent)?.sync_all()?;
Ok(())
}
pub(crate) fn report(root: &Path, value: &super::Report) -> Result<()> {
// Imported worlds may already carry a report; never replace source files.
for i in 0..10000 {
let name = if i == 0 {
"conversion-report.json".to_owned()
} else {
format!("conversion-report-{i}.json")
};
let path = root.join(name);
if !path.exists() {
return json(&path, value);
}
}
bail!("report_limit: too many existing reports")
}
fn sync_tree(p: &Path, depth: usize) -> Result<()> {
ensure!(depth <= 32, "input_limit: path depth");
for e in fs::read_dir(p)? {
let e = e?;
let t = e.file_type()?;
if t.is_dir() {
sync_tree(&e.path(), depth + 1)?
} else if t.is_file() {
File::open(e.path())?.sync_all()?
} else {
bail!("unsafe_path: special file")
}
}
File::open(p)?.sync_all()?;
Ok(())
}
#[derive(Serialize, Deserialize)]
struct FileHash {
path: String,
size: u64,
sha256: String,
}
pub(crate) fn safe_path(root: &Path, s: &str) -> Result<PathBuf> {
let p = Path::new(s);
ensure!(
p.components().all(|c| matches!(c, Component::Normal(_))),
"unsafe_path: nonrelative path"
);
let mut result = root.to_path_buf();
ensure!(
!fs::symlink_metadata(root)?.file_type().is_symlink(),
"unsafe_path: symlink root"
);
for part in p.components() {
result.push(part);
if let Ok(metadata) = fs::symlink_metadata(&result) {
ensure!(
!metadata.file_type().is_symlink(),
"unsafe_path: symlink component"
);
}
}
Ok(result)
}
pub(crate) fn copy_source(
source: &Path,
dest: &Path,
index: &Path,
report: &mut super::Report,
) -> Result<()> {
ensure!(
!fs::symlink_metadata(source)?.file_type().is_symlink(),
"unsafe_path: symlink root"
);
fs::create_dir_all(dest)?;
let mut log = File::create(index)?;
copy_dir(source, dest, source, &mut log, report, 0)?;
log.sync_all()?;
Ok(())
}
fn copy_dir(
source: &Path,
dest: &Path,
root: &Path,
index: &mut File,
report: &mut super::Report,
depth: usize,
) -> Result<()> {
ensure!(depth <= 24, "input_limit: directory depth");
for entry in fs::read_dir(source)? {
let entry = entry?;
let kind = entry.file_type()?;
let to = dest.join(entry.file_name());
if kind.is_dir() {
fs::create_dir(&to)?;
copy_dir(&entry.path(), &to, root, index, report, depth + 1)?
} else {
ensure!(
kind.is_file(),
"unsafe_path: symlink/special file {}",
entry.path().display()
);
report.source_files += 1;
ensure!(
report.source_files <= 1_000_000,
"input_limit: source file count"
);
let mut from = File::open(entry.path())?;
let before = from.metadata()?;
ensure!(
before.len() <= 8 * 1024 * 1024 * 1024,
"input_limit: individual source file exceeds8GiB"
);
let mut out = OpenOptions::new().write(true).create_new(true).open(&to)?;
let (size, hash) = copy_hash(&mut from, &mut out)?;
let after = from.metadata()?;
ensure!(
size == before.len()
&& after.len() == before.len()
&& after.modified()? == before.modified()?,
"source_changed: {}",
entry.path().display()
);
report.source_bytes = report
.source_bytes
.checked_add(size)
.context("input_limit: bytes overflow")?;
ensure!(
report.source_bytes <= 1024 * 1024 * 1024 * 1024,
"input_limit: source exceeds1TiB"
);
let path = entry
.path()
.strip_prefix(root)?
.to_str()
.context("unsafe_path: non-UTF8 filename")?
.to_owned();
serde_json::to_writer(
&mut *index,
&FileHash {
path,
size,
sha256: hash,
},
)?;
index.write_all(b"\n")?;
out.sync_all()?;
}
}
Ok(())
}
fn copy_hash(r: &mut impl Read, w: &mut impl Write) -> Result<(u64, String)> {
let mut sha = Sha256::new();
let mut buf = [0; 65536];
let mut size = 0;
loop {
let n = r.read(&mut buf)?;
if n == 0 {
break;
}
sha.update(&buf[..n]);
w.write_all(&buf[..n])?;
size += n as u64;
}
Ok((size, format!("{:x}", sha.finalize())))
}
pub(crate) fn copy_verified(root: &Path, dest: &Path, index: &Path) -> Result<()> {
fs::create_dir_all(dest)?;
let mut reader = BufReader::new(File::open(index)?);
let mut count = 0;
loop {
let mut line = String::new();
let n = (&mut reader).take(16385).read_line(&mut line)?;
if n == 0 {
break;
}
ensure!(
n <= 16384 && line.ends_with('\n'),
"provenance_corrupt: checksum record"
);
count += 1;
ensure!(count <= 1_000_000, "input_limit: checksum records");
let item: FileHash = serde_json::from_str(&line)?;
let from = safe_path(root, &item.path)?;
ensure!(
fs::symlink_metadata(&from)?.file_type().is_file(),
"provenance_corrupt: not regular file"
);
let to = safe_path(dest, &item.path)?;
fs::create_dir_all(to.parent().context("path")?)?;
let mut w = OpenOptions::new().write(true).create_new(true).open(to)?;
ensure!(
item.size <= 8 * 1024 * 1024 * 1024,
"input_limit: source file size"
);
let mut input = File::open(from)?.take(item.size + 1);
let (size, hash) = copy_hash(&mut input, &mut w)?;
ensure!(
size == item.size && hash == item.sha256,
"provenance_corrupt: source hash {}",
item.path
);
w.sync_all()?
}
Ok(())
}
pub(crate) fn visit_files(
root: &Path,
callback: &mut impl FnMut(&Path) -> Result<()>,
depth: usize,
) -> Result<()> {
ensure!(depth <= 24, "input_limit: directory depth");
for e in fs::read_dir(root)? {
let e = e?;
let t = e.file_type()?;
if t.is_dir() {
visit_files(&e.path(), callback, depth + 1)?
} else {
ensure!(t.is_file(), "unsafe_path: symlink/special file");
callback(&e.path())?
}
}
Ok(())
}
+119
View File
@@ -0,0 +1,119 @@
//! Offline world interchange. Imports publish a new native store atomically;
//! exports hold its single-writer lock and never overwrite a destination.
mod anvil;
mod entities;
mod io;
pub mod nbt;
mod schem;
pub use anvil::{export_anvil, import_anvil};
use anyhow::{Result, ensure};
pub use schem::{export_schem, import_schem};
use serde::{Deserialize, Serialize};
use shacraft_core::WorldStore;
use std::{collections::BTreeMap, path::Path};
pub const DATA_VERSION: i32 = 4903;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum Mode {
Exact,
BestEffort,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub code: String,
pub severity: String,
pub action: String,
pub location: String,
pub detail: String,
pub count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Report {
pub schema_version: u32,
pub format: String,
pub mode: Mode,
pub data_version: i32,
pub worlds: BTreeMap<String, u64>,
pub chunks: u64,
pub sections: u64,
pub nonair_blocks: u64,
pub issues: Vec<Issue>,
pub issue_limit: usize,
pub omitted_issues: u64,
pub source_files: u64,
pub source_bytes: u64,
pub native_entity_bytes: u64,
pub profile: String,
}
impl Report {
fn new(format: &str, mode: Mode) -> Self {
Self{schema_version:1,format:format.into(),mode,data_version:DATA_VERSION,worlds:BTreeMap::new(),chunks:0,sections:0,nonair_blocks:0,issues:vec![],issue_limit:1000,omitted_issues:0,source_files:0,source_bytes:0,native_entity_bytes:0,profile:"Java 26.2 (DataVersion 4903); blocks editable, other typed NBT preserved on disk; offline writer lock".into()}
}
fn issue(
&mut self,
code: &str,
action: &str,
location: impl Into<String>,
detail: impl Into<String>,
) {
if self.issues.len() < self.issue_limit {
self.issues.push(Issue {
code: code.into(),
severity: if action == "preserved" {
"info"
} else {
"warning"
}
.into(),
action: action.into(),
location: location.into(),
detail: detail.into(),
count: 1,
})
} else {
self.omitted_issues += 1
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Provenance {
schema_version: u32,
format: String,
data_version: i32,
worlds: BTreeMap<String, Dimension>,
schem_min: Option<[i32; 3]>,
schem_max: Option<[i32; 3]>,
#[serde(default)]
entity_fingerprint: Option<String>,
#[serde(default)]
entity_blob_hashes: BTreeMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Dimension {
path: String,
revision: u64,
}
fn provenance(root: &Path) -> Result<Option<Provenance>> {
let p = root.join("compat/provenance.json");
if !p.exists() {
return Ok(None);
}
let b = io::read_limited(&p, 4 * 1024 * 1024)?;
let p: Provenance = serde_json::from_slice(&b)?;
ensure!(
p.schema_version == 1,
"compat_version: unsupported provenance"
);
Ok(Some(p))
}
fn section_cells(store: &mut WorldStore, world: &str, pos: [i32; 3]) -> Result<[u32; 4096]> {
let min = pos.map(|v| v * 16);
let max = min.map(|v| v + 15);
let mut cells = [0; 4096];
for c in store.read_region(world, min, max)? {
let [x, y, z] = c.pos.map(|v| v.rem_euclid(16) as usize);
cells[x + z * 16 + y * 256] = c.block
}
Ok(cells)
}
+97
View File
@@ -0,0 +1,97 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use shacraft_compat::{Mode, export_anvil, export_schem, import_anvil, import_schem};
use std::path::PathBuf;
#[derive(Parser)]
#[command(about = "Offline bounded Java26.2 world converter; every destination must be new")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
ImportAnvil {
source: PathBuf,
destination: PathBuf,
#[arg(long, value_enum, default_value = "exact")]
mode: Mode,
},
ExportAnvil {
store: PathBuf,
destination: PathBuf,
#[arg(long)]
world: Option<String>,
#[arg(long, value_enum, default_value = "exact")]
mode: Mode,
},
ImportSchem {
source: PathBuf,
destination: PathBuf,
#[arg(long, default_value = "main")]
world: String,
#[arg(long, value_enum, default_value = "exact")]
mode: Mode,
},
ExportSchem {
store: PathBuf,
destination: PathBuf,
#[arg(long, default_value = "main")]
world: String,
#[arg(long,value_parser=parse_pos,allow_hyphen_values=true)]
min: Option<[i32; 3]>,
#[arg(long,value_parser=parse_pos,allow_hyphen_values=true)]
max: Option<[i32; 3]>,
#[arg(long, value_enum, default_value = "exact")]
mode: Mode,
},
}
fn parse_pos(s: &str) -> Result<[i32; 3], String> {
let parts = s
.split(',')
.map(str::parse::<i32>)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
parts
.try_into()
.map_err(|_| "coordinates must be x,y,z".into())
}
fn run() -> Result<()> {
let report = match Cli::parse().command {
Command::ImportAnvil {
source,
destination,
mode,
} => import_anvil(&source, &destination, mode)?,
Command::ExportAnvil {
store,
destination,
world,
mode,
} => export_anvil(&store, world.as_deref(), &destination, mode)?,
Command::ImportSchem {
source,
destination,
world,
mode,
} => import_schem(&source, &destination, &world, mode)?,
Command::ExportSchem {
store,
destination,
world,
min,
max,
mode,
} => export_schem(&store, &world, &destination, min, max, mode)?,
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!(
"{}",
serde_json::json!({"error":format!("{e:#}"),"success":false})
);
std::process::exit(1)
}
}
+335
View File
@@ -0,0 +1,335 @@
//! Bounded, big-endian Java NBT. Numeric types, float bit patterns and list
//! element types (including empty lists) survive decoding and encoding.
use anyhow::{Context, Result, bail, ensure};
use std::collections::BTreeMap;
use std::io::{Cursor, Read, Write};
pub type Compound = BTreeMap<String, Tag>;
pub const MAX_BYTES: usize = 16 * 1024 * 1024;
const MAX_NODES: usize = 1_000_000;
#[derive(Clone, Debug, PartialEq)]
pub enum Tag {
Byte(i8),
Short(i16),
Int(i32),
Long(i64),
Float(u32),
Double(u64),
ByteArray(Vec<u8>),
String(String),
List(u8, Vec<Tag>),
Compound(Compound),
IntArray(Vec<i32>),
LongArray(Vec<i64>),
}
impl Tag {
pub fn id(&self) -> u8 {
match self {
Self::Byte(_) => 1,
Self::Short(_) => 2,
Self::Int(_) => 3,
Self::Long(_) => 4,
Self::Float(_) => 5,
Self::Double(_) => 6,
Self::ByteArray(_) => 7,
Self::String(_) => 8,
Self::List(..) => 9,
Self::Compound(_) => 10,
Self::IntArray(_) => 11,
Self::LongArray(_) => 12,
}
}
pub fn compound(&self) -> Result<&Compound> {
if let Self::Compound(v) = self {
Ok(v)
} else {
bail!("nbt_type: expected compound")
}
}
pub fn compound_mut(&mut self) -> Result<&mut Compound> {
if let Self::Compound(v) = self {
Ok(v)
} else {
bail!("nbt_type: expected compound")
}
}
pub fn list(&self, id: u8) -> Result<&[Tag]> {
if let Self::List(kind, v) = self {
ensure!(
*kind == id || v.is_empty(),
"nbt_type: wrong list element type"
);
Ok(v)
} else {
bail!("nbt_type: expected list")
}
}
pub fn int(&self) -> Result<i32> {
if let Self::Int(v) = self {
Ok(*v)
} else {
bail!("nbt_type: expected int")
}
}
pub fn numeric_i32(&self) -> Result<i32> {
match self {
Self::Byte(v) => Ok(*v as i32),
Self::Short(v) => Ok(*v as i32),
Self::Int(v) => Ok(*v),
_ => bail!("nbt_type: expected integer"),
}
}
pub fn string(&self) -> Result<&str> {
if let Self::String(v) = self {
Ok(v)
} else {
bail!("nbt_type: expected string")
}
}
pub fn bytes(&self) -> Result<&[u8]> {
if let Self::ByteArray(v) = self {
Ok(v)
} else {
bail!("nbt_type: expected byte array")
}
}
pub fn longs(&self) -> Result<&[i64]> {
if let Self::LongArray(v) = self {
Ok(v)
} else {
bail!("nbt_type: expected long array")
}
}
pub fn ints(&self) -> Result<&[i32]> {
if let Self::IntArray(v) = self {
Ok(v)
} else {
bail!("nbt_type: expected int array")
}
}
}
pub fn get<'a>(c: &'a Compound, k: &str) -> Result<&'a Tag> {
c.get(k).with_context(|| format!("nbt_missing: {k}"))
}
pub fn compound(entries: impl IntoIterator<Item = (impl Into<String>, Tag)>) -> Tag {
Tag::Compound(entries.into_iter().map(|(k, v)| (k.into(), v)).collect())
}
pub fn decode(bytes: &[u8]) -> Result<(String, Tag)> {
ensure!(bytes.len() <= MAX_BYTES, "nbt_limit: decompressed bytes");
let mut r = Decoder {
r: Cursor::new(bytes),
nodes: 0,
};
let id = r.byte()?;
ensure!(id == 10, "nbt_root: root must be compound");
let name = r.string()?;
let tag = r.tag(id, 0)?;
ensure!(
r.r.position() as usize == bytes.len(),
"nbt_trailing: extra bytes"
);
Ok((name, tag))
}
struct Decoder<'a> {
r: Cursor<&'a [u8]>,
nodes: usize,
}
impl Decoder<'_> {
fn bytes<const N: usize>(&mut self) -> Result<[u8; N]> {
let mut b = [0; N];
self.r.read_exact(&mut b)?;
Ok(b)
}
fn byte(&mut self) -> Result<u8> {
Ok(self.bytes::<1>()?[0])
}
fn len(&mut self, width: usize) -> Result<usize> {
let n = i32::from_be_bytes(self.bytes()?);
ensure!(n >= 0, "nbt_length: negative array/list length");
let n = n as usize;
ensure!(
n <= MAX_BYTES / width
&& n * width <= self.r.get_ref().len() - self.r.position() as usize,
"nbt_limit: impossible array/list length"
);
Ok(n)
}
fn string(&mut self) -> Result<String> {
let n = u16::from_be_bytes(self.bytes()?) as usize;
let mut b = vec![0; n];
self.r.read_exact(&mut b)?;
Ok(cesu8::from_java_cesu8(&b)
.context("nbt_string: invalid modified UTF-8")?
.into_owned())
}
fn tag(&mut self, id: u8, depth: usize) -> Result<Tag> {
self.nodes += 1;
ensure!(
depth <= 64 && self.nodes <= MAX_NODES,
"nbt_limit: depth/node budget"
);
Ok(match id {
1 => Tag::Byte(self.byte()? as i8),
2 => Tag::Short(i16::from_be_bytes(self.bytes()?)),
3 => Tag::Int(i32::from_be_bytes(self.bytes()?)),
4 => Tag::Long(i64::from_be_bytes(self.bytes()?)),
5 => Tag::Float(u32::from_be_bytes(self.bytes()?)),
6 => Tag::Double(u64::from_be_bytes(self.bytes()?)),
7 => {
let n = self.len(1)?;
let mut v = vec![0; n];
self.r.read_exact(&mut v)?;
Tag::ByteArray(v)
}
8 => Tag::String(self.string()?),
9 => {
let kind = self.byte()?;
let n = self.len(1)?;
ensure!(
kind <= 12 && (kind != 0 || n == 0),
"nbt_type: invalid list element"
);
ensure!(n <= MAX_NODES - self.nodes, "nbt_limit: list nodes");
let mut v = Vec::new();
for _ in 0..n {
v.push(self.tag(kind, depth + 1)?)
}
Tag::List(kind, v)
}
10 => {
let mut c = BTreeMap::new();
loop {
let kind = self.byte()?;
if kind == 0 {
break;
}
let name = self.string()?;
ensure!(
!c.contains_key(&name),
"nbt_duplicate: duplicate compound name"
);
c.insert(name, self.tag(kind, depth + 1)?);
}
Tag::Compound(c)
}
11 => {
let n = self.len(4)?;
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(i32::from_be_bytes(self.bytes()?))
}
Tag::IntArray(v)
}
12 => {
let n = self.len(8)?;
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(i64::from_be_bytes(self.bytes()?))
}
Tag::LongArray(v)
}
_ => bail!("nbt_type: unknown tag {id}"),
})
}
}
pub fn encode(name: &str, tag: &Tag) -> Result<Vec<u8>> {
ensure!(tag.id() == 10, "nbt_root: expected compound");
let mut b = vec![10];
write_string(&mut b, name)?;
write_tag(&mut b, tag, 0)?;
ensure!(b.len() <= MAX_BYTES, "nbt_limit: encoded bytes");
Ok(b)
}
fn write_string(w: &mut Vec<u8>, s: &str) -> Result<()> {
let b = cesu8::to_java_cesu8(s);
ensure!(b.len() <= u16::MAX as usize, "nbt_limit: string bytes");
w.write_all(&(b.len() as u16).to_be_bytes())?;
w.write_all(&b)?;
Ok(())
}
fn write_len(w: &mut Vec<u8>, n: usize) -> Result<()> {
ensure!(n <= MAX_BYTES, "nbt_limit: array count");
w.extend((n as i32).to_be_bytes());
Ok(())
}
fn write_tag(w: &mut Vec<u8>, t: &Tag, depth: usize) -> Result<()> {
ensure!(
depth <= 64 && w.len() <= MAX_BYTES,
"nbt_limit: encode depth/bytes"
);
match t {
Tag::Byte(v) => w.push(*v as u8),
Tag::Short(v) => w.extend(v.to_be_bytes()),
Tag::Int(v) => w.extend(v.to_be_bytes()),
Tag::Long(v) => w.extend(v.to_be_bytes()),
Tag::Float(v) => w.extend(v.to_be_bytes()),
Tag::Double(v) => w.extend(v.to_be_bytes()),
Tag::String(s) => write_string(w, s)?,
Tag::ByteArray(v) => {
write_len(w, v.len())?;
w.extend(v)
}
Tag::IntArray(v) => {
write_len(w, v.len())?;
for n in v {
w.extend(n.to_be_bytes())
}
}
Tag::LongArray(v) => {
write_len(w, v.len())?;
for n in v {
w.extend(n.to_be_bytes())
}
}
Tag::List(id, v) => {
ensure!(*id <= 12 && (*id != 0 || v.is_empty()), "nbt_type: list");
w.push(*id);
write_len(w, v.len())?;
for t in v {
ensure!(t.id() == *id, "nbt_type: heterogeneous list");
write_tag(w, t, depth + 1)?
}
}
Tag::Compound(c) => {
for (k, t) in c {
w.push(t.id());
write_string(w, k)?;
write_tag(w, t, depth + 1)?
}
w.push(0)
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_types_roundtrip() {
let t = compound([
("byte", Tag::Byte(-128)),
("short", Tag::Short(-32768)),
("int", Tag::Int(i32::MAX)),
("long", Tag::Long(i64::MIN)),
("float", Tag::Float(0x7fc12345)),
("double", Tag::Double(0xfff0000000000000)),
("bytes", Tag::ByteArray(vec![0, 128, 255])),
("ints", Tag::IntArray(vec![-1, 0, 2])),
("longs", Tag::LongArray(vec![i64::MIN, i64::MAX])),
("string", Tag::String("\0Привет🌍".into())),
("empty", Tag::List(4, vec![])),
("list", Tag::List(10, vec![compound([("x", Tag::Int(2))])])),
]);
assert_eq!(
decode(&encode("root", &t).unwrap()).unwrap(),
("root".into(), t)
);
}
#[test]
fn rejects_bombs() {
assert!(decode(&[10, 0, 0, 7, 0, 1, b'a', 127, 255, 255, 255, 0]).is_err());
assert!(decode(&[10, 0, 0, 0, 0]).is_err());
}
}
+469
View File
@@ -0,0 +1,469 @@
use crate::{
DATA_VERSION, Dimension, Mode, Provenance, Report, io,
nbt::{self, Compound, Tag, compound, get},
provenance,
};
use anyhow::{Context, Result, ensure};
use shacraft_core::{BlockChange, WorldStore};
use std::{collections::BTreeMap, fs, path::Path};
const MAX_VOLUME: usize = 262_144;
fn container(root: &Tag) -> Result<&Compound> {
get(root.compound()?, "Schematic")?.compound()
}
fn shape(c: &Compound) -> Result<([i32; 3], [i32; 3], usize)> {
let dim = |key| -> Result<i32> {
if let Tag::Short(v) = get(c, key)? {
Ok(*v as u16 as i32)
} else {
anyhow::bail!("schem_type: {key} must be unsigned short")
}
};
let size = [dim("Width")?, dim("Height")?, dim("Length")?];
ensure!(
size.iter().all(|v| *v > 0),
"schem_dimensions: empty dimension"
);
let volume = size
.iter()
.try_fold(1usize, |n, v| n.checked_mul(*v as usize))
.context("schem_limit: volume overflow")?;
ensure!(volume <= MAX_VOLUME, "schem_limit: maximum262144 cells");
let min = if let Some(t) = c.get("Offset") {
let v = t.ints()?;
ensure!(v.len() == 3, "schem_offset: expected3 values");
[v[0], v[1], v[2]]
} else {
[0; 3]
};
let mut max = min;
for i in 0..3 {
max[i] = min[i]
.checked_add(size[i] - 1)
.context("schem_limit: offset overflow")?;
ensure!(
min[i] >= -30_000_000 && max[i] <= 30_000_000,
"schem_limit: coordinates"
)
}
Ok((min, max, volume))
}
fn varints(bytes: &[u8], count: usize) -> Result<Vec<u32>> {
let mut out = Vec::with_capacity(count);
let mut p = 0;
for _ in 0..count {
let mut n = 0;
for shift in (0..35).step_by(7) {
let b = *bytes.get(p).context("schem_data: truncated varint")?;
p += 1;
ensure!(shift != 28 || b <= 15, "schem_data: varint overflow");
n |= u32::from(b & 127) << shift;
if b & 128 == 0 {
out.push(n);
break;
}
ensure!(shift < 28, "schem_data: overlong varint")
}
}
ensure!(p == bytes.len(), "schem_data: trailing indices");
Ok(out)
}
fn write_varint(out: &mut Vec<u8>, mut n: u32) {
while n >= 128 {
out.push((n as u8 & 127) | 128);
n >>= 7
}
out.push(n as u8)
}
fn palette_data(c: &Compound, count: usize) -> Result<(BTreeMap<u32, String>, Vec<u32>)> {
let mut palette = BTreeMap::new();
for (name, id) in get(c, "Palette")?.compound()? {
let id = id.int()?;
ensure!(id >= 0, "schem_palette: negative index");
ensure!(
palette.insert(id as u32, name.clone()).is_none(),
"schem_palette: duplicate index"
)
}
ensure!(
!palette.is_empty() && palette.len() <= MAX_VOLUME,
"schem_palette: size"
);
let data = varints(get(c, "Data")?.bytes()?, count)?;
ensure!(
data.iter().all(|id| palette.contains_key(id)),
"schem_palette: index not in palette"
);
Ok((palette, data))
}
fn local_pos(index: usize, min: [i32; 3], max: [i32; 3]) -> [i32; 3] {
let w = (max[0] - min[0] + 1) as usize;
let d = (max[2] - min[2] + 1) as usize;
[
min[0] + (index % w) as i32,
min[1] + (index / (w * d)) as i32,
min[2] + ((index / w) % d) as i32,
]
}
pub fn import_schem(source: &Path, destination: &Path, world: &str, mode: Mode) -> Result<Report> {
let stage = io::stage(destination)?;
let compat = stage.path().join("compat");
fs::create_dir(&compat)?;
let bytes = io::gzip_read(source)?;
let (_, root) = nbt::decode(&bytes)?;
let c = container(&root)?;
ensure!(
get(c, "Version")?.int()? == 3,
"schem_version: only Sponge v3 supported"
);
let version = get(c, "DataVersion")?.int()?;
ensure!(
mode != Mode::Exact || version == DATA_VERSION,
"unsupported_data_version: exact requires4903"
);
let (min, max, volume) = shape(c)?;
let mut report = Report::new("sponge-schem-v3", mode);
report.data_version = version;
if version != DATA_VERSION {
report.issue(
"unverified_data_version",
"unsupported",
"Schematic.DataVersion",
format!("Preserved {version}; no DataFixer migration"),
)
}
let blocks = get(c, "Blocks")?.compound()?;
let (palette, indices) = palette_data(blocks, volume)?;
if let Some(b) = c.get("Biomes") {
palette_data(b.compound()?, volume)?;
report.issue("biomes_preserved","preserved","Schematic.Biomes","Full3D biome container retained in typed NBT source; native simulation does not consume it")
}
for key in ["Entities", "Metadata"] {
if c.contains_key(key) {
report.issue(
"opaque_nbt_preserved",
"preserved",
format!("Schematic.{key}"),
"Retained in original typed NBT; not editable in this adapter",
)
}
}
if blocks.contains_key("BlockEntities") {
for entity in get(blocks, "BlockEntities")?.list(10)? {
let entity = entity.compound()?;
let pos = get(entity, "Pos")?.ints()?;
ensure!(
pos.len() == 3 && (0..3).all(|i| pos[i] >= 0 && pos[i] <= max[i] - min[i]),
"schem_block_entity_position: outside volume"
);
get(entity, "Id")?.string()?;
if let Some(data) = entity.get("Data") {
data.compound()?;
}
}
report.issue(
"block_entities_preserved",
"preserved",
"Schematic.Blocks.BlockEntities",
"Inventories and unknown tags retained on disk",
)
}
let mut store = WorldStore::open(stage.path(), 32)?;
store.create_world(world, None)?;
let mut registry = BTreeMap::new();
for (id, state) in palette {
let name = if state.contains(':') {
state
} else {
format!("minecraft:{state}")
};
registry.insert(id, store.register_block(&name)?);
}
let mut batch = Vec::new();
let mut op = 0;
for (index, id) in indices.into_iter().enumerate() {
let block = registry[&id];
if block != 0 {
batch.push(BlockChange {
pos: local_pos(index, min, max),
block,
});
report.nonair_blocks += 1
}
if batch.len() == 32768 {
store.edit(
world,
store.revision(world)?,
&format!("compat-import-{op}"),
std::mem::take(&mut batch),
)?;
op += 1
}
}
if !batch.is_empty() {
store.edit(
world,
store.revision(world)?,
&format!("compat-import-{op}"),
batch,
)?;
}
let revision = store.revision(world)?;
store.flush()?;
drop(store);
let original = compat.join("original");
fs::create_dir(&original)?;
fs::copy(source, original.join("source.schem"))?;
// The source is copied and hashed after parsing, then checked against parsed bytes.
ensure!(
io::gzip_read(&original.join("source.schem"))? == bytes,
"source_changed: schematic changed during import"
);
let mut provenance_report = Report::new("source", mode);
let indexed = compat.join("indexed");
io::copy_source(
&original,
&indexed,
&compat.join("files.jsonl"),
&mut provenance_report,
)?;
fs::remove_dir_all(&original)?;
fs::rename(indexed, &original)?;
report.source_files = provenance_report.source_files;
report.source_bytes = provenance_report.source_bytes;
report.worlds.insert(world.into(), revision);
let mut provenance = Provenance {
schema_version: 1,
format: "sponge-schem-v3".into(),
data_version: version,
worlds: BTreeMap::from([(
world.into(),
Dimension {
path: String::new(),
revision,
},
)]),
schem_min: Some(min),
schem_max: Some(max),
entity_fingerprint: None,
entity_blob_hashes: BTreeMap::new(),
};
crate::entities::import_schem(stage.path(), c, &mut provenance, &mut report)?;
io::json(&compat.join("provenance.json"), &provenance)?;
io::json(&compat.join("import-report.json"), &report)?;
io::publish(stage, destination)?;
Ok(report)
}
pub fn export_schem(
store_path: &Path,
world: &str,
destination: &Path,
min: Option<[i32; 3]>,
max: Option<[i32; 3]>,
mode: Mode,
) -> Result<Report> {
let mut store = WorldStore::open(store_path, 32)?;
let revision = store.revision(world)?;
let p = provenance(store_path)?;
let original = p
.as_ref()
.filter(|p| p.format == "sponge-schem-v3" && p.worlds.contains_key(world));
let bounds = match (min, max) {
(Some(a), Some(b)) => (a, b),
(None, None) => {
let p = original.context("schem_bounds: supply --min and --max for a native world")?;
(
p.schem_min.context("provenance bounds")?,
p.schem_max.context("provenance bounds")?,
)
}
_ => anyhow::bail!("schem_bounds: both min/max required"),
};
let stage = io::stage(destination)?;
let mut report = Report::new("sponge-schem-v3", mode);
if original.is_none() && p.is_some() {
ensure!(
mode != Mode::Exact,
"exact_cross_format: source biome/block-entity metadata requires format-specific mapping; use best-effort"
);
io::copy_verified(
&store_path.join("compat/original"),
&stage.path().join("source-sidecar"),
&store_path.join("compat/files.jsonl"),
)?;
report.issue("cross_format_opaque_data","unsupported","source-sidecar","Source world metadata, block entities and biome layout are archived without cross-format translation; blocks and entity bridge are exported");
}
report.worlds.insert(world.into(), revision);
let (mut name, mut root) = if let Some(p) = original {
ensure!(
Some(bounds.0) == p.schem_min && Some(bounds.1) == p.schem_max,
"schem_bounds: cropping imported entities/biomes unsupported"
);
ensure!(
mode != Mode::Exact || p.data_version == DATA_VERSION,
"unsupported_data_version: exact requires4903"
);
let checked = stage.path().join(".checked");
io::copy_verified(
&store_path.join("compat/original"),
&checked,
&store_path.join("compat/files.jsonl"),
)?;
let entity_changes = crate::entities::changed(store_path, p)?;
if p.worlds[world].revision == revision && !entity_changes {
fs::rename(
checked.join("source.schem"),
stage.path().join("world.schem"),
)?;
fs::remove_dir(&checked)?;
report.data_version = p.data_version;
report.issue(
"unchanged_original",
"preserved",
"world.schem",
"Byte-identical verified source",
);
io::report(stage.path(), &report)?;
io::publish(stage, destination)?;
return Ok(report);
}
ensure!(
mode != Mode::Exact,
"exact_provenance_invalidated: world revision changed; derived/opaque metadata cannot be proven current; use best-effort"
);
let decoded = nbt::decode(&io::gzip_read(&checked.join("source.schem"))?)?;
fs::rename(&checked, stage.path().join("source-sidecar"))?;
report.data_version = p.data_version;
report.issue("modified_source_metadata","approximated","Schematic","Original entities,3D biomes,metadata and unknown tags preserved; behavior and placement dependencies are not simulated");
decoded
} else {
(
"".into(),
compound([(
"Schematic",
compound([
("Version", Tag::Int(3)),
("DataVersion", Tag::Int(DATA_VERSION)),
]),
)]),
)
};
let c = get_mut(root.compound_mut()?, "Schematic")?.compound_mut()?;
let mut volume = 1usize;
for (i, key) in ["Width", "Height", "Length"].into_iter().enumerate() {
let d = i64::from(bounds.1[i]) - i64::from(bounds.0[i]) + 1;
ensure!((1..=65535).contains(&d), "schem_dimensions: invalid extent");
volume = volume
.checked_mul(d as usize)
.context("schem_limit: volume overflow")?;
ensure!(volume <= MAX_VOLUME, "schem_limit: maximum262144 cells");
c.insert(key.into(), Tag::Short(d as u16 as i16));
}
c.insert("Offset".into(), Tag::IntArray(bounds.0.to_vec()));
let old_blocks = c.get("Blocks").cloned();
let mut blocks = old_blocks
.clone()
.unwrap_or_else(|| compound([] as [(&str, Tag); 0]));
let old_data = if let Some(old) = &old_blocks {
Some(palette_data(old.compound()?, volume)?)
} else {
None
};
let mut states = BTreeMap::<String, u32>::new();
let mut indices = vec![0u32; volume];
let air = store.registry()[0].clone();
states.insert(air, 0);
let width = (bounds.1[0] - bounds.0[0] + 1) as usize;
let depth = (bounds.1[2] - bounds.0[2] + 1) as usize;
for change in store.read_region(world, bounds.0, bounds.1)? {
let state = store.registry()[change.block as usize].clone();
let next = states.len() as u32;
let id = *states.entry(state).or_insert(next);
let [x, y, z] = [
change.pos[0] - bounds.0[0],
change.pos[1] - bounds.0[1],
change.pos[2] - bounds.0[2],
];
indices[x as usize + z as usize * width + y as usize * width * depth] = id;
report.nonair_blocks += 1
}
for state in states.keys().filter(|s| !s.starts_with("minecraft:")) {
report.issue(
"nonvanilla_block_state",
"unsupported",
"Schematic.Blocks.Palette",
format!(
"{state} is preserved as a Sponge resource id; pasting requires matching target mod"
),
);
}
let reverse: BTreeMap<u32, &str> = states.iter().map(|(s, id)| (*id, s.as_str())).collect();
let bc = blocks.compound_mut()?;
if let Some(Tag::List(_, entities)) = bc.get_mut("BlockEntities") {
entities.retain(|e|{
let Ok(e)=e.compound()else{return true};let Ok(pos)=get(e,"Pos").and_then(Tag::ints)else{return true};if pos.len()!=3{return true}let i=pos[0]as i64+pos[2]as i64*width as i64+pos[1]as i64*(width*depth)as i64;
let changed=if let Some((pal,data))=&old_data{if i>=0&&(i as usize)<volume{pal.get(&data[i as usize]).map(String::as_str)!=reverse.get(&indices[i as usize]).copied()}else{true}}else{false};
if changed{report.issue("changed_block_entity","dropped",format!("{pos:?}"),"Block state changed; stale block entity removed from export and retained in source-sidecar")}!changed
});
}
bc.insert(
"Palette".into(),
Tag::Compound(
states
.into_iter()
.map(|(s, id)| (s, Tag::Int(id as i32)))
.collect(),
),
);
let mut data = Vec::with_capacity(volume);
for id in indices {
write_varint(&mut data, id)
}
bc.insert("Data".into(), Tag::ByteArray(data));
c.insert("Blocks".into(), blocks);
if original.is_none() {
c.insert(
"Metadata".into(),
compound([
("Name", Tag::String(world.into())),
("Author", Tag::String("Shacraft Core".into())),
]),
);
report.issue("native_biomes_absent","preserved","Schematic","Native bounds exported; no biome NBT exists for native worlds. Native entities use the explicit bridge.")
}
crate::entities::export_schem(store_path, world, c, bounds.0, p.as_ref(), &mut report)?;
io::json(
&stage.path().join("shacraft-native-entities.json"),
&crate::entities::read_native(store_path)?,
)?;
if name == "Schematic" {
name.clear()
}
io::gzip_write(
&stage.path().join("world.schem"),
&nbt::encode(&name, &root)?,
)?;
io::report(stage.path(), &report)?;
io::publish(stage, destination)?;
Ok(report)
}
fn get_mut<'a>(c: &'a mut Compound, k: &str) -> Result<&'a mut Tag> {
c.get_mut(k).with_context(|| format!("nbt_missing: {k}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varint_validation() {
assert_eq!(
varints(&[0, 127, 128, 1, 255, 255, 255, 255, 15], 4).unwrap(),
vec![0, 127, 128, u32::MAX]
);
assert!(varints(&[128], 1).is_err());
assert!(varints(&[255, 255, 255, 255, 16], 1).is_err());
assert!(varints(&[0, 0], 1).is_err());
}
}
+436
View File
@@ -0,0 +1,436 @@
use shacraft_compat::{
Mode, export_anvil, export_schem, import_anvil, import_schem,
nbt::{self, Tag},
};
use shacraft_core::{BlockChange, WorldStore};
use std::{
fs,
io::Read,
path::{Path, PathBuf},
};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("fixtures")
.join(name)
}
fn read_gzip(p: &Path) -> Tag {
let mut b = Vec::new();
flate2::read::GzDecoder::new(fs::File::open(p).unwrap())
.read_to_end(&mut b)
.unwrap();
nbt::decode(&b).unwrap().1
}
fn assert_tree_equal(source: &Path, out: &Path) {
for e in fs::read_dir(source).unwrap() {
let e = e.unwrap();
let to = out.join(e.file_name());
if e.file_type().unwrap().is_dir() {
assert_tree_equal(&e.path(), &to)
} else {
assert_eq!(
fs::read(e.path()).unwrap(),
fs::read(to).unwrap(),
"{}",
e.path().display()
)
}
}
}
#[test]
fn sponge_import_edits_unknown_states_and_preserves_nbt() {
let t = tempfile::tempdir().unwrap();
let store_path = t.path().join("store");
let source = fixture("sponge-v3.schem");
let imported = import_schem(&source, &store_path, "main", Mode::Exact).unwrap();
assert_eq!(imported.nonair_blocks, 4);
let exact = t.path().join("exact");
export_schem(&store_path, "main", &exact, None, None, Mode::Exact).unwrap();
assert_eq!(
fs::read(&source).unwrap(),
fs::read(exact.join("world.schem")).unwrap()
);
let mut store = WorldStore::open(&store_path, 2).unwrap();
let unknown = store.get_block("main", [-15, -1, 31]).unwrap();
assert_eq!(
store.registry()[unknown as usize],
"example:unknown[facing=west]"
);
let revision = store.revision("main").unwrap();
store
.edit(
"main",
revision,
"break-container",
vec![BlockChange {
pos: [-17, -1, 31],
block: 0,
}],
)
.unwrap();
drop(store);
let rejected = t.path().join("rejected");
assert!(
export_schem(&store_path, "main", &rejected, None, None, Mode::Exact)
.unwrap_err()
.to_string()
.contains("exact_provenance_invalidated")
);
assert!(!rejected.exists());
let edited = t.path().join("edited");
let r = export_schem(&store_path, "main", &edited, None, None, Mode::BestEffort).unwrap();
assert!(r.issues.iter().any(|i| i.code == "changed_block_entity"));
let a = read_gzip(&source);
let b = read_gzip(&edited.join("world.schem"));
let a = a.compound().unwrap()["Schematic"].compound().unwrap();
let b = b.compound().unwrap()["Schematic"].compound().unwrap();
for k in ["Metadata", "Entities", "Biomes", "UnknownRoot", "Offset"] {
assert_eq!(a[k], b[k], "{k}")
}
let reimport = t.path().join("reimport");
import_schem(&edited.join("world.schem"), &reimport, "main", Mode::Exact).unwrap();
let mut s = WorldStore::open(&reimport, 2).unwrap();
assert_eq!(s.get_block("main", [-17, -1, 31]).unwrap(), 0);
assert_ne!(s.get_block("main", [-16, -1, 31]).unwrap(), 0);
}
#[test]
fn genuine_java_world_import_exact_roundtrip_edit_and_new_chunk() {
let t = tempfile::tempdir().unwrap();
let store_path = t.path().join("store");
let source = fixture("java26_2-world");
let imported = import_anvil(&source, &store_path, Mode::Exact).unwrap();
assert_eq!(imported.nonair_blocks, 3);
assert_eq!(imported.chunks, 1);
let exact = t.path().join("exact");
export_anvil(&store_path, None, &exact, Mode::Exact).unwrap();
assert_tree_equal(&source, &exact);
let mut store = WorldStore::open(&store_path, 2).unwrap();
let stone = store.register_block("minecraft:stone").unwrap();
assert_ne!(store.get_block("main", [-1, -1, 31]).unwrap(), 0);
let revision = store.revision("main").unwrap();
store
.edit(
"main",
revision,
"edit",
vec![
BlockChange {
pos: [-1, -1, 31],
block: 0,
},
BlockChange {
pos: [512, 64, -513],
block: stone,
},
],
)
.unwrap();
drop(store);
let rejected = t.path().join("rejected");
assert!(export_anvil(&store_path, None, &rejected, Mode::Exact).is_err());
assert!(!rejected.exists());
let edited = t.path().join("edited");
let report = export_anvil(&store_path, None, &edited, Mode::BestEffort).unwrap();
assert_eq!(report.chunks, 2);
assert!(
report
.issues
.iter()
.any(|i| i.code == "dependent_records_invalidated")
);
assert_eq!(
fs::read(source.join("entities/r.-1.0.mca")).unwrap(),
fs::read(edited.join("entities/r.-1.0.mca")).unwrap()
);
let reimport = t.path().join("reimport");
import_anvil(&edited, &reimport, Mode::Exact).unwrap();
let mut store = WorldStore::open(&reimport, 2).unwrap();
assert_eq!(store.get_block("main", [-1, -1, 31]).unwrap(), 0);
let id = store.get_block("main", [512, 64, -513]).unwrap();
assert_eq!(store.registry()[id as usize], "minecraft:stone");
}
#[test]
fn native_new_world_export_and_schematic() {
let t = tempfile::tempdir().unwrap();
let store_path = t.path().join("store");
let mut store = WorldStore::open(&store_path, 2).unwrap();
store.create_world("native", None).unwrap();
let stone = store.register_block("minecraft:stone").unwrap();
let stairs = store
.register_block(
"minecraft:oak_stairs[facing=east,half=top,shape=straight,waterlogged=false]",
)
.unwrap();
store
.edit(
"native",
0,
"build",
vec![
BlockChange {
pos: [-1, 63, 0],
block: stone,
},
BlockChange {
pos: [0, 64, 0],
block: stairs,
},
],
)
.unwrap();
drop(store);
let world = t.path().join("world");
export_anvil(&store_path, Some("native"), &world, Mode::BestEffort).unwrap();
assert!(
world
.join("data/minecraft/world_gen_settings.dat")
.is_file()
);
let reimport = t.path().join("reimport");
assert_eq!(
import_anvil(&world, &reimport, Mode::Exact)
.unwrap()
.nonair_blocks,
2
);
let schem = t.path().join("schem");
export_schem(
&store_path,
"native",
&schem,
Some([-1, 63, 0]),
Some([0, 64, 0]),
Mode::Exact,
)
.unwrap();
let s = t.path().join("schem-reimport");
assert_eq!(
import_schem(&schem.join("world.schem"), &s, "main", Mode::Exact)
.unwrap()
.nonair_blocks,
2
);
}
#[test]
fn no_overwrite_and_provenance_hash_guard() {
let t = tempfile::tempdir().unwrap();
let path = t.path().join("store");
let source = fixture("sponge-v3.schem");
import_schem(&source, &path, "main", Mode::Exact).unwrap();
let before = fs::read(path.join("worlds.sqlite3")).unwrap();
assert!(import_schem(&source, &path, "other", Mode::Exact).is_err());
assert_eq!(before, fs::read(path.join("worlds.sqlite3")).unwrap());
fs::write(path.join("compat/original/source.schem"), b"tampered").unwrap();
let out = t.path().join("out");
assert!(export_schem(&path, "main", &out, None, None, Mode::Exact).is_err());
assert!(!out.exists());
}
#[test]
fn malformed_input_is_not_published() {
let t = tempfile::tempdir().unwrap();
let bad = t.path().join("bad.schem");
fs::write(&bad, b"not gzip").unwrap();
let out = t.path().join("out");
assert!(import_schem(&bad, &out, "main", Mode::Exact).is_err());
assert!(!out.exists());
}
fn metadata(path: &Path) -> serde_json::Value {
let db = rusqlite::Connection::open(path.join("server.sqlite3")).unwrap();
let text: String = db
.query_row("SELECT json FROM metadata WHERE id=1", [], |r| r.get(0))
.unwrap();
serde_json::from_str(&text).unwrap()
}
fn save_metadata(path: &Path, v: &serde_json::Value) {
let db = rusqlite::Connection::open(path.join("server.sqlite3")).unwrap();
db.execute(
"UPDATE metadata SET json=?1 WHERE id=1",
[serde_json::to_string(v).unwrap()],
)
.unwrap();
}
#[test]
fn native_entity_edit_move_create_remove_roundtrip() {
let t = tempfile::tempdir().unwrap();
let path = t.path().join("store");
import_anvil(&fixture("java26_2-world"), &path, Mode::Exact).unwrap();
let mut meta = metadata(&path);
let id = meta["entities"]
.as_object()
.unwrap()
.keys()
.next()
.unwrap()
.clone();
meta["entities"][&id]["position"] = serde_json::json!([513.5, 65., -513.5]);
meta["entities"][&id]["yaw"] = serde_json::json!(1.25);
meta["entities"][&id]["properties"]["Invisible"] = serde_json::json!(true);
let new_id = "12345678-1234-4234-8234-123456789abc";
meta["entities"][new_id] = serde_json::json!({"id":new_id,"world":"main","kind":"minecraft:cow","type":"minecraft:cow","position":[0.5,70.,0.5],"yaw":0.,"properties":{"Health":17.0,"CustomName":"moo","custom_unmapped":{"a":1}}});
meta["revision"] = serde_json::json!(1);
save_metadata(&path, &meta);
let rejected = t.path().join("exact");
assert!(
export_anvil(&path, None, &rejected, Mode::Exact)
.unwrap_err()
.to_string()
.contains("exact_entity_revision")
);
assert!(!rejected.exists());
let out = t.path().join("out");
let r = export_anvil(&path, None, &out, Mode::BestEffort).unwrap();
assert!(
r.issues
.iter()
.any(|i| i.code == "entity_property_unmapped")
);
let again = t.path().join("again");
import_anvil(&out, &again, Mode::Exact).unwrap();
let got = metadata(&again);
assert_eq!(got["entities"].as_object().unwrap().len(), 2);
assert_eq!(
got["entities"][&id]["position"],
meta["entities"][&id]["position"]
);
assert!(
got["entities"][&id]["properties"]["Invisible"]
.as_bool()
.unwrap()
);
assert_eq!(
got["entities"][new_id]["properties"]["Health"],
serde_json::json!(17.0)
);
meta["entities"] = serde_json::json!({});
save_metadata(&path, &meta);
let removed = t.path().join("removed");
let r = export_anvil(&path, None, &removed, Mode::BestEffort).unwrap();
assert!(r.issues.iter().any(|i| i.code == "entity_removed"));
let final_store = t.path().join("final");
import_anvil(&removed, &final_store, Mode::Exact).unwrap();
assert!(
metadata(&final_store)["entities"]
.as_object()
.unwrap()
.is_empty()
);
}
#[test]
fn sponge_entity_metadata_guard_and_position_roundtrip() {
let t = tempfile::tempdir().unwrap();
let path = t.path().join("store");
import_schem(&fixture("sponge-v3.schem"), &path, "main", Mode::Exact).unwrap();
let mut meta = metadata(&path);
let id = meta["entities"]
.as_object()
.unwrap()
.keys()
.next()
.unwrap()
.clone();
meta["entities"][&id]["position"] = serde_json::json!([-15., 1., 31.]);
save_metadata(&path, &meta);
assert!(
export_schem(
&path,
"main",
&t.path().join("reject"),
None,
None,
Mode::Exact
)
.is_err()
);
let out = t.path().join("out");
export_schem(&path, "main", &out, None, None, Mode::BestEffort).unwrap();
let again = t.path().join("again");
import_schem(&out.join("world.schem"), &again, "main", Mode::Exact).unwrap();
assert_eq!(
metadata(&again)["entities"][&id]["position"],
meta["entities"][&id]["position"]
);
}
fn write_gzip(path: &Path, tag: &Tag) {
use std::io::Write;
let mut out = flate2::write::GzEncoder::new(
fs::File::create(path).unwrap(),
flate2::Compression::default(),
);
out.write_all(&nbt::encode("", tag).unwrap()).unwrap();
out.finish().unwrap();
}
#[test]
fn duplicate_entity_uuid_stays_outside_editable_subset() {
let t = tempfile::tempdir().unwrap();
let mut tag = read_gzip(&fixture("sponge-v3.schem"));
let c = tag
.compound_mut()
.unwrap()
.get_mut("Schematic")
.unwrap()
.compound_mut()
.unwrap();
if let Tag::List(_, entities) = c.get_mut("Entities").unwrap() {
entities.push(entities[0].clone());
} else {
panic!("fixture entities")
}
let source = t.path().join("duplicate.schem");
write_gzip(&source, &tag);
let store = t.path().join("store");
let report = import_schem(&source, &store, "main", Mode::Exact).unwrap();
assert!(
report
.issues
.iter()
.any(|i| i.code == "entity_duplicate_uuid")
);
assert!(metadata(&store)["entities"].as_object().unwrap().is_empty());
let out = t.path().join("out");
export_schem(&store, "main", &out, None, None, Mode::Exact).unwrap();
assert_eq!(
fs::read(source).unwrap(),
fs::read(out.join("world.schem")).unwrap()
);
}
#[test]
fn unsupported_data_version_rejects_exact_before_publication() {
let t = tempfile::tempdir().unwrap();
let mut tag = read_gzip(&fixture("sponge-v3.schem"));
let c = tag
.compound_mut()
.unwrap()
.get_mut("Schematic")
.unwrap()
.compound_mut()
.unwrap();
c.insert("DataVersion".into(), Tag::Int(100));
let source = t.path().join("old.schem");
write_gzip(&source, &tag);
let dest = t.path().join("store");
assert!(import_schem(&source, &dest, "main", Mode::Exact).is_err());
assert!(!dest.exists());
let report = import_schem(&source, &dest, "main", Mode::BestEffort).unwrap();
assert!(
report
.issues
.iter()
.any(|i| i.code == "unverified_data_version")
);
assert!(
export_schem(
&dest,
"main",
&t.path().join("reject"),
None,
None,
Mode::Exact
)
.is_err()
);
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "shacraft-content"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
flate2 = "1"
sha2 = "0.10"
[dev-dependencies]
tempfile = "3"
Binary file not shown.
@@ -0,0 +1,50 @@
{
"source": {
"url": "https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar",
"size": 60894273,
"sha1": "823e2250d24b3ddac457a60c92a6a941943fcd6a",
"sha256": "cdacdfb25898de5e4b4b0e5ddcc2722f77067e46605709c2d886c000ebb63ec5"
},
"version": {
"id": "26.2",
"name": "26.2",
"world_version": 4903,
"series_id": "main",
"protocol_version": 776,
"pack_version": {
"resource_major": 88,
"resource_minor": 0,
"data_major": 107,
"data_minor": 1
},
"build_time": "2026-06-16T12:01:27+00:00",
"java_component": "java-runtime-epsilon",
"java_version": 25,
"stable": true,
"use_editor": false
},
"java": "openjdk version \"25.0.1\" 2025-10-21 LTS\nOpenJDK Runtime Environment Microsoft-12574222 (build 25.0.1+8-LTS)\nOpenJDK 64-Bit Server VM Microsoft-12574222 (build 25.0.1+8-LTS, mixed mode)",
"extracted_utc": "2026-09-14T13:56:31.823007+00:00",
"command": "python3 scripts/catalog_generate.py --java /path/to/java25/bin/java",
"data_command": "java -Xmx1G -DbundlerMainClass=net.minecraft.data.Main -jar server.jar --reports --output generated",
"collision_context": "All states at BlockPos.ZERO, with air neighbors and CollisionContext.empty(); compare solid neighbors and positionContext(+10/-10). Entity-dependent/block-entity contexts remain approximate.",
"counts": {
"blocks": 1196,
"states": 32366,
"entities": 158,
"distinct_collision_shapes": 326,
"collision_errors": 0,
"context_sample_differences": 32
},
"inputs": {
"blocks.json": "dec22eb9547b0bbea396cbf35569f7ec81b7be662775ce03dc2236227473af7b",
"registries.json": "9f34d4deb0b494627f6a1a1590e7fb21c11a3949d84bacb59a304e6315287b24",
"measurements.json": "256a1852db69f9e05394c44a76cbc9d22e9b02a85b16a95fb4ee52c05e966045",
"catalog_extract.java": "6a80d75c3a63da382dcedd702ba2218a1f8339df77a30829d33df6e3dd599164"
},
"outputs": {
"catalog.json.gz": "df889f6ce074b96130011d00b2f3b661cf910aba44064a0b5f361d7c064ce8ed",
"report-identities.json.gz": "938232b10d17071d3eefa54ce9a1c3026e17f39b5a2c0f4d6ede2644824d9135"
},
"license_note": "Factual interoperability data derived through public APIs. No Minecraft visual/audio assets, server binaries or decompiled code are redistributed. Authored runtime and render templates: MIT OR Apache-2.0."
}
@@ -0,0 +1,16 @@
use shacraft_content::Catalog;
fn main() -> anyhow::Result<()> {
let c = Catalog::load_builtin()?;
println!("{}", serde_json::to_string_pretty(&c.summary())?);
let mut approximate = std::collections::BTreeMap::<String, usize>::new();
for state in &c.states {
if state.coverage.render == "authored-approximate" {
*approximate.entry(state.family.clone()).or_default() += 1;
}
}
eprintln!(
"Approximate render families: {}",
serde_json::to_string(&approximate)?
);
Ok(())
}
+565
View File
@@ -0,0 +1,565 @@
//! Independent factual content catalog and authored render templates.
//!
//! Minecraft IDs are provenance, never Shacraft runtime BlockIds. Collision
//! measurements use an explicitly documented world/entity context; they do not
//! claim to reproduce entity-dependent or block-entity-dependent behavior.
use anyhow::{Context, Result, bail, ensure};
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
mod packages;
mod shapes;
pub use packages::*;
pub const MINECRAFT_VERSION: &str = "26.2";
pub const DATA_VERSION: i32 = 4903;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct Aabb {
pub min: [f32; 3],
pub max: [f32; 3],
}
impl Aabb {
pub const fn new(min: [f32; 3], max: [f32; 3]) -> Self {
Self { min, max }
}
pub fn volume(&self) -> f32 {
(0..3).map(|i| self.max[i] - self.min[i]).product()
}
pub fn valid(&self) -> bool {
(0..3).all(|i| {
self.min[i].is_finite() && self.max[i].is_finite() && self.max[i] > self.min[i]
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Coverage {
pub identity: String,
pub render: String,
pub collision: String,
pub behavior: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateDefinition {
pub state: String,
/// Target Java numerical state ID, unrelated to the persistent runtime registry.
pub minecraft_id: u32,
pub color: [u8; 3],
pub opacity: f32,
pub collision: Vec<Aabb>,
pub render: Vec<Aabb>,
pub coverage: Coverage,
pub family: String,
pub light: u8,
pub hardness: f32,
}
impl StateDefinition {
pub fn solid(&self) -> bool {
!self.collision.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockDefinition {
pub name: String,
pub default_state: String,
pub properties: BTreeMap<String, Vec<String>>,
pub family: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockSample {
pub state: String,
pub position: [i32; 3],
pub family: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityDefinition {
pub name: String,
pub width: f32,
pub height: f32,
pub color: [u8; 3],
/// Authored boxes in entity-local coordinates, relative to the feet.
pub render: Vec<Aabb>,
/// Default physical dimensions, registry flags, attributes, and model family.
/// Instance properties and unknown NBT belong to the entity persistence layer.
pub properties: BTreeMap<String, Value>,
pub coverage: Coverage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Catalog {
pub version: String,
pub data_version: i32,
pub blocks: Vec<BlockDefinition>,
pub states: Vec<StateDefinition>,
pub entities: Vec<EntityDefinition>,
#[serde(skip)]
state_index: HashMap<String, usize>,
#[serde(skip)]
entity_index: HashMap<String, usize>,
}
#[derive(Deserialize)]
struct RawCatalog {
version: String,
data_version: i32,
blocks: Vec<BlockDefinition>,
states: Vec<RawState>,
shapes: Vec<Vec<[f32; 6]>>,
entities: BTreeMap<String, BTreeMap<String, Value>>,
}
#[derive(Deserialize)]
struct RawState {
state: String,
minecraft_id: u32,
block: usize,
shape: usize,
context: bool,
light: u8,
hardness: f32,
}
fn decompress(bytes: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
GzDecoder::new(bytes)
.take(32 * 1024 * 1024)
.read_to_end(&mut out)?;
ensure!(
out.len() < 32 * 1024 * 1024,
"catalog exceeds maximum decompressed size"
);
Ok(out)
}
impl Catalog {
pub fn load_builtin() -> Result<Self> {
let raw: RawCatalog =
serde_json::from_slice(&decompress(include_bytes!("../data/catalog.json.gz"))?)?;
ensure!(
raw.version == MINECRAFT_VERSION && raw.data_version == DATA_VERSION,
"unexpected built-in catalog version"
);
let mut states = Vec::with_capacity(raw.states.len() + 1);
for s in raw.states {
let block = raw.blocks.get(s.block).context("invalid block index")?;
let collision = raw
.shapes
.get(s.shape)
.context("invalid shape index")?
.iter()
.map(|b| Aabb::new([b[0], b[1], b[2]], [b[3], b[4], b[5]]))
.collect::<Vec<_>>();
let (render, render_coverage) =
shapes::block_render(&s.state, &block.family, &collision);
let contextual = s.context
|| matches!(
block.family.as_str(),
"powder_snow" | "shulker_box" | "moving_piston" | "big_dripleaf"
);
states.push(StateDefinition {
color: shapes::color(&block.name),
opacity: shapes::opacity(&block.name),
state: s.state,
minecraft_id: s.minecraft_id,
collision,
render,
coverage: Coverage {
identity: "official-report-exact".into(),
render: render_coverage.into(),
collision: if contextual {
"approximate-context"
} else {
"measured-empty-context"
}
.into(),
behavior: "storage-and-physics".into(),
},
family: block.family.clone(),
light: s.light,
hardness: s.hardness,
});
}
let mut entities = Vec::new();
for (name, mut properties) in raw.entities {
let width = properties
.get("width")
.and_then(Value::as_f64)
.context("entity width missing")? as f32;
let height = properties
.get("height")
.and_then(Value::as_f64)
.context("entity height missing")? as f32;
let (render, family) = shapes::entity_render(&name, width, height);
properties.insert("model_family".into(), json!(family));
entities.push(EntityDefinition {
name: name.clone(),
width,
height,
color: shapes::entity_color(&name),
render,
properties,
coverage: Coverage {
identity: "official-report-exact".into(),
render: if family == "invisible" {
"intentionally-invisible"
} else {
"authored-procedural"
}
.into(),
collision: "measured-default-dimensions".into(),
behavior: "persistent-instance-no-vanilla-ai".into(),
},
});
}
let mut result = Self {
version: raw.version,
data_version: raw.data_version,
blocks: raw.blocks,
states,
entities,
state_index: HashMap::new(),
entity_index: HashMap::new(),
};
result.add_trampoline();
result.rebuild_indexes()?;
Ok(result)
}
pub fn rebuild_indexes(&mut self) -> Result<()> {
self.state_index.clear();
self.entity_index.clear();
for (i, s) in self.states.iter().enumerate() {
ensure!(
self.state_index.insert(s.state.clone(), i).is_none(),
"duplicate canonical state {}",
s.state
);
ensure!(
s.collision.iter().chain(s.render.iter()).all(Aabb::valid),
"invalid geometry for {}",
s.state
);
}
for (i, e) in self.entities.iter().enumerate() {
ensure!(
self.entity_index.insert(e.name.clone(), i).is_none(),
"duplicate entity {}",
e.name
);
ensure!(
e.render.iter().all(Aabb::valid),
"invalid entity model {}",
e.name
);
}
Ok(())
}
pub fn state(&self, canonical: &str) -> Option<&StateDefinition> {
self.state_index
.get(canonical)
.map(|i| &self.states[*i])
.or_else(|| {
self.blocks
.iter()
.find(|b| b.name == canonical)
.and_then(|b| self.state_index.get(&b.default_state))
.map(|i| &self.states[*i])
})
}
pub fn entity(&self, name: &str) -> Option<&EntityDefinition> {
self.entity_index.get(name).map(|i| &self.entities[*i])
}
pub fn default_state(&self, name: &str) -> Option<&str> {
self.blocks
.iter()
.find(|b| b.name == name)
.map(|b| b.default_state.as_str())
}
pub fn palette(&self) -> BTreeMap<String, String> {
[
("grass", "minecraft:grass_block"),
("stone", "minecraft:stone"),
("snow", "minecraft:snow_block"),
("glass", "minecraft:glass"),
("wood", "minecraft:oak_planks"),
("stairs", "minecraft:oak_stairs"),
("slab", "minecraft:oak_slab"),
("fence", "minecraft:oak_fence"),
("water", "minecraft:water"),
("sand", "minecraft:sand"),
("light", "minecraft:sea_lantern"),
("trampoline", "shacraft:trampoline"),
]
.into_iter()
.filter_map(|(key, name)| {
self.default_state(name)
.map(|s| (key.to_owned(), s.to_owned()))
})
.collect()
}
/// One default state per block, in an original 32-column inspection garden.
/// The floor is caller-owned; every sample is one cell above it.
pub fn sampler(&self) -> Vec<BlockSample> {
self.blocks
.iter()
.enumerate()
.map(|(i, b)| BlockSample {
state: b.default_state.clone(),
family: b.family.clone(),
position: [-48 + (i % 32) as i32 * 3, 1, -57 + (i / 32) as i32 * 3],
})
.collect()
}
pub fn summary(&self) -> Value {
let mut render = BTreeMap::<&str, usize>::new();
let mut collision = BTreeMap::<&str, usize>::new();
for s in &self.states {
*render.entry(&s.coverage.render).or_default() += 1;
*collision.entry(&s.coverage.collision).or_default() += 1;
}
json!({"minecraft_version":self.version,"data_version":self.data_version,
"minecraft_blocks":self.blocks.iter().filter(|b|b.name.starts_with("minecraft:")).count(),
"minecraft_states":self.states.iter().filter(|s|s.state.starts_with("minecraft:")).count(),
"entities":self.entities.len(),"total_states":self.states.len(),"render":render,"collision":collision,
"palette":self.palette(),"collision_context":"BlockPos.ZERO, air neighbors, empty entity context; contextual classes flagged separately",
"simulation":"server movement, block editing, authored modules; vanilla AI/redstone/fluid simulation are not implemented"})
}
fn add_trampoline(&mut self) {
self.blocks.push(BlockDefinition {
name: "shacraft:trampoline".into(),
default_state: "shacraft:trampoline".into(),
properties: BTreeMap::new(),
family: "trampoline".into(),
});
self.states.push(StateDefinition {
state: "shacraft:trampoline".into(),
minecraft_id: u32::MAX,
color: [97, 205, 151],
opacity: 1.,
collision: vec![Aabb::new([0., 0., 0.], [1., 1., 1.])],
render: vec![
Aabb::new([0., 0., 0.], [1., 0.18, 1.]),
Aabb::new([0.05, 0.18, 0.05], [0.95, 0.86, 0.95]),
Aabb::new([0., 0.86, 0.], [1., 1., 1.]),
],
coverage: Coverage {
identity: "authored-package".into(),
render: "authored-procedural".into(),
collision: "authored-exact".into(),
behavior: "wasm-on-jump".into(),
},
family: "trampoline".into(),
light: 0,
hardness: 1.,
});
}
}
/// Canonicalize registry state keys without discarding unknown properties.
pub fn canonical_state(name: &str, properties: &BTreeMap<String, String>) -> Result<String> {
ensure!(
!name.is_empty()
&& name.is_ascii()
&& name.contains(':')
&& !name
.bytes()
.any(|b| b.is_ascii_whitespace() || b"[]=,".contains(&b)),
"invalid resource name"
);
for (k, v) in properties {
if k.is_empty()
|| v.is_empty()
|| !k.is_ascii()
|| !v.is_ascii()
|| k.bytes()
.chain(v.bytes())
.any(|b| b.is_ascii_whitespace() || b"[]=,:".contains(&b))
{
bail!("invalid property");
}
}
let result = if properties.is_empty() {
name.to_owned()
} else {
format!(
"{}[{}]",
name,
properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",")
)
};
ensure!(result.len() <= 512, "canonical state too long");
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::sync::OnceLock;
fn catalog() -> &'static Catalog {
static C: OnceLock<Catalog> = OnceLock::new();
C.get_or_init(|| Catalog::load_builtin().unwrap())
}
#[test]
fn complete_against_independent_official_report_projection() {
let c = catalog();
let report: Value = serde_json::from_slice(
&decompress(include_bytes!("../data/report-identities.json.gz")).unwrap(),
)
.unwrap();
let blocks = report["blocks"].as_object().unwrap();
assert_eq!(blocks.len(), 1196);
assert_eq!(c.data_version, 4903);
let mut actual = HashMap::new();
for s in &c.states {
if s.state.starts_with("minecraft:") {
actual.insert(s.state.clone(), s.minecraft_id);
}
}
let mut expected = HashMap::new();
for (name, b) in blocks {
let def = c.blocks.iter().find(|b| &b.name == name).unwrap();
assert_eq!(
serde_json::to_value(&def.properties).unwrap(),
b["properties"]
);
for state in b["states"].as_array().unwrap() {
let props =
serde_json::from_value(state.get("properties").cloned().unwrap_or(json!({})))
.unwrap();
let name = canonical_state(name, &props).unwrap();
expected.insert(name.clone(), state["id"].as_u64().unwrap() as u32);
if state.get("default") == Some(&json!(true)) {
assert_eq!(def.default_state, name);
}
}
}
assert_eq!(actual.len(), 32366);
assert_eq!(actual, expected);
assert_eq!(
c.entities
.iter()
.map(|e| e.name.clone())
.collect::<HashSet<_>>(),
report["entities"]
.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap().to_owned())
.collect()
);
assert!(c.state("minecraft:potent_sulfur").is_some());
assert!(c.entity("minecraft:sulfur_cube").is_some());
}
#[test]
fn every_state_has_valid_shapes_and_explicit_coverage() {
for s in &catalog().states {
assert!(!s.coverage.render.is_empty());
assert!(!s.coverage.collision.is_empty());
for b in s.collision.iter().chain(&s.render) {
assert!(b.valid(), "{} {b:?}", s.state);
assert!(
b.min.iter().chain(&b.max).all(|v| (-2.0..=3.0).contains(v)),
"{} {b:?}",
s.state
);
}
if s.family != "air"
&& !matches!(
s.family.as_str(),
"light" | "structure_void" | "barrier" | "moving_piston"
)
{
assert!(!s.render.is_empty(), "missing {}", s.state);
}
}
}
#[test]
fn official_entity_dimensions_and_authored_models_present() {
assert_eq!(catalog().entities.len(), 158);
for e in &catalog().entities {
assert!(e.width >= 0. && e.height >= 0.);
assert!(e.properties.contains_key("attributes"));
if e.properties["model_family"] != "invisible" {
assert!(!e.render.is_empty());
}
}
assert_eq!(catalog().entity("minecraft:player").unwrap().width, 0.6);
assert_eq!(
catalog().entity("minecraft:cow").unwrap().properties["model_family"],
"quadruped"
);
}
#[test]
fn sampler_defaults_and_palette_are_registered() {
let c = catalog();
let samples = c.sampler();
assert_eq!(samples.len(), 1197);
let positions = samples.iter().map(|s| s.position).collect::<HashSet<_>>();
assert_eq!(positions.len(), samples.len());
for sample in samples {
assert!(c.state(&sample.state).is_some());
assert!(sample.position[0].abs() <= 64 && sample.position[2].abs() <= 64);
}
for state in c.palette().values() {
assert!(c.state(state).is_some());
}
}
#[test]
fn key_collision_states_and_orientation() {
let c = catalog();
let bottom = c
.state("minecraft:oak_slab[type=bottom,waterlogged=false]")
.unwrap();
let top = c
.state("minecraft:oak_slab[type=top,waterlogged=false]")
.unwrap();
assert_eq!(bottom.collision[0], Aabb::new([0., 0., 0.], [1., 0.5, 1.]));
assert_eq!(top.collision[0].min[1], 0.5);
for half in ["bottom", "top"] {
for facing in ["north", "south", "east", "west"] {
let s=c.state(&format!("minecraft:oak_stairs[facing={facing},half={half},shape=straight,waterlogged=false]")).unwrap();
assert!((s.collision.iter().map(Aabb::volume).sum::<f32>() - 0.75).abs() < 0.0001);
}
}
let door = c
.state(
"minecraft:oak_door[facing=north,half=lower,hinge=left,open=false,powered=false]",
)
.unwrap();
let open = c
.state("minecraft:oak_door[facing=north,half=lower,hinge=left,open=true,powered=false]")
.unwrap();
assert_ne!(door.collision, open.collision);
assert!(
c.state("minecraft:water[level=0]")
.unwrap()
.collision
.is_empty()
);
assert!(
c.state("minecraft:oak_fence")
.unwrap()
.collision
.iter()
.any(|b| b.max[1] == 1.5)
);
assert!(
c.state("minecraft:scaffolding")
.unwrap()
.coverage
.collision
.contains("approximate")
);
assert!(
c.state("minecraft:powder_snow")
.unwrap()
.coverage
.collision
.contains("approximate")
);
}
}
+365
View File
@@ -0,0 +1,365 @@
//! Unified packages: verified resources with an explicit public/server boundary.
use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::path::{Component, Path, PathBuf};
pub const MAX_RESOURCE_BYTES: u64 = 16 * 1024 * 1024;
pub const MAX_PACKAGE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PackageDependency {
pub id: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PackageResource {
pub path: String,
pub scope: String,
pub role: String,
pub sha256: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PackageManifest {
pub schema: u32,
pub id: String,
pub version: String,
pub license: String,
pub dependencies: Vec<PackageDependency>,
pub capabilities: Vec<String>,
pub resources: Vec<PackageResource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Package {
pub directory: PathBuf,
pub manifest: PackageManifest,
}
fn token(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
}
fn version(s: &str) -> bool {
let p = s.split('.').collect::<Vec<_>>();
p.len() == 3
&& p.iter().all(|s| {
!s.is_empty()
&& s.len() <= 5
&& (s.len() == 1 || !s.starts_with('0'))
&& s.bytes().all(|b| b.is_ascii_digit())
})
}
fn safe_path(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 256
&& s.is_ascii()
&& !s.contains('\\')
&& !s.contains(':')
&& !s.contains('%')
&& !s.bytes().any(|b| b.is_ascii_control())
&& !Path::new(s).is_absolute()
&& Path::new(s)
.components()
.all(|c| matches!(c, Component::Normal(_)))
&& s.split('/').all(|s| !s.is_empty() && s != "." && s != "..")
}
impl PackageManifest {
pub fn validate(&self) -> Result<()> {
ensure!(self.schema == 1, "unsupported package schema");
ensure!(token(&self.id), "invalid package id");
ensure!(version(&self.version), "invalid exact semantic version");
ensure!(
!self.license.is_empty()
&& self.license.len() <= 256
&& !self.license.chars().any(char::is_control),
"license required"
);
ensure!(
self.resources.len() <= 128 && !self.resources.is_empty(),
"invalid package resource count"
);
ensure!(
self.dependencies.len() <= 32 && self.capabilities.len() <= 32,
"too many dependencies or capabilities"
);
let mut paths = HashSet::new();
let mut total = 0u64;
for r in &self.resources {
ensure!(safe_path(&r.path), "unsafe resource path {}", r.path);
ensure!(paths.insert(&r.path), "duplicate resource path");
ensure!(
matches!(r.scope.as_str(), "client" | "common" | "server"),
"invalid resource scope"
);
ensure!(token(&r.role), "invalid resource role");
ensure!(
r.sha256.len() == 64
&& r.sha256
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
"invalid SHA-256"
);
ensure!(r.size <= MAX_RESOURCE_BYTES, "resource too large");
total = total.checked_add(r.size).context("package size overflow")?;
if r.role == "wasm-on-jump" {
ensure!(
r.scope == "server" && r.path.ends_with(".wasm"),
"executable jump hook must be server-only WASM"
);
}
}
ensure!(
total <= MAX_PACKAGE_BYTES,
"package exceeds total size limit"
);
let mut deps = HashSet::new();
for d in &self.dependencies {
ensure!(
token(&d.id) && version(&d.version) && d.id != self.id && deps.insert(&d.id),
"invalid or duplicate dependency"
);
}
for c in &self.capabilities {
ensure!(
matches!(
c.as_str(),
"blocks.define"
| "entities.define"
| "client.texture"
| "client.shader"
| "client.sound"
| "client.style"
| "server.on_jump"
),
"unsupported capability {c}"
);
}
ensure!(
self.resources.iter().all(|r| r.role != "wasm-on-jump"
|| self.capabilities.iter().any(|c| c == "server.on_jump")),
"WASM hook capability missing"
);
Ok(())
}
/// Clients never receive server resource names, contents, sizes, or hashes.
pub fn client_manifest(&self) -> Value {
json!({"schema":self.schema,"id":self.id,"version":self.version,"license":self.license,
"dependencies":self.dependencies,"capabilities":self.capabilities.iter().filter(|s|!s.starts_with("server.")).collect::<Vec<_>>(),
"resources":self.resources.iter().filter(|r|r.scope!="server").collect::<Vec<_>>()})
}
}
impl Package {
pub fn load(directory: impl AsRef<Path>) -> Result<Self> {
let dir = directory.as_ref();
ensure!(
!dir.symlink_metadata()?.file_type().is_symlink(),
"package directory is a symlink"
);
let directory = dir.canonicalize()?;
let path = directory.join("manifest.json");
ensure!(
!path.symlink_metadata()?.file_type().is_symlink(),
"package manifest is a symlink"
);
ensure!(path.metadata()?.len() <= 256 * 1024, "manifest too large");
let manifest: PackageManifest = serde_json::from_slice(&std::fs::read(path)?)?;
manifest.validate()?;
let package = Self {
directory,
manifest,
};
for r in &package.manifest.resources {
package.read_resource(&r.path)?;
}
Ok(package)
}
/// Re-verifies the resource at read time to reject replacement after loading.
pub fn read_resource(&self, path: &str) -> Result<Vec<u8>> {
let resource = self
.manifest
.resources
.iter()
.find(|r| r.path == path)
.context("resource is not declared")?;
ensure!(safe_path(path), "unsafe resource path");
let mut real = self.directory.clone();
for part in Path::new(path).components() {
real.push(part);
ensure!(
!real.symlink_metadata()?.file_type().is_symlink(),
"resource paths may not contain symlinks"
);
}
let file = std::fs::File::open(&real)?;
ensure!(
file.metadata()?.is_file()
&& file.metadata()?.len() == resource.size
&& resource.size <= MAX_RESOURCE_BYTES,
"resource size mismatch"
);
use std::io::Read;
let mut bytes = Vec::new();
file.take(MAX_RESOURCE_BYTES + 1).read_to_end(&mut bytes)?;
ensure!(
bytes.len() as u64 == resource.size,
"resource changed while reading"
);
ensure!(
format!("{:x}", Sha256::digest(&bytes)) == resource.sha256,
"resource hash mismatch: {path}"
);
Ok(bytes)
}
pub fn public_resource(&self, path: &str) -> Result<Vec<u8>> {
ensure!(
self.manifest
.resources
.iter()
.any(|r| r.path == path && r.scope != "server"),
"resource is not public"
);
self.read_resource(path)
}
}
/// Load and verify the entire dependency graph. Missing or incompatible required
/// packages and cycles fail before the server exposes any package to clients.
pub fn load_packages(root: impl AsRef<Path>) -> Result<Vec<Package>> {
let mut dirs = std::fs::read_dir(root)?
.map(|e| e.map(|e| e.path()))
.collect::<std::io::Result<Vec<_>>>()?;
dirs.sort();
ensure!(dirs.len() <= 128, "too many package entries");
let mut out = Vec::new();
let mut ids = BTreeMap::new();
for path in dirs {
if path.is_dir() && path.join("manifest.json").exists() {
let p = Package::load(path)?;
ensure!(
ids.insert(p.manifest.id.clone(), out.len()).is_none(),
"duplicate package id"
);
out.push(p);
}
}
for p in &out {
for d in &p.manifest.dependencies {
let i = *ids
.get(&d.id)
.with_context(|| format!("missing required package {}", d.id))?;
ensure!(
out[i].manifest.version == d.version,
"dependency version mismatch for {}",
d.id
);
}
}
fn visit(
i: usize,
p: &[Package],
ids: &BTreeMap<String, usize>,
visiting: &mut HashSet<usize>,
done: &mut HashSet<usize>,
) -> Result<()> {
if done.contains(&i) {
return Ok(());
}
if !visiting.insert(i) {
bail!("package dependency cycle")
}
for d in &p[i].manifest.dependencies {
visit(ids[&d.id], p, ids, visiting, done)?;
}
visiting.remove(&i);
done.insert(i);
Ok(())
}
let mut done = HashSet::new();
for i in 0..out.len() {
visit(i, &out, &ids, &mut HashSet::new(), &mut done)?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn package_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packages")
}
#[test]
fn bundled_packages_hashes_dependencies_and_privacy() {
let p = load_packages(package_root()).unwrap();
assert_eq!(p.len(), 2);
let t = p
.iter()
.find(|p| p.manifest.id == "shacraft.trampoline")
.unwrap();
assert!(
t.read_resource("server/trampoline.wasm")
.unwrap()
.starts_with(b"\0asm")
);
assert!(t.public_resource("server/trampoline.wasm").is_err());
let public = t.manifest.client_manifest().to_string();
assert!(!public.contains("server/"));
assert!(!public.contains("server.on_jump"));
}
#[test]
fn paths_sizes_and_versions_are_validated() {
let p = load_packages(package_root()).unwrap();
for path in [
"../secret",
"/etc/passwd",
"client/../../secret",
"client\\secret",
"client//file",
"client/./file",
"client/%2e%2e/key",
] {
let mut m = p[0].manifest.clone();
m.resources[0].path = path.into();
assert!(m.validate().is_err(), "{path}");
}
let mut m = p[0].manifest.clone();
m.resources[0].size = MAX_RESOURCE_BYTES + 1;
assert!(m.validate().is_err());
m = p[0].manifest.clone();
m.version = "latest".into();
assert!(m.validate().is_err());
}
#[test]
fn changed_resource_rejected() {
let dir = tempfile::tempdir().unwrap();
let bytes = b"own content";
std::fs::write(dir.path().join("asset"), bytes).unwrap();
let m = PackageManifest {
schema: 1,
id: "test.sample".into(),
version: "1.0.0".into(),
license: "MIT".into(),
dependencies: vec![],
capabilities: vec![],
resources: vec![PackageResource {
path: "asset".into(),
scope: "client".into(),
role: "texture".into(),
sha256: format!("{:x}", Sha256::digest(bytes)),
size: bytes.len() as u64,
}],
};
std::fs::write(
dir.path().join("manifest.json"),
serde_json::to_vec(&m).unwrap(),
)
.unwrap();
let p = Package::load(dir.path()).unwrap();
std::fs::write(dir.path().join("asset"), b"bad content").unwrap();
assert!(p.read_resource("asset").is_err());
}
}
+828
View File
@@ -0,0 +1,828 @@
//! Shacraft's original low-poly templates; no Minecraft visual models/assets.
use crate::Aabb;
use std::collections::BTreeMap;
fn bx(a: [f32; 3], b: [f32; 3]) -> Aabb {
Aabb::new(a, b)
}
fn props(state: &str) -> BTreeMap<&str, &str> {
state
.split_once('[')
.map(|(_, tail)| {
tail.trim_end_matches(']')
.split(',')
.filter_map(|p| p.split_once('='))
.collect()
})
.unwrap_or_default()
}
fn get<'a>(p: &BTreeMap<&'a str, &'a str>, key: &str, default: &'a str) -> &'a str {
p.get(key).copied().unwrap_or(default)
}
fn num(p: &BTreeMap<&str, &str>, key: &str, default: f32) -> f32 {
p.get(key).and_then(|s| s.parse().ok()).unwrap_or(default)
}
fn rotate(boxes: Vec<Aabb>, facing: &str) -> Vec<Aabb> {
let steps = match facing {
"east" => 1,
"south" => 2,
"west" => 3,
_ => 0,
};
boxes
.into_iter()
.map(|mut b| {
for _ in 0..steps {
b = bx(
[1. - b.max[2], b.min[1], b.min[0]],
[1. - b.min[2], b.max[1], b.max[0]],
)
}
b
})
.collect()
}
const FULL: Aabb = Aabb::new([0., 0., 0.], [1., 1., 1.]);
pub(crate) fn block_render(
state: &str,
family: &str,
collision: &[Aabb],
) -> (Vec<Aabb>, &'static str) {
let p = props(state);
let name = state.split('[').next().unwrap_or(state);
let facing = get(&p, "facing", "north");
let b = match family {
"air" | "barrier" | "light" | "structure_void" | "moving_piston" => {
return (vec![], "intentionally-invisible");
}
"liquid" | "bubble_column" => vec![bx(
[0., 0., 0.],
[
1.,
if num(&p, "level", 0.) >= 8. {
1.
} else {
(8. - num(&p, "level", 0.)).max(1.) / 9.
},
1.,
],
)],
"button" | "lever" => {
let pressed = get(&p, "powered", "false") == "true";
let depth = if pressed { 0.0625 } else { 0.125 };
let face = get(&p, "face", "wall");
let mut b = vec![match face {
"floor" => bx([0.3125, 0., 0.375], [0.6875, depth, 0.625]),
"ceiling" => bx([0.3125, 1. - depth, 0.375], [0.6875, 1., 0.625]),
_ => bx([0.3125, 0.375, 1. - depth], [0.6875, 0.625, 1.]),
}];
if family == "lever" {
b.push(match face {
"floor" => bx(
[0.46875, 0.0625, if pressed { 0.55 } else { 0.25 }],
[0.53125, 0.375, if pressed { 0.75 } else { 0.45 }],
),
"ceiling" => bx(
[0.46875, 0.625, if pressed { 0.55 } else { 0.25 }],
[0.53125, 0.9375, if pressed { 0.75 } else { 0.45 }],
),
_ => bx(
[0.46875, if pressed { 0.25 } else { 0.55 }, 0.625],
[0.53125, if pressed { 0.45 } else { 0.75 }, 0.9375],
),
});
}
rotate(b, facing)
}
"pressure_plate" | "weighted_pressure_plate" => vec![bx(
[0.0625, 0., 0.0625],
[
0.9375,
if get(&p, "powered", "false") == "true" || num(&p, "power", 0.) > 0. {
0.03125
} else {
0.0625
},
0.9375,
],
)],
"trip_wire_hook" => rotate(
vec![
bx([0.375, 0.25, 0.875], [0.625, 0.75, 1.]),
bx([0.46875, 0.4375, 0.5], [0.53125, 0.5, 0.9375]),
],
facing,
),
"snow_layer" => vec![bx([0., 0., 0.], [1., num(&p, "layers", 1.) / 8., 1.])],
"mossy_carpet" => {
let mut b = vec![bx([0., 0., 0.], [1., 0.0625, 1.])];
for (dir, a, z) in [
("north", [0., 0., 0.], [1., 0., 0.0625]),
("south", [0., 0., 0.9375], [1., 0., 1.]),
("west", [0., 0., 0.], [0.0625, 0., 1.]),
("east", [0.9375, 0., 0.], [1., 0., 1.]),
] {
let v = get(&p, dir, "none");
if v != "none" {
b.push(bx(a, [z[0], if v == "tall" { 1. } else { 0.5 }, z[2]]));
}
}
b
}
"big_dripleaf" | "big_dripleaf_stem" => {
let mut b = vec![bx([0.4375, 0., 0.4375], [0.5625, 0.875, 0.5625])];
if family == "big_dripleaf" {
let tilt = get(&p, "tilt", "none");
b.push(if tilt == "full" {
bx([0.0625, 0.375, 0.75], [0.9375, 0.875, 0.8125])
} else {
bx(
[
0.0625,
if tilt == "partial" { 0.625 } else { 0.8125 },
0.0625,
],
[0.9375, 0.875, 0.9375],
)
});
}
rotate(b, facing)
}
"fence" => {
let mut b = vec![bx([0.375, 0., 0.375], [0.625, 1., 0.625])];
for (dir, a, z) in [
("north", [0.4375, 0., 0.], [0.5625, 0., 0.5]),
("south", [0.4375, 0., 0.5], [0.5625, 0., 1.]),
("west", [0., 0., 0.4375], [0.5, 0., 0.5625]),
("east", [0.5, 0., 0.4375], [1., 0., 0.5625]),
] {
if get(&p, dir, "false") == "true" {
for y in [0.375, 0.75] {
b.push(bx([a[0], y, a[2]], [z[0], y + 0.1875, z[2]]));
}
}
}
b
}
"wall" => {
let mut b = vec![];
if get(&p, "up", "true") == "true" {
b.push(bx([0.25, 0., 0.25], [0.75, 1., 0.75]));
}
for (dir, a, z) in [
("north", [0.3125, 0., 0.], [0.6875, 0., 0.5]),
("south", [0.3125, 0., 0.5], [0.6875, 0., 1.]),
("west", [0., 0., 0.3125], [0.5, 0., 0.6875]),
("east", [0.5, 0., 0.3125], [1., 0., 0.6875]),
] {
let value = get(&p, dir, "none");
if value != "none" {
b.push(bx(
a,
[z[0], if value == "tall" { 1. } else { 0.8125 }, z[2]],
));
}
}
if b.is_empty() {
b.push(bx([0.25, 0., 0.25], [0.75, 1., 0.75]));
}
b
}
"fence_gate" => {
let mut b = vec![
bx([0., 0.3125, 0.375], [0.125, 1., 0.625]),
bx([0.875, 0.3125, 0.375], [1., 1., 0.625]),
];
if get(&p, "open", "false") == "true" {
b.push(bx([0., 0.375, 0.5], [0.1875, 0.875, 1.]));
b.push(bx([0.8125, 0.375, 0.5], [1., 0.875, 1.]));
} else {
for y in [0.375, 0.75] {
b.push(bx([0.125, y, 0.4375], [0.875, y + 0.125, 0.5625]));
}
}
rotate(b, facing)
}
"rail" | "powered_rail" | "detector_rail" => {
let s = get(&p, "shape", "north_south");
let mut b = vec![];
if s.starts_with("ascending_") {
for i in 0..8 {
let z = i as f32 / 8.;
for x in [0.1875, 0.75] {
b.push(bx([x, 0.875 - z, z], [x + 0.0625, 0.9375 - z, z + 0.125]));
}
}
rotate(b, s.trim_start_matches("ascending_"))
} else {
for x in [0.1875, 0.75] {
b.push(bx([x, 0.0625, 0.], [x + 0.0625, 0.125, 1.]));
}
for z in [0.125, 0.5, 0.875] {
b.push(bx([0.125, 0., z], [0.875, 0.0625, (z + 0.0625).min(1.)]));
}
// Curved tracks use a visible orthogonal corner in this box renderer.
if s == "east_west" {
rotate(b, "east")
} else if s.contains('_') && s != "north_south" {
let mut c = vec![
bx([0.1875, 0.06, 0.5], [1., 0.125, 0.5625]),
bx([0.1875, 0.06, 0.], [0.25, 0.125, 0.56]),
bx([0.75, 0.06, 0.], [0.8125, 0.125, 0.94]),
bx([0.75, 0.06, 0.875], [1., 0.125, 0.9375]),
];
c = rotate(
c,
match s {
"south_east" => "east",
"south_west" => "south",
"north_west" => "west",
_ => "north",
},
);
c
} else {
b
}
}
}
"ladder" => rotate(
vec![
bx([0.125, 0., 0.9375], [0.25, 1., 1.]),
bx([0.75, 0., 0.9375], [0.875, 1., 1.]),
bx([0.125, 0.1875, 0.875], [0.875, 0.25, 1.]),
bx([0.125, 0.4375, 0.875], [0.875, 0.5, 1.]),
bx([0.125, 0.6875, 0.875], [0.875, 0.75, 1.]),
bx([0.125, 0.9375, 0.875], [0.875, 1., 1.]),
],
facing,
),
"standing_sign" | "banner" => rotate(
vec![
bx([0.46875, 0., 0.46875], [0.53125, 0.5, 0.53125]),
bx([0.0625, 0.5, 0.46875], [0.9375, 0.9375, 0.53125]),
],
rotation_facing(num(&p, "rotation", 0.)),
),
"wall_sign" | "wall_banner" => rotate(
vec![bx([0.0625, 0.25, 0.9375], [0.9375, 0.8125, 1.])],
facing,
),
"ceiling_hanging_sign" | "wall_hanging_sign" => rotate(
vec![
bx([0.0625, 0.125, 0.4375], [0.9375, 0.6875, 0.5625]),
bx([0.1875, 0.6875, 0.46875], [0.25, 1., 0.53125]),
bx([0.75, 0.6875, 0.46875], [0.8125, 1., 0.53125]),
],
facing,
),
"torch" | "redstone_torch" => vec![
bx([0.4375, 0., 0.4375], [0.5625, 0.625, 0.5625]),
bx([0.375, 0.5, 0.375], [0.625, 0.75, 0.625]),
],
"wall_torch" | "redstone_wall_torch" => rotate(
vec![
bx([0.4375, 0.125, 0.6875], [0.5625, 0.5, 0.875]),
bx([0.375, 0.4375, 0.625], [0.625, 0.6875, 0.875]),
],
facing,
),
"redstone_wire" | "tripwire" => {
let mut b = vec![bx([0.3125, 0.01, 0.3125], [0.6875, 0.04, 0.6875])];
for (dir, a, z) in [
("north", [0.4375, 0.01, 0.], [0.5625, 0.04, 0.5]),
("south", [0.4375, 0.01, 0.5], [0.5625, 0.04, 1.]),
("west", [0., 0.01, 0.4375], [0.5, 0.04, 0.5625]),
("east", [0.5, 0.01, 0.4375], [1., 0.04, 0.5625]),
] {
let v = get(&p, dir, "none");
if v != "none" && v != "false" {
b.push(bx(a, z));
}
}
b
}
"nether_portal" => {
if get(&p, "axis", "x") == "x" {
vec![bx([0., 0., 0.4375], [1., 1., 0.5625])]
} else {
vec![bx([0.4375, 0., 0.], [0.5625, 1., 1.])]
}
}
"end_portal" | "end_gateway" => vec![bx([0., 0.125, 0.], [1., 0.1875, 1.])],
"fire" | "soul_fire" => vec![
bx([0.125, 0., 0.46875], [0.875, 0.875, 0.53125]),
bx([0.46875, 0., 0.125], [0.53125, 0.75, 0.875]),
],
"vine" | "glow_lichen" | "sculk_vein" | "multiface" => {
let mut b = vec![];
for (dir, a, z) in [
("north", [0., 0., 0.], [1., 1., 0.03125]),
("south", [0., 0., 0.96875], [1., 1., 1.]),
("west", [0., 0., 0.], [0.03125, 1., 1.]),
("east", [0.96875, 0., 0.], [1., 1., 1.]),
("up", [0., 0.96875, 0.], [1., 1., 1.]),
("down", [0., 0., 0.], [1., 0.03125, 1.]),
] {
if get(&p, dir, "false") == "true" {
b.push(bx(a, z));
}
}
if b.is_empty() {
b.push(bx([0., 0., 0.], [1., 1., 0.03125]));
}
b
}
"flower_pot" => {
let mut b = vec![bx([0.3125, 0., 0.3125], [0.6875, 0.375, 0.6875])];
if name != "minecraft:flower_pot" {
b.extend(plant(0.8));
}
b
}
"crop" | "beetroot" | "potato" | "carrot" | "torchflower_crop" | "pitcher_crop"
| "nether_wart" => plant(0.2 + num(&p, "age", 0.).min(7.) / 10.),
"flower"
| "wither_rose"
| "eyeblossom"
| "tall_flower"
| "double_plant"
| "sapling"
| "bush"
| "tall_grass"
| "short_dry_grass"
| "tall_dry_grass"
| "dry_vegetation"
| "sugar_cane"
| "mushroom"
| "nether_fungus"
| "nether_roots"
| "nether_sprouts"
| "hanging_roots"
| "hanging_moss"
| "firefly_bush"
| "sweet_berry_bush"
| "mangrove_propagule"
| "bamboo_sapling"
| "stem"
| "attached_stem"
| "seagrass"
| "tall_seagrass"
| "kelp"
| "kelp_plant"
| "twisting_vines"
| "twisting_vines_plant"
| "weeping_vines"
| "weeping_vines_plant"
| "cave_vines"
| "cave_vines_plant"
| "small_dripleaf"
| "spore_blossom"
| "cactus_flower" => plant(if family == "mushroom" { 0.45 } else { 0.85 }),
"leaf_litter" | "flower_bed" | "frogspawn" => {
vec![bx([0.0625, 0.015, 0.0625], [0.9375, 0.0625, 0.9375])]
}
"coral"
| "coral_plant"
| "base_coral_plant"
| "coral_fan"
| "base_coral_fan"
| "coral_wall_fan"
| "base_coral_wall_fan" => plant(0.625),
"web" => vec![
bx([0., 0., 0.46875], [1., 1., 0.53125]),
bx([0.46875, 0., 0.], [0.53125, 1., 1.]),
],
"powder_snow" => vec![FULL],
_ => {
if !collision.is_empty() {
collision.to_vec()
} else {
// Noncollidable interaction geometry authored from the family, no
// silent air substitution. Rare unsupported visual details remain
// explicitly approximate in the coverage field.
return (
vec![bx([0.125, 0., 0.125], [0.875, 0.5, 0.875])],
"authored-approximate",
);
}
}
};
(b, "authored-procedural")
}
fn rotation_facing(v: f32) -> &'static str {
match ((v + 2.) as i32 / 4).rem_euclid(4) {
0 => "south",
1 => "west",
2 => "north",
_ => "east",
}
}
fn plant(height: f32) -> Vec<Aabb> {
vec![
bx([0.46875, 0., 0.46875], [0.53125, height, 0.53125]),
bx(
[0.1875, height * 0.35, 0.46875],
[0.8125, height * 0.75, 0.53125],
),
bx(
[0.46875, height * 0.25, 0.1875],
[0.53125, height * 0.65, 0.8125],
),
]
}
pub(crate) fn color(name: &str) -> [u8; 3] {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let colors = [
("light_blue", [108, 169, 193]),
("light_gray", [170, 173, 163]),
("magenta", [175, 97, 153]),
("orange", [207, 136, 66]),
("yellow", [220, 191, 80]),
("purple", [130, 96, 164]),
("cyan", [61, 155, 156]),
("blue", [74, 111, 181]),
("brown", [119, 88, 65]),
("green", [91, 132, 75]),
("lime", [148, 185, 87]),
("pink", [216, 153, 165]),
("red", [179, 78, 64]),
("black", [54, 59, 62]),
("gray", [104, 113, 112]),
("white", [215, 219, 207]),
];
for (prefix, c) in colors {
if name.starts_with(&format!("{prefix}_")) {
return c;
}
}
if name.contains("sulfur") {
return [220, 210, 78];
}
if name.contains("cinnabar") {
return [185, 78, 63];
}
if name.contains("snow") || name.contains("quartz") || name.contains("calcite") {
return [217, 229, 228];
}
if name.contains("water") {
return [69, 133, 182];
}
if name.contains("lava") {
return [226, 123, 49];
}
if name.contains("glass") || name.contains("ice") {
return [139, 190, 203];
}
if name.contains("grass") || name.contains("leaves") || name.contains("moss") {
return [109, 148, 82];
}
if name.contains("cherry") {
return [191, 141, 142];
}
if name.contains("pale_oak") {
return [194, 192, 169];
}
if name.contains("crimson") {
return [132, 63, 82];
}
if name.contains("warped") {
return [58, 139, 135];
}
if name.contains("dark_oak") || name.contains("spruce") {
return [106, 85, 60];
}
if name.contains("birch") || name.contains("bamboo") {
return [190, 179, 121];
}
if name.contains("acacia") || name.contains("mangrove") {
return [159, 102, 71];
}
if name.contains("oak")
|| name.contains("jungle")
|| name.contains("wood")
|| name.contains("chest")
{
return [163, 134, 91];
}
if name.contains("copper") {
return if name.contains("oxidized") {
[79, 155, 131]
} else {
[179, 115, 83]
};
}
if name.contains("gold") {
return [210, 181, 81];
}
if name.contains("diamond") {
return [91, 189, 191];
}
if name.contains("emerald") {
return [67, 161, 112];
}
if name.contains("redstone") {
return [162, 70, 57];
}
if name.contains("sand") || name.contains("end_stone") {
return [195, 184, 144];
}
if name.contains("dirt") || name.contains("mud") {
return [131, 108, 83];
}
if name.contains("deepslate") || name.contains("blackstone") || name.contains("obsidian") {
return [69, 72, 80];
}
if name.contains("stone") || name.contains("andesite") || name.contains("cobble") {
return [141, 150, 146];
}
let hash = name
.bytes()
.fold(2166136261u32, |h, b| (h ^ b as u32).wrapping_mul(16777619));
[
100 + (hash % 82) as u8,
100 + ((hash >> 8) % 82) as u8,
100 + ((hash >> 16) % 82) as u8,
]
}
pub(crate) fn opacity(name: &str) -> f32 {
if name.contains("glass") {
0.42
} else if name.contains("water") || name.contains("ice") {
0.68
} else {
1.
}
}
pub(crate) fn entity_color(name: &str) -> [u8; 3] {
match name.strip_prefix("minecraft:").unwrap_or(name) {
"player" | "mannequin" => [78, 154, 169],
"cow" | "mooshroom" => [132, 108, 90],
"pig" => [209, 150, 155],
"sheep" => [219, 216, 196],
"creeper" | "zombie" | "zombie_villager" => [110, 152, 98],
"skeleton" | "stray" | "parched" => [201, 202, 179],
"enderman" | "ender_dragon" => [66, 60, 83],
"sulfur_cube" => [213, 209, 75],
"slime" => [129, 188, 103],
"magma_cube" => [149, 76, 44],
"allay" => [96, 199, 212],
"bee" => [213, 182, 72],
"fox" => [191, 123, 71],
"wolf" => [169, 177, 166],
_ => color(name),
}
}
pub(crate) fn entity_render(name: &str, width: f32, height: f32) -> (Vec<Aabb>, &'static str) {
let n = name.strip_prefix("minecraft:").unwrap_or(name);
let family = if n.ends_with("_boat") || n.ends_with("_raft") {
"boat"
} else if n.ends_with("minecart") {
"cart"
} else {
match n {
"marker" | "interaction" | "area_effect_cloud" | "lightning_bolt" => "invisible",
"cow" | "mooshroom" | "pig" | "sheep" | "wolf" | "cat" | "ocelot" | "fox" | "panda"
| "polar_bear" | "goat" | "horse" | "skeleton_horse" | "zombie_horse" | "donkey"
| "mule" | "llama" | "trader_llama" | "camel" | "camel_husk" | "hoglin" | "zoglin"
| "ravager" | "sniffer" | "armadillo" => "quadruped",
"creeper" => "creeper",
"spider" | "cave_spider" | "silverfish" | "endermite" => "arthropod",
"cod" | "salmon" | "tropical_fish" | "pufferfish" | "dolphin" | "tadpole"
| "axolotl" | "guardian" | "elder_guardian" | "nautilus" | "zombie_nautilus" => {
"aquatic"
}
"squid" | "glow_squid" => "squid",
"ghast" | "happy_ghast" => "ghast",
"slime" | "magma_cube" | "sulfur_cube" | "shulker" => "cube-creature",
"bat" | "bee" | "allay" | "vex" | "parrot" | "phantom" => "winged",
"chicken" => "bird",
"rabbit" => "rabbit",
"frog" | "turtle" => "low-quadruped",
"ender_dragon" => "dragon",
"wither" => "wither",
"snow_golem" => "snow-golem",
"blaze" | "breeze" => "elemental",
"player" | "mannequin" | "zombie" | "zombie_villager" | "drowned" | "husk"
| "skeleton" | "stray" | "parched" | "bogged" | "wither_skeleton"
| "zombified_piglin" | "piglin" | "piglin_brute" | "villager" | "wandering_trader"
| "pillager" | "vindicator" | "evoker" | "illusioner" | "witch" | "enderman"
| "iron_golem" | "copper_golem" | "creaking" | "warden" | "giant" | "armor_stand"
| "strider" => "biped",
"painting" | "item_frame" | "glow_item_frame" => "frame",
"block_display" | "item_display" | "text_display" => "property-dependent-display",
"arrow" | "spectral_arrow" | "trident" | "firework_rocket" => "bolt",
"falling_block" | "tnt" => "block",
"end_crystal" | "ominous_item_spawner" => "crystal",
"evoker_fangs" => "fangs",
"item" | "leash_knot" | "fishing_bobber" | "egg" | "ender_pearl"
| "experience_bottle" | "experience_orb" | "eye_of_ender" | "fireball"
| "small_fireball" | "dragon_fireball" | "snowball" | "splash_potion"
| "lingering_potion" | "llama_spit" | "shulker_bullet" | "wind_charge"
| "breeze_wind_charge" | "wither_skull" => "small-object",
_ => "small-object",
}
};
if family == "invisible" {
return (vec![], family);
}
let mut b = match family {
"biped" => vec![
bx([-0.3, 0.38, -0.18], [0.3, 0.77, 0.18]),
bx([-0.23, 0.77, -0.23], [0.23, 1., 0.23]),
bx([-0.27, 0., -0.16], [-0.04, 0.4, 0.16]),
bx([0.04, 0., -0.16], [0.27, 0.4, 0.16]),
bx([-0.5, 0.4, -0.14], [-0.31, 0.75, 0.14]),
bx([0.31, 0.4, -0.14], [0.5, 0.75, 0.14]),
],
"quadruped" | "low-quadruped" => {
let mut b = vec![
bx([-0.4, 0.3, -0.35], [0.4, 0.8, 0.35]),
bx([-0.3, 0.58, -0.5], [0.3, 0.98, -0.2]),
];
for x in [-0.35, 0.15] {
for z in [-0.3, 0.18] {
b.push(bx([x, 0., z], [x + 0.2, 0.4, z + 0.15]));
}
}
b
}
"creeper" => vec![
bx([-0.27, 0.22, -0.2], [0.27, 0.73, 0.2]),
bx([-0.38, 0.7, -0.38], [0.38, 1., 0.38]),
bx([-0.38, 0., -0.38], [-0.02, 0.25, -0.06]),
bx([0.02, 0., -0.38], [0.38, 0.25, -0.06]),
bx([-0.38, 0., 0.06], [-0.02, 0.25, 0.38]),
bx([0.02, 0., 0.06], [0.38, 0.25, 0.38]),
],
"arthropod" => {
let mut b = vec![
bx([-0.27, 0.3, -0.35], [0.27, 0.7, 0.35]),
bx([-0.2, 0.32, -0.5], [0.2, 0.62, -0.25]),
];
for z in [-0.32, -0.12, 0.12, 0.32] {
b.push(bx([-0.5, 0.1, z], [-0.2, 0.35, z + 0.07]));
b.push(bx([0.2, 0.1, z], [0.5, 0.35, z + 0.07]));
}
b
}
"aquatic" => vec![
bx([-0.24, 0.25, -0.45], [0.24, 0.7, 0.25]),
bx([-0.4, 0.3, 0.2], [0.4, 0.62, 0.5]),
bx([-0.03, 0.7, -0.15], [0.03, 1., 0.1]),
bx([-0.45, 0.25, -0.2], [0.45, 0.35, 0.1]),
],
"squid" | "ghast" => {
let mut b = vec![bx([-0.4, 0.4, -0.4], [0.4, 1., 0.4])];
for x in [-0.3, 0., 0.3] {
for z in [-0.3, 0., 0.3] {
b.push(bx([x - 0.04, 0., z - 0.04], [x + 0.04, 0.45, z + 0.04]));
}
}
b
}
"cube-creature" => vec![
bx([-0.5, 0., -0.5], [0.5, 1., 0.5]),
bx([-0.3, 0.55, -0.52], [-0.12, 0.72, -0.49]),
bx([0.12, 0.55, -0.52], [0.3, 0.72, -0.49]),
],
"winged" => vec![
bx([-0.19, 0.2, -0.3], [0.19, 0.8, 0.3]),
bx([-0.23, 0.65, -0.4], [0.23, 1., -0.05]),
bx([-0.5, 0.45, -0.1], [-0.15, 0.6, 0.3]),
bx([0.15, 0.45, -0.1], [0.5, 0.6, 0.3]),
],
"bird" => vec![
bx([-0.34, 0.25, -0.25], [0.34, 0.7, 0.25]),
bx([-0.23, 0.65, -0.35], [0.23, 1., -0.02]),
bx([-0.12, 0.73, -0.5], [0.12, 0.82, -0.3]),
bx([-0.22, 0., -0.03], [-0.08, 0.3, 0.06]),
bx([0.08, 0., -0.03], [0.22, 0.3, 0.06]),
],
"rabbit" => vec![
bx([-0.3, 0.12, -0.25], [0.3, 0.6, 0.3]),
bx([-0.22, 0.5, -0.4], [0.22, 0.78, -0.05]),
bx([-0.2, 0.7, -0.25], [-0.08, 1., -0.1]),
bx([0.08, 0.7, -0.25], [0.2, 1., -0.1]),
bx([-0.4, 0., 0.05], [-0.1, 0.2, 0.4]),
bx([0.1, 0., 0.05], [0.4, 0.2, 0.4]),
],
"dragon" => vec![
bx([-0.15, 0.3, -0.3], [0.15, 0.7, 0.3]),
bx([-0.1, 0.4, -0.5], [0.1, 0.65, -0.2]),
bx([-0.5, 0.48, -0.1], [-0.12, 0.56, 0.3]),
bx([0.12, 0.48, -0.1], [0.5, 0.56, 0.3]),
bx([-0.055, 0.35, 0.25], [0.055, 0.45, 0.5]),
],
"wither" => vec![
bx([-0.1, 0.05, -0.15], [0.1, 0.75, 0.15]),
bx([-0.4, 0.65, -0.12], [0.4, 0.75, 0.12]),
bx([-0.17, 0.75, -0.2], [0.17, 1., 0.2]),
bx([-0.5, 0.65, -0.18], [-0.25, 0.9, 0.18]),
bx([0.25, 0.65, -0.18], [0.5, 0.9, 0.18]),
],
"snow-golem" => vec![
bx([-0.45, 0., -0.45], [0.45, 0.4, 0.45]),
bx([-0.35, 0.4, -0.35], [0.35, 0.75, 0.35]),
bx([-0.25, 0.75, -0.25], [0.25, 1., 0.25]),
bx([-0.5, 0.5, -0.04], [0.5, 0.6, 0.04]),
],
"elemental" => {
let mut b = vec![bx([-0.3, 0.7, -0.3], [0.3, 1., 0.3])];
for (x, z) in [(-0.4, -0.3), (0.3, -0.3), (-0.4, 0.3), (0.3, 0.3)] {
b.push(bx([x, 0.15, z], [x + 0.1, 0.65, z + 0.1]));
}
b
}
"boat" => vec![
bx([-0.5, 0., -0.5], [0.5, 0.2, 0.5]),
bx([-0.5, 0.2, -0.5], [-0.36, 0.7, 0.5]),
bx([0.36, 0.2, -0.5], [0.5, 0.7, 0.5]),
bx([-0.36, 0.2, -0.5], [0.36, 0.7, -0.38]),
bx([-0.36, 0.2, 0.38], [0.36, 0.7, 0.5]),
bx([-0.36, 0.32, -0.08], [0.36, 0.42, 0.08]),
],
"cart" => vec![
bx([-0.5, 0.1, -0.5], [0.5, 0.3, 0.5]),
bx([-0.5, 0.3, -0.5], [-0.38, 0.85, 0.5]),
bx([0.38, 0.3, -0.5], [0.5, 0.85, 0.5]),
bx([-0.38, 0.3, -0.5], [0.38, 0.85, -0.38]),
bx([-0.38, 0.3, 0.38], [0.38, 0.85, 0.5]),
],
"frame" | "property-dependent-display" => vec![bx([-0.5, 0., -0.05], [0.5, 1., 0.05])],
"bolt" => vec![
bx([-0.04, 0.45, -0.5], [0.04, 0.55, 0.5]),
bx([-0.18, 0.43, -0.5], [0.18, 0.57, -0.3]),
],
"block" => vec![bx([-0.5, 0., -0.5], [0.5, 1., 0.5])],
"crystal" => vec![
bx([-0.3, 0.25, -0.3], [0.3, 0.75, 0.3]),
bx([-0.15, 0.1, -0.15], [0.15, 0.9, 0.15]),
bx([-0.45, 0., -0.45], [0.45, 0.08, 0.45]),
],
"fangs" => vec![
bx([-0.35, 0., -0.3], [-0.1, 1., 0.3]),
bx([0.1, 0., -0.3], [0.35, 1., 0.3]),
],
_ => vec![
bx([-0.3, 0.2, -0.4], [0.3, 0.8, 0.4]),
bx([-0.4, 0.3, -0.25], [0.4, 0.7, 0.25]),
],
};
if family == "boat" && n.contains("chest") {
b.push(bx([-0.25, 0.4, 0.08], [0.25, 0.95, 0.42]));
}
if family == "cart" && n != "minecart" {
b.push(bx([-0.3, 0.3, -0.3], [0.3, 1., 0.3]));
}
let w = width.max(0.25);
let h = height.max(0.25);
for v in &mut b {
for i in 0..3 {
v.min[i] *= if i == 1 { h } else { w };
v.max[i] *= if i == 1 { h } else { w };
}
}
(b, family)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn four_quarter_rotations_restore_box() {
let initial = vec![bx([0.125, 0.25, 0.375], [0.25, 0.5, 0.75])];
let mut b = initial.clone();
for _ in 0..4 {
b = rotate(b, "east");
}
assert_eq!(b, initial);
}
#[test]
fn connected_fence_has_two_added_rails() {
let (a, _) = block_render(
"minecraft:oak_fence[east=false,north=false,south=false,waterlogged=false,west=false]",
"fence",
&[],
);
let (b, _) = block_render(
"minecraft:oak_fence[east=true,north=false,south=false,waterlogged=false,west=false]",
"fence",
&[],
);
assert_eq!(b.len(), a.len() + 2);
assert!(b.iter().all(|b| b.max[1] <= 1.));
}
#[test]
fn rail_ascent_reaches_next_block() {
let (b, _) = block_render(
"minecraft:rail[shape=ascending_north,waterlogged=false]",
"rail",
&[],
);
assert_eq!(b.len(), 16);
assert!(b.iter().any(|b| b.max[1] > 0.9));
assert!(b.iter().all(Aabb::valid));
}
}
+39
View File
@@ -509,6 +509,45 @@ impl WorldStore {
Ok(result)
}
/// Page effective stored section coordinates in lexicographic x/y/z order.
/// Coordinates here identify 16³ sections, not individual blocks. Air
/// overrides are included, which matters when exporting deletions. The
/// caller holding this single writer must not mutate between pages when
/// it needs one consistent snapshot.
pub fn section_positions(
&self,
name: &str,
after: Option<Pos>,
limit: usize,
) -> Result<Vec<Pos>> {
validate_world_name(name)?;
ensure!(
(1..=4096).contains(&limit),
"input_limit: section page must be 1..4096"
);
let world = lookup_world(&self.conn, name)?;
let mut query = self.conn.prepare(
"WITH section_keys AS (
SELECT x,y,z FROM world_sections WHERE world_id=?1
UNION SELECT x,y,z FROM snapshot_sections WHERE snapshot_id=?2
) SELECT x,y,z FROM section_keys
WHERE (?3 IS NULL OR (x,y,z) > (?3,?4,?5))
ORDER BY x,y,z LIMIT ?6",
)?;
let rows = query.query_map(
params![
world.id,
world.base,
after.map(|p| p[0]),
after.map(|p| p[1]),
after.map(|p| p[2]),
limit as i64
],
|row| Ok([row.get(0)?, row.get(1)?, row.get(2)?]),
)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Durably committed operations need no flush to survive process restart.
/// This also checkpoints WAL, reporting busy/error instead of hiding it.
pub fn flush(&mut self) -> Result<()> {
@@ -0,0 +1,74 @@
use shacraft_core::{BlockChange, WorldStore};
#[test]
fn section_pages_are_sorted_bounded_and_include_inherited_air_overrides() {
let dir = tempfile::tempdir().unwrap();
let mut store = WorldStore::open(dir.path(), 2).unwrap();
let stone = store.register_block("minecraft:stone").unwrap();
store.create_world("source", None).unwrap();
store
.edit(
"source",
0,
"seed",
vec![
BlockChange {
pos: [-17, 0, 0],
block: stone,
},
BlockChange {
pos: [-1, 0, 0],
block: stone,
},
BlockChange {
pos: [0, 16, 0],
block: stone,
},
BlockChange {
pos: [32, 0, 0],
block: stone,
},
],
)
.unwrap();
store.create_world("child", Some("source")).unwrap();
store
.edit(
"child",
0,
"override",
vec![
BlockChange {
pos: [-1, 0, 0],
block: 0,
},
BlockChange {
pos: [16, 0, 0],
block: stone,
},
],
)
.unwrap();
let expected = vec![[-2, 0, 0], [-1, 0, 0], [0, 1, 0], [1, 0, 0], [2, 0, 0]];
let mut all = Vec::new();
loop {
let page = store
.section_positions("child", all.last().copied(), 2)
.unwrap();
if page.is_empty() {
break;
}
assert!(page.len() <= 2);
all.extend(page);
}
assert_eq!(all, expected);
assert_eq!(store.get_block("child", [-1, 0, 0]).unwrap(), 0);
assert!(store.section_positions("child", None, 0).is_err());
assert!(store.section_positions("child", None, 4097).is_err());
drop(store);
let store = WorldStore::open(dir.path(), 2).unwrap();
assert_eq!(
store.section_positions("child", None, 100).unwrap(),
expected
);
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "shacraft-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
+749
View File
@@ -0,0 +1,749 @@
//! Bounded newline-delimited JSON-RPC MCP transport, independent of world storage.
use anyhow::{Context, Result, bail};
use clap::Parser;
use serde_json::{Value, json};
use std::{
io::{self, BufRead, Read, Write},
path::PathBuf,
time::Duration,
};
const MAX_FRAME: usize = 8 * 1024 * 1024;
const VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
#[derive(Parser)]
#[command(about = "Shacraft MCP server over stdio (JSON-RPC; diagnostics only on stderr)")]
struct Args {
#[arg(long, default_value = "http://127.0.0.1:4000")]
server: String,
#[arg(long, default_value = "data")]
data: PathBuf,
#[arg(long)]
token_file: Option<PathBuf>,
}
struct Backend {
url: String,
token_file: PathBuf,
client: reqwest::blocking::Client,
}
impl Backend {
fn new(args: Args) -> Result<Self> {
let parsed = reqwest::Url::parse(&args.server).context("Invalid --server URL")?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
bail!("--server must be an HTTP(S) URL without credentials, query, or fragment");
}
Ok(Self {
url: args.server.trim_end_matches('/').to_owned(),
token_file: args
.token_file
.unwrap_or_else(|| args.data.join("control.token")),
client: reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(3))
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()?,
})
}
fn read_response(&self, mut response: reqwest::blocking::Response) -> Result<Value> {
let status = response.status();
let mut bytes = Vec::new();
Read::by_ref(&mut response)
.take((MAX_FRAME + 1) as u64)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_FRAME {
bail!("Server response exceeds 8 MiB; request a smaller region");
}
let body: Value = serde_json::from_slice(&bytes).context("Server returned invalid JSON")?;
if let Some(error) = body.get("error") {
bail!("{}", error.as_str().unwrap_or("Control operation failed"));
}
if !status.is_success() {
bail!("Server returned HTTP {}", status.as_u16());
}
Ok(body)
}
fn control(&self, method: &str, params: &Value) -> Result<Value> {
let file = std::fs::File::open(&self.token_file)
.context("Cannot read control token; start shacraft-server or set --token-file")?;
let mut token = String::new();
file.take(4097).read_to_string(&mut token)?;
if token.len() > 4096 {
bail!("Control token file is too large");
}
let token = token.trim();
if token.is_empty() || token.chars().any(char::is_control) {
bail!("Control token is empty or invalid");
}
let response = self
.client
.post(format!("{}/api/control", self.url))
.bearer_auth(token)
.json(&json!({"method":method,"params":params}))
.send()
.context("Shacraft server is unavailable")?;
self.read_response(response)?
.get("result")
.cloned()
.context("Control response is missing result")
}
fn public(&self, path: &str) -> Result<Value> {
self.read_response(
self.client
.get(format!("{}{}", self.url, path))
.send()
.context("Shacraft server is unavailable")?,
)
}
}
fn object(properties: Value, required: &[&str]) -> Value {
json!({"type":"object","properties":properties,"required":required,"additionalProperties":false})
}
fn pos(integer: bool) -> Value {
json!({"type":"array","items":{"type":if integer{"integer"}else{"number"}},"minItems":3,"maxItems":3})
}
fn world() -> Value {
json!({"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$"})
}
fn operation() -> Value {
json!({"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key, retained per world. Reuse only for an identical retry."})
}
fn revision() -> Value {
json!({"type":"integer","minimum":0,"description":"Observed world revision; a mismatch refuses the write without mutation."})
}
fn metadata_revision() -> Value {
json!({"type":"integer","minimum":0,"description":"Optional global metadata revision from entity_list or metrics; detects concurrent entity/rule changes."})
}
fn arena_position() -> Value {
json!({"type":"array","items":{"type":"number","minimum":-32700,"maximum":32700},"minItems":3,"maxItems":3,"description":"Feet position. Y must be greater than elimination_y + 0.5."})
}
fn block() -> Value {
json!({"oneOf":[{"type":"integer","minimum":0},{"type":"string","minLength":1,"maxLength":512}],"description":"Runtime BlockId or canonical state, e.g. minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]. Use catalog_search to discover states."})
}
fn tool(name: &str, description: &str, input: Value, read_only: bool, idempotent: bool) -> Value {
json!({"name":name,"description":description,"inputSchema":input,"annotations":{"readOnlyHint":read_only,"destructiveHint":!read_only,"idempotentHint":idempotent,"openWorldHint":false}})
}
fn tools() -> Vec<Value> {
vec![
tool(
"world_list",
"List all persistent worlds, their revisions and pinned source templates. Start here before reading or editing a world.",
object(json!({}), &[]),
true,
true,
),
tool(
"world_create",
"Create an empty independent world or a copy-on-write snapshot of an existing template. Later source edits never change the copy.",
object(json!({"world":world(),"template":world()}), &["world"]),
false,
false,
),
tool(
"world_read",
"Read a bounded inclusive region. Returns non-air cells plus world revision and registry information. Maximum region volume 262144 cells.",
object(
json!({"world":world(),"min":pos(true),"max":pos(true)}),
&["world", "min", "max"],
),
true,
true,
),
tool(
"world_edit",
"Atomically edit at most 32768 unique cells. Requires current revision and idempotency key. Use air (BlockId 0) to remove a block. The server commits durably before replying.",
object(
json!({"world":world(),"expected_revision":revision(),"operation_id":operation(),"changes":{"type":"array","minItems":1,"maxItems":32768,"items":object(json!({"pos":pos(true),"block":{"type":"integer","minimum":0,"description":"Runtime BlockId from catalog_search"}}),&["pos","block"])}}),
&["world", "expected_revision", "operation_id", "changes"],
),
false,
true,
),
tool(
"world_undo",
"Undo the latest edit atomically. Refuses when any later revision or reset could be overwritten. target_operation is the original edit's operation_id.",
object(
json!({"world":world(),"expected_revision":revision(),"operation_id":operation(),"target_operation":operation()}),
&[
"world",
"expected_revision",
"operation_id",
"target_operation",
],
),
false,
true,
),
tool(
"world_reset",
"Reset a world to its pinned base snapshot, or air if created without a template. All instance edits are removed; revision increases. Reset is an undo barrier.",
object(
json!({"world":world(),"expected_revision":revision(),"operation_id":operation()}),
&["world", "expected_revision", "operation_id"],
),
false,
true,
),
tool(
"catalog_search",
"Search the Minecraft Java 26.2 and installed-package state catalog. Reports exact identifiers, runtime IDs, shapes, and coverage; registered content does not imply all vanilla behaviors are simulated.",
object(
json!({"query":{"type":"string","maxLength":256},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":256}}),
&[],
),
true,
true,
),
tool(
"entity_list",
"List persisted entities of one world, including IDs, positions, and properties. Entity kinds are defined in the public entity catalog.",
object(json!({"world":world()}), &["world"]),
true,
true,
),
tool(
"entity_spawn",
"Spawn one entity from a registered kind at a feet position. Its persisted properties are distinct from implemented behavior. Discover kinds in the entity catalog resource.",
object(
json!({"world":world(),"kind":{"type":"string","maxLength":128},"position":pos(false),"properties":{"type":"object"},"yaw":{"type":"number"}}),
&["world", "kind", "position"],
),
false,
false,
),
tool(
"entity_update",
"Update a persisted entity's position, yaw, or properties. Fields omitted from the request are preserved.",
object(
json!({"id":{"type":"string"},"expected_revision":metadata_revision(),"position":pos(false),"properties":{"type":"object"},"yaw":{"type":"number"}}),
&["id"],
),
false,
true,
),
tool(
"entity_delete",
"Remove a single persisted entity by ID from its world.",
object(
json!({"id":{"type":"string"},"expected_revision":metadata_revision()}),
&["id"],
),
false,
true,
),
tool(
"arena_start",
"Start the configured Spleef match in the specified arena. Requires enough joined players and resets the arena floor according to its rules.",
object(json!({"world":world()}), &["world"]),
false,
false,
),
tool(
"arena_configure",
"Persist creative/Spleef mode, spawn positions and match rules. Configuration is refused during a started round. Nonempty spawn_points must cover min_players and all joining participants; points must be at least 0.8 apart. floor_block defines the breakable material and does not rebuild the map.",
object(
json!({"world":world(),"expected_revision":metadata_revision(),"mode":{"type":"string","enum":["creative","spleef"]},"spawn":arena_position(),"countdown_seconds":{"type":"integer","minimum":1,"maximum":30},"round_seconds":{"type":"integer","minimum":1,"maximum":3600},"min_players":{"type":"integer","minimum":2,"maximum":32},"elimination_y":{"type":"number","minimum":-32700,"maximum":32700},"spawn_points":{"type":"array","maxItems":32,"items":arena_position(),"description":"Empty uses a radius-7 circle. Otherwise positions must cover all participants."},"spectator_spawn":arena_position(),"floor_block":{"type":"string","minLength":1,"maxLength":512,"description":"Known solid canonical state that players may break during Spleef."}}),
&["world"],
),
false,
true,
),
tool(
"metrics",
"Read live process RSS, storage/cache estimates, connected players, tick and queue diagnostics. Payload estimates must not be reported as total process RAM.",
object(json!({}), &[]),
true,
true,
),
tool(
"build_plan",
"Plan bounded construction without changing world blocks. Expands box, line and cylinder operations, checks limits and returns a plan_id, summary and expected revision. Review the plan before build_commit.",
object(
json!({"world":world(),"expected_revision":revision(),"operations":{"type":"array","minItems":1,"maxItems":128,"items":{"oneOf":[object(json!({"type":{"const":"box"},"min":pos(true),"max":pos(true),"block":block(),"hollow":{"type":"boolean"}}),&["type","min","max","block"]),object(json!({"type":{"const":"line"},"from":pos(true),"to":pos(true),"block":block()}),&["type","from","to","block"]),object(json!({"type":{"const":"cylinder"},"center":pos(true),"radius":{"type":"integer","minimum":1,"maximum":64},"height":{"type":"integer","minimum":1,"maximum":128},"block":block(),"hollow":{"type":"boolean"}}),&["type","center","radius","height","block"])]}}}),
&["world", "expected_revision", "operations"],
),
false,
false,
),
tool(
"build_commit",
"Commit a previously reviewed build_plan atomically using plan_id and an idempotency key. A stale revision is refused; create a new plan after inspecting intervening changes.",
object(
json!({"plan_id":{"type":"string","minLength":1,"maxLength":128},"operation_id":operation()}),
&["plan_id", "operation_id"],
),
false,
true,
),
tool(
"camera_capture",
"Capture a server-rendered isometric PNG of a bounded world region. Returns an MCP image with world/revision metadata; this is an inspection projection, not the browser's first-person camera.",
object(
json!({"world":world(),"min":pos(true),"max":pos(true)}),
&["world"],
),
true,
true,
),
]
}
fn validate(schema: &Value, value: &Value, path: &str) -> std::result::Result<(), String> {
if let Some(variants) = schema.get("oneOf").and_then(Value::as_array)
&& variants
.iter()
.filter(|s| validate(s, value, path).is_ok())
.count()
!= 1
{
return Err(format!("{path}: does not match exactly one accepted form"));
}
if let Some(choices) = schema.get("enum").and_then(Value::as_array)
&& !choices.contains(value)
{
return Err(format!("{path}: value is not in allowed enum"));
}
if let Some(constant) = schema.get("const")
&& value != constant
{
return Err(format!("{path}: expected {constant}"));
}
if let Some(t) = schema.get("type").and_then(Value::as_str) {
let matches = match t {
"object" => value.is_object(),
"array" => value.is_array(),
"string" => value.is_string(),
"integer" => value.is_i64() || value.is_u64(),
"number" => value.is_number(),
"boolean" => value.is_boolean(),
_ => true,
};
if !matches {
return Err(format!("{path}: expected {t}"));
}
}
if let Some(obj) = value.as_object() {
if let Some(required) = schema.get("required").and_then(Value::as_array) {
for key in required {
let key = key.as_str().unwrap_or("");
if !obj.contains_key(key) {
return Err(format!("{path}.{key}: required"));
}
}
}
if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
for (key, value) in obj {
if let Some(subschema) = properties.get(key) {
validate(subschema, value, &format!("{path}.{key}"))?;
} else if schema.get("additionalProperties") == Some(&json!(false)) {
return Err(format!("{path}.{key}: unknown field"));
}
}
}
}
if let Some(values) = value.as_array() {
if let Some(n) = schema.get("minItems").and_then(Value::as_u64)
&& values.len() < (n as usize)
{
return Err(format!("{path}: too few items"));
}
if let Some(n) = schema.get("maxItems").and_then(Value::as_u64)
&& values.len() > (n as usize)
{
return Err(format!("{path}: too many items"));
}
if let Some(item) = schema.get("items") {
for (i, value) in values.iter().enumerate() {
validate(item, value, &format!("{path}[{i}]"))?;
}
}
}
if let Some(s) = value.as_str() {
let len = s.chars().count();
if schema
.get("minLength")
.and_then(Value::as_u64)
.is_some_and(|n| len < n as usize)
|| schema
.get("maxLength")
.and_then(Value::as_u64)
.is_some_and(|n| len > n as usize)
{
return Err(format!("{path}: string length out of range"));
}
if schema.get("pattern") == Some(&json!("^[A-Za-z0-9_-]{1,64}$"))
&& (s.is_empty()
|| s.len() > 64
|| !s
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-'))
{
return Err(format!("{path}: invalid world name"));
}
}
if let Some(n) = value.as_f64()
&& (schema
.get("minimum")
.and_then(Value::as_f64)
.is_some_and(|min| n < min)
|| schema
.get("maximum")
.and_then(Value::as_f64)
.is_some_and(|max| n > max))
{
return Err(format!("{path}: number out of range"));
}
Ok(())
}
fn resources() -> Value {
json!({"resources":[
{"uri":"shacraft://server/status","name":"Server status","description":"Live server health and storage metrics","mimeType":"application/json"},
{"uri":"shacraft://catalog/blocks","name":"Block catalog","description":"Initial page and search endpoint for registered block states","mimeType":"application/json"},
{"uri":"shacraft://catalog/entities","name":"Entity catalog","description":"All registered entity kinds, shapes and coverage","mimeType":"application/json"},
{"uri":"shacraft://packages","name":"Required packages","description":"Client-visible manifest and resource integrity hashes; contains no admin credentials","mimeType":"application/json"}
]})
}
fn rpc_error(id: Value, code: i32, message: impl Into<String>) -> Value {
json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message.into()}})
}
fn rpc_result(id: Value, result: Value) -> Value {
json!({"jsonrpc":"2.0","id":id,"result":result})
}
fn tool_error(message: impl Into<String>) -> Value {
json!({"isError":true,"content":[{"type":"text","text":message.into()}]})
}
#[derive(Default)]
struct Session {
initialized: bool,
ready: bool,
}
impl Session {
fn dispatch(&mut self, request: Value, backend: &Backend) -> Option<Value> {
let id = request.get("id").cloned().unwrap_or(Value::Null);
if !request.is_object()
|| request.get("jsonrpc") != Some(&json!("2.0"))
|| request.get("method").and_then(Value::as_str).is_none()
|| request
.get("id")
.is_some_and(|id| !id.is_string() && !id.is_i64() && !id.is_u64())
{
return Some(rpc_error(id, -32600, "Invalid JSON-RPC request"));
}
let method = request["method"].as_str().unwrap();
if request.get("id").is_none() {
if method == "notifications/initialized" && self.initialized {
self.ready = true;
}
return None;
}
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
if !params.is_object() {
return Some(rpc_error(id, -32602, "params must be an object"));
}
if method == "initialize" {
if self.initialized {
return Some(rpc_error(id, -32600, "Session already initialized"));
}
let Some(version) = params.get("protocolVersion").and_then(Value::as_str) else {
return Some(rpc_error(id, -32602, "protocolVersion is required"));
};
if !params.get("capabilities").is_some_and(Value::is_object)
|| !params
.get("clientInfo")
.is_some_and(|v| v["name"].is_string() && v["version"].is_string())
{
return Some(rpc_error(
id,
-32602,
"capabilities and clientInfo.name/version are required",
));
}
self.initialized = true;
return Some(rpc_result(
id,
json!({"protocolVersion":if VERSIONS.contains(&version){version}else{VERSIONS[0]},"capabilities":{"tools":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"prompts":{"listChanged":false}},"serverInfo":{"name":"shacraft-mcp","version":env!("CARGO_PKG_VERSION")},"instructions":"Inspect world revisions and catalog before editing. Plan bounded construction with build_plan; commit with a unique operation_id. Preserve expected_revision on retries. camera_capture is an isometric inspection image. This MCP controls only the configured Shacraft server."}),
));
}
if method == "ping" {
return Some(rpc_result(id, json!({})));
}
if !self.ready {
return Some(rpc_error(
id,
-32002,
"Initialize and send notifications/initialized first",
));
}
let result = match method {
"tools/list" => {
if params.get("cursor").is_some() {
return Some(rpc_error(id, -32602, "This tool list has no further pages"));
}
json!({"tools":tools()})
}
"tools/call" => {
let Some(name) = params.get("name").and_then(Value::as_str) else {
return Some(rpc_error(id, -32602, "Tool name is required"));
};
let list = tools();
let Some(tool) = list.iter().find(|t| t["name"] == name) else {
return Some(rpc_error(id, -32602, "Unknown tool"));
};
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
if let Err(error) = validate(&tool["inputSchema"], &args, "arguments") {
tool_error(error)
} else {
let control = name.replacen('_', ".", 1);
match backend.control(&control, &args) {
Ok(mut output) => {
let mut content = Vec::new();
if name == "camera_capture"
&& let Some(png) = output.get("png_base64").and_then(Value::as_str)
{
content.push(
json!({"type":"image","data":png,"mimeType":"image/png"}),
);
if let Some(obj) = output.as_object_mut() {
obj.remove("png_base64");
}
}
content.push(json!({"type":"text","text":serde_json::to_string(&output).unwrap_or_default()}));
let structured = if output.is_object() {
output
} else {
json!({"result":output})
};
json!({"content":content,"structuredContent":structured,"isError":false})
}
Err(error) => tool_error(format!("{error:#}")),
}
}
}
"resources/list" => resources(),
"resources/templates/list" => json!({"resourceTemplates":[]}),
"resources/read" => {
let Some(uri) = params.get("uri").and_then(Value::as_str) else {
return Some(rpc_error(id, -32602, "Resource URI is required"));
};
let path = match uri {
"shacraft://server/status" => "/api/metrics",
"shacraft://catalog/blocks" => "/api/catalog?offset=0&limit=64",
"shacraft://catalog/entities" => "/api/entities",
"shacraft://packages" => "/api/manifest",
_ => return Some(rpc_error(id, -32002, "Unknown resource URI")),
};
match backend.public(path) {
Ok(data) => {
json!({"contents":[{"uri":uri,"mimeType":"application/json","text":serde_json::to_string(&data).unwrap_or_default()}]})
}
Err(error) => return Some(rpc_error(id, -32000, format!("{error:#}"))),
}
}
"prompts/list" => {
json!({"prompts":[{"name":"construct","description":"Inspect, plan, commit and visually verify a bounded Shacraft build","arguments":[{"name":"world","description":"Target world name","required":true},{"name":"request","description":"What to build and any constraints","required":true}]}]})
}
"prompts/get" => {
if params["name"] != "construct" {
return Some(rpc_error(id, -32602, "Unknown prompt"));
}
let a = &params["arguments"];
let (Some(world), Some(request)) = (a["world"].as_str(), a["request"].as_str())
else {
return Some(rpc_error(
id,
-32602,
"world and request string arguments are required",
));
};
json!({"description":"Revision-safe construction workflow","messages":[{"role":"user","content":{"type":"text","text":format!("Build in world {world}: {request}\nUse world_list and world_read to inspect the site and current revision, then catalog_search for exact block states. Use build_plan for bounded box/line/cylinder operations. Review the resulting bounds and change count against the requested construction; then call build_commit with a unique operation_id if the request authorizes the build. Capture camera_capture afterward and inspect the result. If the revision conflicts, read the world and make a new plan rather than forcing a stale write. Undo uses the original operation_id and is safe only before later edits. Explain content coverage and any limitations relevant to the build.")}}]})
}
_ => return Some(rpc_error(id, -32601, "Method not found")),
};
Some(rpc_result(id, result))
}
}
/// Discards an oversized frame through its newline without accumulating its tail.
fn read_frame(reader: &mut impl BufRead) -> io::Result<Option<std::result::Result<Vec<u8>, ()>>> {
let mut frame = Vec::new();
let mut oversized = false;
loop {
let buffer = reader.fill_buf()?;
if buffer.is_empty() {
return Ok(if frame.is_empty() && !oversized {
None
} else {
Some(if oversized { Err(()) } else { Ok(frame) })
});
}
let end = buffer.iter().position(|&b| b == b'\n').map(|i| i + 1);
let take = end.unwrap_or(buffer.len());
if !oversized {
if frame.len() + take > MAX_FRAME {
oversized = true;
frame.clear();
} else {
frame.extend_from_slice(&buffer[..take]);
}
}
reader.consume(take);
if end.is_some() {
return Ok(Some(if oversized { Err(()) } else { Ok(frame) }));
}
}
}
fn main() -> Result<()> {
let backend = Backend::new(Args::parse())?;
let mut session = Session::default();
let stdin = io::stdin();
let mut input = stdin.lock();
let stdout = io::stdout();
let mut output = stdout.lock();
while let Some(frame) = read_frame(&mut input)? {
let reply = match frame {
Err(()) => Some(rpc_error(Value::Null, -32600, "Request exceeds 8 MiB")),
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(request) => session.dispatch(request, &backend),
Err(_) => Some(rpc_error(Value::Null, -32700, "Parse error")),
},
};
if let Some(reply) = reply {
serde_json::to_writer(&mut output, &reply)?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn backend() -> Backend {
Backend::new(Args {
server: "http://127.0.0.1:1".into(),
data: PathBuf::from("/nonexistent-shacraft-test-data"),
token_file: None,
})
.unwrap()
}
fn request(id: i32, method: &str, params: Value) -> Value {
json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})
}
fn initialize(s: &mut Session, b: &Backend, version: &str) -> Value {
let result=s.dispatch(request(1,"initialize",json!({"protocolVersion":version,"capabilities":{},"clientInfo":{"name":"test","version":"1"}})),b).unwrap();
assert!(
s.dispatch(
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
b
)
.is_none()
);
result
}
#[test]
fn lifecycle_negotiates_and_rejects_before_initialization() {
let b = backend();
let mut s = Session::default();
assert_eq!(
s.dispatch(request(1, "tools/list", json!({})), &b).unwrap()["error"]["code"],
-32002
);
assert_eq!(
initialize(&mut s, &b, "unknown")["result"]["protocolVersion"],
VERSIONS[0]
);
let list = s.dispatch(request(2, "tools/list", json!({})), &b).unwrap();
assert_eq!(list["result"]["tools"].as_array().unwrap().len(), 17);
assert_eq!(
s.dispatch(request(3, "initialize", json!({})), &b).unwrap()["error"]["code"],
-32600
);
}
#[test]
fn supported_version_is_preserved() {
let b = backend();
let mut s = Session::default();
assert_eq!(
initialize(&mut s, &b, "2024-11-05")["result"]["protocolVersion"],
"2024-11-05"
);
}
#[test]
fn notifications_never_generate_responses() {
let b = backend();
let mut s = Session::default();
assert!(
s.dispatch(json!({"jsonrpc":"2.0","method":"unknown/notification"}), &b)
.is_none()
);
assert!(s.dispatch(json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":3}}),&b).is_none());
}
#[test]
fn invalid_tool_arguments_are_mcp_content_errors() {
let b = backend();
let mut s = Session::default();
initialize(&mut s, &b, VERSIONS[0]);
let reply=s.dispatch(request(2,"tools/call",json!({"name":"world_edit","arguments":{"world":"lobby","expected_revision":0,"operation_id":"x","changes":[{"pos":[0,0],"block":1}]}})),&b).unwrap();
assert_eq!(reply["result"]["isError"], true);
assert!(
reply["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("too few items")
);
}
#[test]
fn unavailable_token_is_a_tool_error_not_transport_error() {
let b = backend();
let mut s = Session::default();
initialize(&mut s, &b, VERSIONS[0]);
let reply = s
.dispatch(request(2, "tools/call", json!({"name":"world_list"})), &b)
.unwrap();
assert_eq!(reply["result"]["isError"], true);
assert!(reply.get("error").is_none());
}
#[test]
fn malformed_ids_and_requests_do_not_execute() {
let b = backend();
let mut s = Session::default();
for req in [
json!([]),
json!({"jsonrpc":"2.0","id":null,"method":"ping"}),
json!({"id":1,"method":"ping"}),
] {
assert_eq!(s.dispatch(req, &b).unwrap()["error"]["code"], -32600);
}
}
#[test]
fn bounded_reader_recovers_after_oversized_line() {
let mut input = vec![b'x'; MAX_FRAME + 100];
input.extend_from_slice(b"\n{\"ok\":true}\n");
let mut input = io::BufReader::new(io::Cursor::new(input));
assert!(read_frame(&mut input).unwrap().unwrap().is_err());
assert_eq!(
read_frame(&mut input).unwrap().unwrap().unwrap(),
b"{\"ok\":true}\n"
);
assert!(read_frame(&mut input).unwrap().is_none());
}
#[test]
fn build_shapes_validate_without_allowing_invented_parameters() {
let list = tools();
let schema = &list.iter().find(|t| t["name"] == "build_plan").unwrap()["inputSchema"];
assert!(validate(schema,&json!({"world":"lobby","expected_revision":0,"operations":[{"type":"box","min":[0,0,0],"max":[3,3,3],"block":"minecraft:stone"}]}),"args").is_ok());
assert!(validate(schema,&json!({"world":"lobby","expected_revision":0,"operations":[{"type":"box","min":[0,0,0],"max":[3,3,3],"block":1,"erase_everything":true}]}),"args").is_err());
}
}
+214
View File
@@ -0,0 +1,214 @@
// Real MCP SDK end-to-end smoke. Requires a running disposable Shacraft server.
// node sdk-smoke.mjs --binary /abs/shacraft-mcp --data /abs/test-data [--server URL]
// SHACRAFT_MCP_SDK may point at an installed @modelcontextprotocol/sdk directory.
import assert from "node:assert/strict";
import path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
const args = {};
for (let i = 2; i < process.argv.length; i += 2)
args[process.argv[i].replace(/^--/, "")] = process.argv[i + 1];
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../..",
);
const sdk =
process.env.SHACRAFT_MCP_SDK ||
args.sdk ||
path.resolve(
root,
"../minecraft-builder-mcp/bridge/node_modules/@modelcontextprotocol/sdk",
);
if (!existsSync(sdk))
throw Error(
"Set SHACRAFT_MCP_SDK to an installed @modelcontextprotocol/sdk directory.",
);
const { Client } = await import(
pathToFileURL(path.join(sdk, "dist/esm/client/index.js"))
);
const { StdioClientTransport } = await import(
pathToFileURL(path.join(sdk, "dist/esm/client/stdio.js"))
);
const binary = args.binary || path.join(root, "target/debug/shacraft-mcp");
assert.ok(args.data, "--data is required: use disposable server data");
const client = new Client(
{ name: "shacraft-sdk-smoke", version: "1.0.0" },
{ capabilities: {} },
);
const transport = new StdioClientTransport({
command: binary,
args: [
"--server",
args.server || "http://127.0.0.1:4000",
"--data",
path.resolve(args.data),
],
stderr: "pipe",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => (stderr += chunk));
async function call(name, arguments_ = {}) {
const r = await client.callTool({ name, arguments: arguments_ });
assert.notEqual(r.isError, true, `${name}: ${JSON.stringify(r.content)}`);
return (
r.structuredContent ||
JSON.parse(r.content.find((c) => c.type === "text").text)
);
}
const report = { sdk: true, checks: [], world: `mcp_sdk_${Date.now()}` };
try {
await client.connect(transport);
assert.equal(client.getServerVersion()?.name, "shacraft-mcp");
await client.ping();
report.checks.push(
"SDK initialize, version negotiation, notification and ping",
);
const listed = await client.listTools();
assert.equal(listed.tools.length, 17);
report.checks.push("17 discoverable tool schemas accepted by SDK");
const resources = await client.listResources();
assert.equal(resources.resources.length, 4);
for (const resource of resources.resources) {
const r = await client.readResource({ uri: resource.uri });
assert.equal(r.contents[0].mimeType, "application/json");
JSON.parse(r.contents[0].text);
}
report.checks.push("4 resources read through SDK");
const prompts = await client.listPrompts();
assert.equal(prompts.prompts[0].name, "construct");
const prompt = await client.getPrompt({
name: "construct",
arguments: {
world: report.world,
request: "Create a small test structure",
},
});
assert.ok(prompt.messages.length);
report.checks.push("Construction prompt and arguments");
const found = await call("catalog_search", {
query: "minecraft:stone",
limit: 32,
});
const stone = found.items.find((x) => x.state === "minecraft:stone");
assert.ok(stone);
await call("world_create", { world: report.world });
const edit = {
world: report.world,
expected_revision: 0,
operation_id: "sdk-edit",
changes: [{ pos: [0, 0, 0], block: stone.id }],
};
const first = await call("world_edit", edit);
assert.equal(first.revision, 1);
assert.equal((await call("world_edit", edit)).replayed, true);
const read = await call("world_read", {
world: report.world,
min: [0, 0, 0],
max: [1, 1, 1],
});
assert.equal(read.blocks.length, 1);
const conflict = await client.callTool({
name: "world_edit",
arguments: { ...edit, operation_id: "sdk-conflict" },
});
assert.equal(conflict.isError, true);
await call("world_undo", {
world: report.world,
expected_revision: 1,
operation_id: "sdk-undo",
target_operation: "sdk-edit",
});
report.checks.push(
"Durable write, read, idempotent retry, conflict content and undo",
);
const plan = await call("build_plan", {
world: report.world,
expected_revision: 2,
operations: [
{ type: "box", min: [0, 0, 0], max: [2, 1, 2], block: stone.id },
],
});
assert.equal(plan.cells, 18);
const committed = await call("build_commit", {
plan_id: plan.plan_id,
operation_id: "sdk-build",
});
assert.equal(committed.changed, 18);
assert.equal(
(
await call("build_commit", {
plan_id: plan.plan_id,
operation_id: "sdk-build",
})
).replayed,
true,
);
report.checks.push("Plan and atomic idempotent commit");
const image = await client.callTool({
name: "camera_capture",
arguments: { world: report.world, min: [-2, -2, -2], max: [4, 4, 4] },
});
assert.notEqual(image.isError, true);
const png = image.content.find((c) => c.type === "image");
assert.ok(png);
assert.equal(png.mimeType, "image/png");
assert.deepEqual(
[...Buffer.from(png.data, "base64").subarray(0, 8)],
[137, 80, 78, 71, 13, 10, 26, 10],
);
report.checks.push("MCP PNG image content");
const entity = await call("entity_spawn", {
world: report.world,
kind: "minecraft:pig",
position: [1, 2, 1],
properties: { name: "SDK test" },
});
const id = entity.entity.id;
assert.ok(id);
const updated = await call("entity_update", {
id,
position: [2, 2, 1],
properties: { name: "SDK updated" },
});
assert.deepEqual(updated.entity.position, [2, 2, 1]);
const entities = await call("entity_list", { world: report.world });
assert.ok(entities.items.some((e) => e.id === id));
await call("entity_delete", { id });
assert.equal(
(await call("entity_list", { world: report.world })).items.length,
0,
);
report.checks.push("Entity spawn/update/list/delete");
const rules = await call("arena_configure", {
world: report.world,
mode: "creative",
spawn: [0, 3, 0],
countdown_seconds: 2,
round_seconds: 120,
min_players: 2,
elimination_y: -5,
spawn_points: [],
spectator_spawn: [13, 3, 0],
floor_block: "minecraft:snow_block",
});
assert.equal(rules.countdown_seconds ?? rules.rules?.countdown_seconds, 2);
const metrics = await call("metrics");
assert.ok(metrics.storage);
report.checks.push("Persisted world configuration and measured metrics");
await call("world_reset", {
world: report.world,
expected_revision: committed.revision,
operation_id: "sdk-reset",
});
const empty = await call("world_read", {
world: report.world,
min: [0, 0, 0],
max: [2, 1, 2],
});
assert.equal(empty.blocks.length, 0);
report.checks.push("World reset");
assert.equal(stderr, "", "MCP server should not emit routine diagnostics");
console.log(JSON.stringify(report, null, 2));
} finally {
await client.close();
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "shacraft-server"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
shacraft-core.workspace = true
shacraft-content = { path = "../shacraft-content" }
axum = { version = "0.8.9", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["fs", "limit", "trace"] }
tower = { version = "0.5", features = ["limit", "load-shed"] }
futures-util = "0.3"
sha2 = "0.10"
base64 = "0.22"
png = "0.17"
wasmi = "2"
uuid = { version = "1", features = ["v4", "serde"] }
rusqlite = { version = "=0.40.2", features = ["bundled"] }
[dev-dependencies]
tempfile = "3"
+499
View File
@@ -0,0 +1,499 @@
impl Game {
pub fn query(&mut self, method: &str, p: Value) -> Result<Value> {
match method {
"health" => Ok(json!({"status":"ok","protocol":1,"minecraft":"26.2","tick":self.tick})),
"manifest" => Ok(self.manifest.clone()),
"world.list" => Ok(json!(
self.store
.list_worlds()?
.into_iter()
.filter(|w| self.config(&w.name).mode != "template")
.map(|w| {
let mut v = json!(w);
v["mode"] = json!(self.config(&w.name).mode);
v
})
.collect::<Vec<_>>()
)),
"catalog.search" => {
let query = p["query"].as_str().unwrap_or("").to_lowercase();
ensure!(query.len() <= 256, "query too long");
let offset = p["offset"].as_u64().unwrap_or(0) as usize;
let limit = p["limit"].as_u64().unwrap_or(64).clamp(1, 256) as usize;
let selected = p["ids"]
.as_str()
.map(|s| {
s.split(',')
.take(128)
.filter_map(|s| s.parse::<u32>().ok())
.collect::<HashSet<_>>()
})
.or_else(|| {
p["states"].as_str().map(|s| {
split_states(s)
.into_iter()
.take(128)
.filter_map(|s| self.id(s).ok())
.collect::<HashSet<_>>()
})
});
let ids: Vec<_> = self
.store
.registry()
.iter()
.enumerate()
.filter(|(id, s)| {
if let Some(ref selected) = selected {
selected.contains(&(*id as u32))
} else {
s.to_lowercase().contains(&query)
}
})
.map(|(i, _)| i as u32)
.collect();
Ok(
json!({"items":ids.iter().skip(offset).take(limit).filter_map(|id|self.material(*id)).collect::<Vec<_>>(),"total":ids.len(),"summary":self.catalog.summary()}),
)
}
"entity.catalog" => Ok(json!({"items":self.catalog.entities})),
"metrics" => {
let rss = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("VmRSS:"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse::<u64>().ok())
.map(|n| n * 1024)
});
Ok(
json!({"tick":self.tick,"tick_ms":self.last_tick_ms,"max_tick_ms":self.max_tick_ms,"uptime_seconds":self.started.elapsed().as_secs(),"players":self.players.len(),"active_worlds":self.players.values().map(|p|&p.world).collect::<HashSet<_>>().len(),"world_count":self.store.list_worlds()?.len(),"plans":self.plans.len(),"entities":self.meta.entities.len(),"entity_revision":self.meta.revision,"rss_bytes":rss,"slow_clients_disconnected":self.slow_clients,"queued_client_bytes":self.players.values().map(|p|p.out.bytes.load(Ordering::Relaxed)).sum::<usize>(),"limits":{"players":32,"command_queue":64,"messages_per_client":16,"bytes_per_client":8388608,"build_plans":16,"plan_cells":32768},"storage":self.store.stats(),"catalog":self.catalog.summary(),"wasm":self.jump.metrics()}),
)
}
"world.create" => {
let world = string(&p, "world")?;
self.store.create_world(world, p["template"].as_str())?;
let conf = if let Some(template) = p["template"].as_str() {
let mut c = self.config(template);
if c.mode == "template" {
c.mode = "spleef".into();
}
c
} else {
WorldConfig::default()
};
self.meta.worlds.insert(world.into(), conf);
self.save_meta()?;
Ok(json!({"world":world,"revision":0}))
}
"world.read" => {
let world = string(&p, "world")?;
let min: Pos = serde_json::from_value(p["min"].clone())?;
let max: Pos = serde_json::from_value(p["max"].clone())?;
let blocks = self.store.read_region(world, min, max)?;
let response = json!({"world":world,"revision":self.store.revision(world)?,"blocks":blocks,"materials":self.materials(&blocks)});
ensure!(
response.to_string().len() <= 6 * 1024 * 1024,
"read response exceeds 6 MiB; request a smaller region"
);
Ok(response)
}
"world.edit" => {
let world = string(&p, "world")?;
let rev = p["expected_revision"]
.as_u64()
.context("expected_revision required")?;
let op = string(&p, "operation_id")?;
let changes: Vec<BlockChange> = serde_json::from_value(p["changes"].clone())?;
let result = self.store.edit(world, rev, op, changes.clone())?;
if !result.replayed {
self.broadcast(world,json!({"type":"blocks","revision":result.revision,"changes":changes,"materials":self.materials(&changes)}));
}
Ok(json!(result))
}
"world.undo" | "world.reset" => {
let world = string(&p, "world")?;
let rev = p["expected_revision"]
.as_u64()
.context("expected_revision required")?;
let op = string(&p, "operation_id")?;
let result = if method == "world.undo" {
self.store
.undo(world, rev, op, string(&p, "target_operation")?)?
} else {
self.store.reset_world(world, rev, op)?
};
if !result.replayed {
self.resnapshot(world);
}
Ok(json!(result))
}
"entity.list" => {
let world = string(&p, "world")?;
self.store.revision(world)?;
Ok(
json!({"items":self.meta.entities.values().filter(|e|e["world"]==world).skip(p["offset"].as_u64().unwrap_or(0)as usize).take(p["limit"].as_u64().unwrap_or(64).clamp(1,128)as usize).cloned().collect::<Vec<_>>(),"total":self.meta.entities.values().filter(|e|e["world"]==world).count(),"revision":self.meta.revision}),
)
}
"entity.spawn" => {
ensure!(self.meta.entities.len() < 4096, "entity limit reached");
let world = string(&p, "world")?;
self.store.revision(world)?;
let kind = p["kind"]
.as_str()
.or_else(|| p["entity_type"].as_str())
.context("kind required")?;
ensure!(self.catalog.entity(kind).is_some(), "unknown entity type");
let pos = entity_position(&p["position"])?;
let id = Uuid::new_v4().to_string();
let properties = p.get("properties").cloned().unwrap_or(json!({}));
ensure!(
properties.to_string().len() <= 16384,
"entity properties too large"
);
let entity = json!({"id":id,"world":world,"kind":kind,"type":kind,"position":pos,"yaw":p["yaw"].as_f64().unwrap_or(0.),"properties":properties});
self.meta.entities.insert(id, entity.clone());
self.save_meta()?;
self.broadcast(
world,
json!({"type":"entities","entities":self.entity_list(world)}),
);
Ok(json!({"entity":entity,"revision":self.meta.revision}))
}
"entity.update" | "entity.delete" => {
let id = string(&p, "id")?;
let mut entity = self
.meta
.entities
.get(id)
.cloned()
.context("unknown entity")?;
let world = entity["world"].as_str().unwrap().to_owned();
if let Some(r) = p["expected_revision"].as_u64() {
ensure!(r == self.meta.revision, "entity revision conflict");
}
if method == "entity.delete" {
self.meta.entities.remove(id);
} else {
if p.get("position").is_some() {
entity["position"] = json!(entity_position(&p["position"])?);
}
if let Some(prop) = p.get("properties") {
ensure!(
prop.to_string().len() <= 16384,
"entity properties too large"
);
entity["properties"] = prop.clone();
}
if p.get("yaw").is_some() {
entity["yaw"] = json!(number(&p, "yaw")?);
}
self.meta.entities.insert(id.into(), entity.clone());
}
self.save_meta()?;
self.broadcast(
&world,
json!({"type":"entities","entities":self.entity_list(&world)}),
);
Ok(json!({"entity":entity,"revision":self.meta.revision}))
}
"arena.configure" => {
let world = string(&p, "world")?;
self.store.revision(world)?;
ensure!(
!self.matches.contains_key(world),
"cannot change rules during a match"
);
if let Some(revision) = p["expected_revision"].as_u64() {
ensure!(
revision == self.meta.revision,
"arena configuration revision conflict"
);
}
let mut conf = self.config(world);
if let Some(mode) = p.get("mode") {
let mode = mode.as_str().context("mode must be a string")?;
ensure!(
["creative", "spleef"].contains(&mode),
"unsupported world mode"
);
conf.mode = mode.into();
}
if let Some(value) = p.get("spawn") {
conf.spawn = entity_position(value)?;
}
if let Some(value) = p.get("spectator_spawn") {
conf.spectator_spawn = entity_position(value)?;
}
if let Some(value) = p.get("spawn_points") {
conf.spawn_points = serde_json::from_value(value.clone())
.context("spawn_points must be arrays of three coordinates")?;
}
if let Some(value) = p.get("countdown_seconds") {
conf.countdown_seconds = u32::try_from(
value
.as_u64()
.context("countdown_seconds must be an integer")?,
)
.context("countdown_seconds too large")?;
}
if let Some(value) = p.get("round_seconds") {
conf.round_seconds =
u32::try_from(value.as_u64().context("round_seconds must be an integer")?)
.context("round_seconds too large")?;
}
if let Some(value) = p.get("min_players") {
conf.min_players =
usize::try_from(value.as_u64().context("min_players must be an integer")?)
.context("min_players too large")?;
}
if p.get("elimination_y").is_some() {
conf.elimination_y = number(&p, "elimination_y")?;
}
if let Some(value) = p.get("floor_block") {
let name = value
.as_str()
.context("floor_block must be a canonical state")?;
conf.floor_block = self
.catalog
.state(name)
.context("unknown floor_block")?
.state
.clone();
}
conf.validate(&self.catalog)?;
self.meta.worlds.insert(world.into(), conf.clone());
self.save_meta()?;
Ok(json!(conf))
}
"arena.start" => self.start_match(string(&p, "world")?),
"build.plan" => self.build_plan(p),
"build.commit" => {
let id = string(&p, "plan_id")?;
let plan = self.plans.get(id).context("plan missing or expired")?;
ensure!(plan.expires > Instant::now(), "plan expired");
let world = plan.world.clone();
let changes = plan.changes.clone();
let result = self.store.edit(
&world,
plan.revision,
string(&p, "operation_id")?,
changes.clone(),
)?;
if !result.replayed {
self.broadcast(&world,json!({"type":"blocks","revision":result.revision,"changes":changes,"materials":self.materials(&changes)}));
}
Ok(json!(result))
}
"camera.capture" => {
let world = string(&p, "world")?;
let min: Pos =
serde_json::from_value(p.get("min").cloned().unwrap_or(json!([-24, -4, -24])))?;
let max: Pos =
serde_json::from_value(p.get("max").cloned().unwrap_or(json!([24, 24, 24])))?;
let blocks = self.store.read_region(world, min, max)?;
let colors = blocks
.iter()
.map(|b| {
(
b.pos,
self.definition(b.block)
.map(|s| s.color)
.unwrap_or([190, 60, 185]),
)
})
.collect();
let png = crate::render::capture(min, max, colors)?;
use base64::Engine;
Ok(
json!({"png_base64":base64::engine::general_purpose::STANDARD.encode(png),"mimeType":"image/png","world":world,"revision":self.store.revision(world)?,"kind":"server-software-isometric","min":min,"max":max}),
)
}
_ => bail!("unknown control method {method}"),
}
}
fn build_plan(&mut self, p: Value) -> Result<Value> {
self.plans.retain(|_, p| p.expires > Instant::now());
ensure!(self.plans.len() < 16, "build plan limit reached");
let world = string(&p, "world")?.to_owned();
let revision = p["expected_revision"]
.as_u64()
.context("expected_revision required")?;
ensure!(
self.store.revision(&world)? == revision,
"revision conflict"
);
let ops = p["operations"].as_array().context("operations required")?;
ensure!(
!ops.is_empty() && ops.len() <= 128,
"expected 1..128 operations"
);
let mut cells = BTreeMap::new();
for op in ops {
let state = op["state"].as_str().or_else(|| op["block"].as_str());
let block = if let Some(s) = state {
self.id(s)?
} else {
op["block"]
.as_u64()
.filter(|n| *n < self.store.registry().len() as u64)
.context("block ID or state required")? as u32
};
let kind = op["type"]
.as_str()
.or_else(|| op["kind"].as_str())
.context("operation type required")?;
let mut put = |pos: Pos| -> Result<()> {
ensure!(
pos.iter().all(|n| n.unsigned_abs() <= 30_000_000),
"position outside world limits"
);
cells.insert(pos, block);
ensure!(cells.len() <= 32768, "plan exceeds 32768 cells");
Ok(())
};
match kind {
"box" | "replace" => {
let min: Pos = serde_json::from_value(op["min"].clone())?;
let max: Pos = serde_json::from_value(op["max"].clone())?;
let mut volume = 1i64;
for i in 0..3 {
ensure!(max[i] >= min[i], "invalid box bounds");
volume = volume
.checked_mul(i64::from(max[i]) - i64::from(min[i]) + 1)
.context("box too large")?;
}
ensure!(volume <= 32768, "box exceeds 32768 cells");
let replace = if kind == "replace" {
Some(if let Some(s) = op["from"].as_str() {
self.id(s)?
} else {
op["from"].as_u64().context("replace from required")? as u32
})
} else {
None
};
for x in min[0]..=max[0] {
for y in min[1]..=max[1] {
for z in min[2]..=max[2] {
let pos = [x, y, z];
if op["hollow"] == true
&& x != min[0]
&& x != max[0]
&& y != min[1]
&& y != max[1]
&& z != min[2]
&& z != max[2]
{
continue;
}
if replace.is_none()
|| Some(self.store.get_block(&world, pos)?) == replace
{
put(pos)?;
}
}
}
}
}
"line" => {
let from: Pos = serde_json::from_value(op["from"].clone())?;
let to: Pos = serde_json::from_value(op["to"].clone())?;
let steps = (0..3)
.map(|i| (i64::from(to[i]) - i64::from(from[i])).abs())
.max()
.unwrap();
ensure!(steps <= 32767, "line too long");
for n in 0..=steps {
put(std::array::from_fn(|i| {
(f64::from(from[i])
+ (f64::from(to[i]) - f64::from(from[i])) * n as f64
/ steps.max(1) as f64)
.round() as i32
}))?;
}
}
"cylinder" => {
let center: Pos = serde_json::from_value(op["center"].clone())?;
let radius = op["radius"].as_i64().context("integer radius required")?;
let height = op["height"].as_i64().context("integer height required")?;
ensure!(
(1..=64).contains(&radius) && (1..=128).contains(&height),
"cylinder dimensions out of range"
);
for x in -radius..=radius {
for z in -radius..=radius {
if x * x + z * z > radius * radius {
continue;
}
if op["hollow"] == true && x * x + z * z < (radius - 1) * (radius - 1) {
continue;
}
for y in 0..height {
put([
center[0]
.checked_add(x as i32)
.context("coordinate overflow")?,
center[1]
.checked_add(y as i32)
.context("coordinate overflow")?,
center[2]
.checked_add(z as i32)
.context("coordinate overflow")?,
])?;
}
}
}
}
_ => bail!("unsupported build primitive {kind}"),
}
}
let changes: Vec<_> = cells
.into_iter()
.map(|(pos, block)| BlockChange { pos, block })
.collect();
let id = Uuid::new_v4().to_string();
let count = changes.len();
let min: Pos = std::array::from_fn(|i| changes.iter().map(|c| c.pos[i]).min().unwrap_or(0));
let max: Pos = std::array::from_fn(|i| changes.iter().map(|c| c.pos[i]).max().unwrap_or(0));
self.plans.insert(
id.clone(),
Plan {
world: world.clone(),
revision,
changes,
expires: Instant::now() + Duration::from_secs(300),
},
);
Ok(
json!({"plan_id":id,"world":world,"expected_revision":revision,"cells":count,"min":min,"max":max,"expires_in_seconds":300}),
)
}
}
fn entity_position(v: &Value) -> Result<[f32; 3]> {
let p: [f32; 3] = serde_json::from_value(v.clone())?;
ensure!(
p.iter().all(|n| n.is_finite() && n.abs() <= 32700.),
"position outside playable limits +/-32700"
);
Ok(p)
}
fn split_states(s: &str) -> Vec<&str> {
let mut depth = 0;
let mut start = 0;
let mut out = Vec::new();
for (i, c) in s.char_indices() {
match c {
'[' => depth += 1,
']' => depth -= 1,
',' if depth == 0 => {
out.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&s[start..]);
out
}
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
mod game;
mod packages;
mod render;
use anyhow::{Context, Result};
use axum::{
Json, Router,
extract::{
DefaultBodyLimit, Path as AxPath, Query, State, WebSocketUpgrade,
ws::{Message, WebSocket},
},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
routing::{get, post},
};
use clap::Parser;
use futures_util::{SinkExt, StreamExt};
use serde_json::{Value, json};
use std::{
path::PathBuf,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
mpsc,
},
time::{Duration, Instant},
};
use tokio::sync::{mpsc as tmpsc, oneshot};
use tower_http::services::ServeDir;
#[derive(Parser)]
#[command(about = "Shacraft authoritative voxel server + browser client")]
struct Args {
#[arg(long, default_value = "127.0.0.1:4000")]
listen: String,
#[arg(long, default_value = "data")]
data: PathBuf,
#[arg(long, default_value_t = 64)]
cache_sections: usize,
#[arg(long,default_value=concat!(env!("CARGO_MANIFEST_DIR"),"/../../client"))]
client: PathBuf,
#[arg(long,default_value=concat!(env!("CARGO_MANIFEST_DIR"),"/../../packages"))]
packages: PathBuf,
}
#[derive(Clone)]
struct App {
tx: mpsc::SyncSender<game::Command>,
token: Arc<String>,
packages: Arc<Vec<shacraft_content::Package>>,
sockets: Arc<tokio::sync::Semaphore>,
}
impl App {
async fn query(&self, method: &str, p: Value) -> Result<Value> {
let (tx, rx) = oneshot::channel();
self.tx
.try_send(game::Command::Query(method.into(), p, tx))
.map_err(|_| anyhow::anyhow!("server command queue busy"))?;
tokio::time::timeout(Duration::from_secs(25), rx)
.await
.context("server query timeout")?
.context("server stopped")?
}
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
anyhow::ensure!(
(1..=4096).contains(&args.cache_sections),
"cache_sections must be 1..4096"
);
std::fs::create_dir_all(&args.data)?;
let token_path = args.data.join("control.token");
let token = if token_path.exists() {
std::fs::read_to_string(&token_path)?.trim().to_owned()
} else {
let token = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(&token_path)?.write_all(token.as_bytes())?;
token
};
anyhow::ensure!(
token.len() >= 32,
"control token must have at least 32 characters"
);
let loaded = shacraft_content::load_packages(&args.packages)?;
let package_manifest = packages::manifest(&loaded);
let jump = packages::JumpModule::load(&loaded)?;
eprintln!("Loading durable worlds and complete Java 26.2 catalog…");
let game = game::Game::open(&args.data, args.cache_sections, package_manifest, jump)?;
let (tx, rx) = mpsc::sync_channel(64);
std::thread::Builder::new()
.name("shacraft-world".into())
.spawn(move || game.run(rx))?;
let app = App {
tx,
token: Arc::new(token),
packages: Arc::new(loaded),
sockets: Arc::new(tokio::sync::Semaphore::new(32)),
};
let router = Router::new()
.route("/api/health", get(health))
.route("/api/manifest", get(manifest))
.route("/api/worlds", get(worlds))
.route("/api/catalog", get(catalog))
.route("/api/entities", get(entities))
.route("/api/metrics", get(metrics))
.route("/api/control", post(control))
.route("/packages/{id}/{version}/{*path}", get(resource))
.route("/ws", get(websocket))
.fallback_service(ServeDir::new(&args.client))
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
.layer(tower::limit::ConcurrencyLimitLayer::new(16))
.with_state(app);
let listener = tokio::net::TcpListener::bind(&args.listen).await?;
eprintln!(
"Shacraft ready: http://{} · data {} · 20 ticks/s",
listener.local_addr()?,
args.data.display()
);
axum::serve(listener, router)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await?;
Ok(())
}
fn response(result: Result<Value>) -> Response {
match result {
Ok(v) => Json(v).into_response(),
Err(e) => (
StatusCode::BAD_REQUEST,
Json(json!({"error":e.to_string()})),
)
.into_response(),
}
}
async fn health(State(app): State<App>) -> Response {
response(app.query("health", json!({})).await)
}
async fn manifest(State(app): State<App>) -> Response {
response(app.query("manifest", json!({})).await)
}
async fn worlds(State(app): State<App>) -> Response {
response(app.query("world.list", json!({})).await)
}
async fn metrics(State(app): State<App>) -> Response {
response(app.query("metrics", json!({})).await)
}
async fn entities(State(app): State<App>) -> Response {
response(app.query("entity.catalog", json!({})).await)
}
async fn catalog(
State(app): State<App>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
let p = json!({"query":q.get("query"),"ids":q.get("ids"),"states":q.get("states"),"offset":q.get("offset").and_then(|s|s.parse::<u64>().ok()).unwrap_or(0),"limit":q.get("limit").and_then(|s|s.parse::<u64>().ok()).unwrap_or(64)});
response(app.query("catalog.search", p).await)
}
async fn control(State(app): State<App>, headers: HeaderMap, Json(body): Json<Value>) -> Response {
if headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
!= Some(format!("Bearer {}", app.token).as_str())
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error":"control token required"})),
)
.into_response();
}
if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok())
&& !same_origin(&headers, origin)
{
return StatusCode::FORBIDDEN.into_response();
}
let Some(method) = body["method"].as_str() else {
return response(Err(anyhow::anyhow!("method required")));
};
match app
.query(method, body.get("params").cloned().unwrap_or(json!({})))
.await
{
Ok(v) => Json(json!({"result":v})).into_response(),
Err(e) => (
StatusCode::BAD_REQUEST,
Json(json!({"error":e.to_string()})),
)
.into_response(),
}
}
async fn resource(
State(app): State<App>,
AxPath((id, version, path)): AxPath<(String, String, String)>,
) -> Response {
let Some(p) = app
.packages
.iter()
.find(|p| p.manifest.id == id && p.manifest.version == version)
else {
return StatusCode::NOT_FOUND.into_response();
};
match p.public_resource(&path) {
Ok(bytes) => {
let mime = if path.ends_with(".png") {
"image/png"
} else if path.ends_with(".wav") {
"audio/wav"
} else if path.ends_with(".json") {
"application/json"
} else {
"text/plain; charset=utf-8"
};
(
[
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "public, max-age=0, must-revalidate"),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
],
bytes,
)
.into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
fn same_origin(headers: &HeaderMap, origin: &str) -> bool {
headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.is_some_and(|host| {
origin == format!("http://{host}") || origin == format!("https://{host}")
})
}
async fn websocket(State(app): State<App>, headers: HeaderMap, ws: WebSocketUpgrade) -> Response {
if headers
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
.is_some_and(|o| !same_origin(&headers, o))
{
return StatusCode::FORBIDDEN.into_response();
}
let Ok(permit) = app.sockets.clone().try_acquire_owned() else {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
};
ws.max_message_size(65536)
.max_frame_size(65536)
.on_upgrade(move |socket| session(socket, app, permit))
}
async fn session(mut socket: WebSocket, app: App, _permit: tokio::sync::OwnedSemaphorePermit) {
let join = match tokio::time::timeout(Duration::from_secs(10), socket.recv()).await {
Ok(Some(Ok(Message::Text(text)))) => serde_json::from_str::<Value>(&text).ok(),
_ => None,
};
let Some(join) = join else { return };
if join["type"] != "join" {
return;
}
let (tx, mut rx) = tmpsc::channel(16);
let bytes = Arc::new(AtomicUsize::new(0));
let out = game::Outbox {
tx,
bytes: bytes.clone(),
};
let (reply, answer) = oneshot::channel();
if app
.tx
.try_send(game::Command::Join(join, out, reply))
.is_err()
{
return;
}
let id = match tokio::time::timeout(Duration::from_secs(25), answer).await {
Ok(Ok(Ok(id))) => id,
Ok(Ok(Err(e))) => {
let _ = socket
.send(Message::Text(
json!({"type":"error","message":e.to_string()})
.to_string()
.into(),
))
.await;
return;
}
_ => return,
};
let (mut writer, mut reader) = socket.split();
let sending = async {
while let Some(text) = rx.recv().await {
let n = text.len();
let sent = tokio::time::timeout(
Duration::from_secs(5),
writer.send(Message::Text((*text).clone().into())),
)
.await;
bytes.fetch_sub(n, Ordering::Relaxed);
if !matches!(sent, Ok(Ok(()))) {
break;
}
}
};
let receiving = async {
let mut start = Instant::now();
let mut count = 0;
while let Some(Ok(message)) = reader.next().await {
if start.elapsed() >= Duration::from_secs(1) {
start = Instant::now();
count = 0;
}
count += 1;
if count > 80 {
break;
}
match message {
Message::Text(text) => {
let Ok(value) = serde_json::from_str(&text) else {
break;
};
if app
.tx
.try_send(game::Command::Client(id.clone(), value))
.is_err()
{
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
};
tokio::select! {_=sending=>{},_=receiving=>{}}
let _ = app.tx.try_send(game::Command::Leave(id));
}
+413
View File
@@ -0,0 +1,413 @@
use anyhow::{Context, Result, ensure};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use shacraft_content::Package;
use wasmi::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc};
pub fn manifest(packages: &[Package]) -> Value {
let public: Vec<_> = packages
.iter()
.map(|p| {
let mut m = p.manifest.client_manifest();
let files: Vec<_> = p
.manifest
.resources
.iter()
.filter(|r| r.scope != "server")
.map(|r| {
let mut v = json!(r);
v["url"] = json!(format!(
"/packages/{}/{}/{}",
p.manifest.id, p.manifest.version, r.path
));
v["kind"] = json!(r.role);
v
})
.collect();
m["files"] = json!(files);
m.as_object_mut().unwrap().remove("resources");
m
})
.collect();
let hash = format!("{:x}", Sha256::digest(serde_json::to_vec(&public).unwrap()));
json!({"protocol":1,"hash":hash,"packages":public,"minecraft_version":"26.2","client_integrity":"manifest compatibility only; executable integrity is not proven"})
}
pub struct JumpModule {
store: Store<StoreLimits>,
function: TypedFunc<(), f32>,
calls: u64,
traps: u64,
definitions: Vec<Value>,
hook_states: std::collections::HashSet<String>,
}
impl JumpModule {
pub fn load(packages: &[Package]) -> Result<Self> {
let mut source = None;
for p in packages {
for r in &p.manifest.resources {
if r.role == "wasm-on-jump" {
ensure!(source.is_none(), "only one on_jump provider may be active");
source = Some(p.read_resource(&r.path)?);
}
}
}
let mut module = Self::from_bytes(&source.context("on_jump module missing")?)?;
module.hook_states.clear();
for package in packages {
for resource in &package.manifest.resources {
if resource.role == "definitions" {
let data: Value =
serde_json::from_slice(&package.read_resource(&resource.path)?)?;
ensure!(
data["schema"] == 1,
"unsupported content definitions schema"
);
if let Some(value) = data.get("blocks") {
let blocks = value
.as_array()
.context("content blocks must be an array")?;
ensure!(
package
.manifest
.capabilities
.iter()
.any(|c| c == "blocks.define"),
"blocks.define capability required"
);
ensure!(
module.definitions.len() + blocks.len() <= 4096,
"package block limit exceeded"
);
for block in blocks {
if block["behavior"]["server_hook"] == "on_jump" {
module.hook_states.insert(
block["state"].as_str().context("state required")?.into(),
);
} else {
ensure!(
block.get("behavior").is_none(),
"unsupported block behavior"
);
}
module.definitions.push(block.clone());
}
}
}
}
}
Ok(module)
}
fn from_bytes(bytes: &[u8]) -> Result<Self> {
ensure!(bytes.len() <= 65536, "WASM module exceeds 64 KiB");
let mut config = Config::default();
config.consume_fuel(true);
let engine = Engine::new(&config);
let module = Module::new(&engine, bytes)?;
ensure!(
module.imports().next().is_none(),
"WASM imports are not permitted"
);
let limits = StoreLimitsBuilder::new()
.memory_size(65536)
.table_elements(128)
.instances(1)
.tables(1)
.memories(1)
.trap_on_grow_failure(true)
.build();
let mut store = Store::new(&engine, limits);
store.limiter(|s| s);
store.set_fuel(10000)?;
let linker = Linker::new(&engine);
let instance = linker.instantiate_and_start(&mut store, &module)?;
let function = instance.get_typed_func::<(), f32>(&store, "on_jump")?;
Ok(Self {
store,
function,
calls: 0,
traps: 0,
definitions: Vec::new(),
hook_states: ["shacraft:trampoline".into()].into_iter().collect(),
})
}
pub fn handles(&self, state: &str) -> bool {
self.hook_states.contains(state)
}
pub fn extend_catalog(&self, catalog: &mut shacraft_content::Catalog) -> Result<()> {
use shacraft_content::{Aabb, BlockDefinition, Coverage, StateDefinition};
let mut seen = std::collections::HashSet::new();
for definition in &self.definitions {
let state = definition["state"]
.as_str()
.context("package state required")?;
let (namespace, path) = state
.split_once(':')
.context("package namespace required")?;
let valid_part = |part: &str, slash: bool| {
!part.is_empty()
&& part.bytes().all(|b| {
b.is_ascii_lowercase()
|| b.is_ascii_digit()
|| b"_.-".contains(&b)
|| (slash && b == b'/')
})
};
ensure!(
state.len() <= 512
&& namespace != "minecraft"
&& valid_part(namespace, false)
&& valid_part(path, true),
"package state must be a new lowercase namespaced identifier"
);
ensure!(seen.insert(state), "duplicate package block definition");
let color: [u8; 3] = serde_json::from_value(definition["color"].clone())?;
let collision: Vec<Aabb> = serde_json::from_value(definition["collision"].clone())?;
let render: Vec<Aabb> = if let Some(value) = definition.get("render") {
serde_json::from_value(value.clone())?
} else {
catalog
.state(state)
.map(|s| s.render.clone())
.unwrap_or_else(|| collision.clone())
};
ensure!(
collision.len() <= 64
&& render.len() <= 64
&& collision.iter().chain(&render).all(|b| b.valid()
&& b.min.iter().chain(&b.max).all(|v| (-2.0..=3.0).contains(v))),
"invalid package geometry"
);
let opacity = if let Some(value) = definition.get("opacity") {
value.as_f64().context("opacity must be a number")?
} else {
1.
};
ensure!(
opacity.is_finite() && (0.0..=1.0).contains(&opacity),
"opacity must be in 0..1"
);
let item = StateDefinition {
state: state.into(),
minecraft_id: u32::MAX,
color,
opacity: opacity as f32,
collision,
render,
coverage: Coverage {
identity: "authored-package".into(),
render: "authored-package".into(),
collision: "authored-exact".into(),
behavior: if self.handles(state) {
"wasm-on-jump"
} else {
"storage-and-physics"
}
.into(),
},
family: state.split(':').nth(1).unwrap().into(),
light: 0,
hardness: 1.,
};
if let Some(existing) = catalog.states.iter_mut().find(|s| s.state == state) {
*existing = item;
} else {
catalog.states.push(item);
catalog.blocks.push(BlockDefinition {
name: state.into(),
default_state: state.into(),
properties: Default::default(),
family: "package".into(),
});
}
}
catalog.rebuild_indexes()?;
Ok(())
}
pub fn call(&mut self) -> Result<f32> {
self.calls += 1;
self.store.set_fuel(10000)?;
match self.function.call(&mut self.store, ()) {
Ok(v) if v.is_finite() && (0.0..=20.0).contains(&v) => Ok(v),
_ => {
self.traps += 1;
anyhow::bail!("on_jump trapped or returned an invalid impulse")
}
}
}
pub fn metrics(&self) -> Value {
json!({"runtime":"wasmi","calls":self.calls,"traps":self.traps,"fuel_per_call":10000,"memory_limit":65536,"imports":0})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sandbox_executes_and_bounds_fuel_memory_and_results() {
let mut m = JumpModule::from_bytes(
b"(module (func (export \"on_jump\") (result f32) f32.const 10))",
)
.unwrap();
assert_eq!(m.call().unwrap(), 10.);
let mut looped = JumpModule::from_bytes(
b"(module (func (export \"on_jump\") (result f32) (loop br 0) f32.const 10))",
)
.unwrap();
assert!(looped.call().is_err());
assert_eq!(looped.metrics()["traps"], 1);
assert!(
JumpModule::from_bytes(
b"(module (memory 2) (func (export \"on_jump\") (result f32) f32.const 10))"
)
.is_err()
);
assert!(JumpModule::from_bytes(b"(module (import \"host\" \"secret\" (func)) (func (export \"on_jump\") (result f32) f32.const 10))").is_err());
let mut invalid = JumpModule::from_bytes(
b"(module (func (export \"on_jump\") (result f32) f32.const nan))",
)
.unwrap();
assert!(invalid.call().is_err());
}
fn custom_block(state: &str) -> Value {
json!({"state":state,"color":[81,153,187],"collision":[{"min":[0,0,0],"max":[1,0.75,1]}],"render":[{"min":[0.1,0,0.1],"max":[0.9,0.75,0.9]}],"opacity":0.9,"behavior":{"server_hook":"on_jump"}})
}
fn package_fixture(blocks: Vec<Value>) -> (tempfile::TempDir, Vec<Package>) {
use shacraft_content::{PackageManifest, PackageResource};
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("custom");
std::fs::create_dir_all(dir.join("common")).unwrap();
std::fs::create_dir_all(dir.join("server")).unwrap();
std::fs::write(
dir.join("common/definitions.json"),
serde_json::to_vec(&json!({"schema":1,"blocks":blocks})).unwrap(),
)
.unwrap();
let mut wasm =
include_bytes!("../../../packages/trampoline/server/trampoline.wasm").to_vec();
let end = wasm.len();
wasm[end - 5..end - 1].copy_from_slice(&12f32.to_le_bytes());
std::fs::write(dir.join("server/spring.wasm"), &wasm).unwrap();
let resources = [
("common/definitions.json", "common", "definitions"),
("server/spring.wasm", "server", "wasm-on-jump"),
]
.into_iter()
.map(|(path, scope, role)| {
let data = std::fs::read(dir.join(path)).unwrap();
PackageResource {
path: path.into(),
scope: scope.into(),
role: role.into(),
size: data.len() as u64,
sha256: format!("{:x}", Sha256::digest(&data)),
}
})
.collect();
let manifest = PackageManifest {
schema: 1,
id: "example.custom".into(),
version: "1.0.0".into(),
license: "MIT".into(),
dependencies: vec![],
capabilities: vec!["blocks.define".into(), "server.on_jump".into()],
resources,
};
std::fs::write(
dir.join("manifest.json"),
serde_json::to_vec(&manifest).unwrap(),
)
.unwrap();
let packages = shacraft_content::load_packages(root.path()).unwrap();
(root, packages)
}
#[test]
fn verified_custom_package_registers_content_and_binds_real_wasm() {
use shacraft_core::{BlockChange, WorldStore};
let mut ordinary = custom_block("example:plain_tile");
ordinary.as_object_mut().unwrap().remove("behavior");
let (root, packages) = package_fixture(vec![custom_block("example:spring"), ordinary]);
let mut module = JumpModule::load(&packages).unwrap();
let mut catalog = shacraft_content::Catalog::load_builtin().unwrap();
module.extend_catalog(&mut catalog).unwrap();
let spring = catalog.state("example:spring").unwrap();
assert_eq!(spring.color, [81, 153, 187]);
assert_eq!(spring.collision[0].max[1], 0.75);
assert_eq!(spring.render[0].min[0], 0.1);
assert_eq!(spring.coverage.behavior, "wasm-on-jump");
assert!(module.handles("example:spring"));
assert!(!module.handles("example:plain_tile"));
assert!(!module.handles("shacraft:trampoline"));
let world_path = root.path().join("world");
let mut store = WorldStore::open(&world_path, 2).unwrap();
store.register_block("minecraft:dirt").unwrap();
let id = store.register_block(&spring.state).unwrap();
assert_ne!(id, spring.minecraft_id);
store.create_world("module", None).unwrap();
store
.edit(
"module",
0,
"package-placement",
vec![BlockChange {
pos: [0, 0, 0],
block: id,
}],
)
.unwrap();
let under = store.get_block("module", [0, 0, 0]).unwrap();
assert!(module.handles(&store.registry()[under as usize]));
assert_eq!(module.call().unwrap(), 12.);
assert_eq!(module.metrics()["calls"], 1);
drop(store);
let mut reopened = WorldStore::open(&world_path, 2).unwrap();
assert_eq!(reopened.get_block("module", [0, 0, 0]).unwrap(), id);
assert_eq!(reopened.registry()[id as usize], "example:spring");
let public = manifest(&packages).to_string();
assert!(public.contains("common/definitions.json"));
assert!(!public.contains("server/spring.wasm"));
}
#[test]
fn duplicate_vanilla_and_invalid_geometry_definitions_are_rejected() {
let mut bad_box = custom_block("example:bad_box");
bad_box["collision"][0]["max"] = json!([4, 1, 1]);
let mut reversed = custom_block("example:reversed");
reversed["render"][0]["max"] = json!([0, 0, 0]);
let mut oversized = custom_block("example:many_boxes");
oversized["render"] = json!(vec![json!({"min":[0,0,0],"max":[1,1,1]}); 65]);
let mut opacity = custom_block("example:bad_opacity");
opacity["opacity"] = json!(2);
let invalid_sets = vec![
vec![
custom_block("example:duplicate"),
custom_block("example:duplicate"),
],
vec![custom_block("minecraft:stone")],
vec![bad_box],
vec![reversed],
vec![oversized],
vec![opacity],
vec![custom_block("example:bad,state")],
vec![custom_block(":empty")],
];
let catalog = shacraft_content::Catalog::load_builtin().unwrap();
for blocks in invalid_sets {
let (_root, packages) = package_fixture(blocks);
let module = JumpModule::load(&packages).unwrap();
assert!(module.extend_catalog(&mut catalog.clone()).is_err());
}
}
#[test]
fn changed_definition_or_module_fails_hash_verification_before_loading() {
for path in ["common/definitions.json", "server/spring.wasm"] {
let (root, packages) = package_fixture(vec![custom_block("example:spring")]);
let file = root.path().join("custom").join(path);
let mut bytes = std::fs::read(&file).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 1;
std::fs::write(&file, bytes).unwrap();
assert!(
JumpModule::load(&packages).is_err(),
"changed {path} accepted after verified package load"
);
assert!(shacraft_content::load_packages(root.path()).is_err());
}
}
}
+100
View File
@@ -0,0 +1,100 @@
use anyhow::{Result, ensure};
use shacraft_core::Pos;
pub fn capture(min: Pos, max: Pos, mut cells: Vec<(Pos, [u8; 3])>) -> Result<Vec<u8>> {
ensure!((0..3).all(|i| max[i] >= min[i]), "invalid capture bounds");
let size = 512usize;
let mut image = vec![0u8; size * size * 3];
for pixel in image.chunks_exact_mut(3) {
pixel.copy_from_slice(&[24, 36, 48]);
}
let span = (max[0] - min[0] + max[2] - min[2] + 2) as f32;
let vertical = ((max[1] - min[1] + 1) as f32 + span * 0.25).max(1.);
let scale = (460. / span.max(1.)).min(440. / vertical).clamp(0.1, 24.);
cells.sort_by_key(|(p, _)| (p[0] + p[2], p[1]));
for (p, c) in cells {
let x = (p[0] - min[0]) as f32;
let y = (p[1] - min[1]) as f32;
let z = (p[2] - min[2]) as f32;
let cx = 256. + (x - z - (max[0] - min[0] - max[2] + min[2]) as f32 * 0.5) * scale;
let cy = 470. - (y + 1.) * scale - (span * 0.5 - x - z) * scale * 0.5;
let top = [
[cx, cy - scale * 0.5],
[cx + scale, cy],
[cx, cy + scale * 0.5],
[cx - scale, cy],
];
polygon(&mut image, size, top, c);
polygon(
&mut image,
size,
[
[cx - scale, cy],
[cx, cy + scale * 0.5],
[cx, cy + scale * 1.5],
[cx - scale, cy + scale],
],
c.map(|v| (v as f32 * 0.64) as u8),
);
polygon(
&mut image,
size,
[
[cx, cy + scale * 0.5],
[cx + scale, cy],
[cx + scale, cy + scale],
[cx, cy + scale * 1.5],
],
c.map(|v| (v as f32 * 0.82) as u8),
);
}
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, size as u32, size as u32);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header()?.write_image_data(&image)?;
}
Ok(bytes)
}
fn polygon(img: &mut [u8], size: usize, p: [[f32; 2]; 4], color: [u8; 3]) {
let minx = p
.iter()
.map(|p| p[0])
.fold(f32::INFINITY, f32::min)
.floor()
.max(0.) as usize;
let maxx = p
.iter()
.map(|p| p[0])
.fold(f32::NEG_INFINITY, f32::max)
.ceil()
.clamp(0., size as f32 - 1.) as usize;
let miny = p
.iter()
.map(|p| p[1])
.fold(f32::INFINITY, f32::min)
.floor()
.max(0.) as usize;
let maxy = p
.iter()
.map(|p| p[1])
.fold(f32::NEG_INFINITY, f32::max)
.ceil()
.clamp(0., size as f32 - 1.) as usize;
for y in miny..=maxy {
for x in minx..=maxx {
let mut signs = [false; 4];
for i in 0..4 {
let a = p[i];
let b = p[(i + 1) % 4];
signs[i] = (b[0] - a[0]) * (y as f32 + 0.5 - a[1])
- (b[1] - a[1]) * (x as f32 + 0.5 - a[0])
>= 0.;
}
if signs.iter().all(|s| *s) || signs.iter().all(|s| !*s) {
let i = (y * size + x) * 3;
img[i..i + 3].copy_from_slice(&color);
}
}
}
}
+554
View File
@@ -0,0 +1,554 @@
//! Stateful acceptance tests for authority, durability, and bounded client queues.
//! Included as a child module of game.rs so tests exercise the real game actor.
use super::*;
use std::sync::OnceLock;
use tempfile::TempDir;
type Inbox = tmpsc::Receiver<Arc<String>>;
struct TestClient {
id: String,
inbox: Inbox,
bytes: Arc<AtomicUsize>,
}
impl TestClient {
fn drain(&mut self) -> Vec<Value> {
let mut messages = Vec::new();
while let Ok(message) = self.inbox.try_recv() {
self.bytes.fetch_sub(message.len(), Ordering::Relaxed);
messages.push(serde_json::from_str(&message).unwrap());
}
messages
}
}
fn open_game(path: &Path) -> Game {
let packages = shacraft_content::load_packages(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packages"),
)
.unwrap();
let manifest = crate::packages::manifest(&packages);
let jump = crate::packages::JumpModule::load(&packages).unwrap();
Game::open(path, 16, manifest, jump).unwrap()
}
fn fixture() -> (TempDir, Game) {
// Do expensive full-catalog initial registration once. Copy closed durable
// databases into isolated directories; do not share a live WorldStore writer.
static SEED: OnceLock<TempDir> = OnceLock::new();
let seed = SEED.get_or_init(|| {
let dir = tempfile::tempdir().unwrap();
let mut game = open_game(dir.path());
game.store.flush().unwrap();
drop(game);
dir
});
let dir = tempfile::tempdir().unwrap();
for entry in std::fs::read_dir(seed.path()).unwrap() {
let path = entry.unwrap().path();
if path.is_file() && path.extension().is_some_and(|s| s == "sqlite3") {
std::fs::copy(&path, dir.path().join(path.file_name().unwrap())).unwrap();
}
}
let game = open_game(dir.path());
(dir, game)
}
fn connect(game: &mut Game, world: &str, name: &str) -> TestClient {
let (tx, inbox) = tmpsc::channel(16);
let bytes = Arc::new(AtomicUsize::new(0));
let p = json!({"type":"join","protocol":1,"name":name,"world":world,"manifest_hash":game.manifest["hash"]});
let id = game
.join(
p,
Outbox {
tx,
bytes: bytes.clone(),
},
)
.unwrap();
TestClient { id, inbox, bytes }
}
fn ticks(game: &mut Game, clients: &mut [&mut TestClient], count: usize) {
for _ in 0..count {
game.step();
for client in clients.iter_mut() {
client.drain();
}
}
}
fn edit(game: &mut Game, world: &str, changes: Vec<BlockChange>) {
let revision = game.store.revision(world).unwrap();
game.store
.edit(world, revision, &Uuid::new_v4().to_string(), changes)
.unwrap();
}
fn entity_spawn(game: &mut Game, kind: &str) -> Result<Value> {
game.query("entity.spawn",json!({"world":"lobby","kind":kind,"position":[2.,1.,2.],"properties":{"custom_name":"Saved animal","nbt":{"test:unrecognized":{"type":"long","value":"9223372036854775807"}}},"expected_revision":game.meta.revision,"operation_id":Uuid::new_v4().to_string()}))
}
#[test]
fn official_collision_shapes_drive_world_physics() {
let (_dir, mut game) = fixture();
game.store.create_world("collision", None).unwrap();
let states = [
"minecraft:stone",
"minecraft:oak_slab[type=bottom,waterlogged=false]",
"minecraft:oak_fence",
"minecraft:water",
];
let changes = states
.into_iter()
.enumerate()
.map(|(i, s)| BlockChange {
pos: [i as i32 * 3, 0, 0],
block: game.id(s).unwrap(),
})
.collect();
edit(&mut game, "collision", changes);
assert!(!game.collision("collision", [0.5, 1., 0.5]));
assert!(game.collision("collision", [0.5, 0.9, 0.5]));
assert!(!game.collision("collision", [3.5, 0.5, 0.5]));
assert!(game.collision("collision", [3.5, 0.49, 0.5]));
assert!(game.collision("collision", [6.5, 1.2, 0.5]));
assert!(!game.collision("collision", [6.5, 1.5, 0.5]));
assert!(!game.collision("collision", [9.5, 0., 0.5]));
}
#[test]
fn authoritative_actions_reject_forged_targets_and_sync_real_edits() {
let (_dir, mut game) = fixture();
let mut a = connect(&mut game, "lobby", "Builder");
let mut b = connect(&mut game, "lobby", "Observer");
a.drain();
b.drain();
let player = game.players.get_mut(&a.id).unwrap();
player.position = [0.5, 1., 8.5];
player.input.pitch = -1.2;
let before = game.store.revision("lobby").unwrap();
assert!(
game.client(&a.id, json!({"type":"break","pos":[20,0,20]}))
.is_err()
);
assert_eq!(game.store.revision("lobby").unwrap(), before);
let (hit, normal) = game.raycast(&a.id).unwrap().expect("ground in reach");
let place: Pos = std::array::from_fn(|i| hit[i] + normal[i]);
assert!(
game.client(&a.id, json!({"type":"place","pos":place,"block":u64::MAX}))
.is_err()
);
assert_eq!(game.store.revision("lobby").unwrap(), before);
game.client(&a.id, json!({"type":"break","pos":hit}))
.unwrap();
assert_eq!(game.store.get_block("lobby", hit).unwrap(), 0);
let messages = b.drain();
let delta = messages
.iter()
.find(|m| m["type"] == "blocks")
.expect("observer must receive edit");
assert_eq!(delta["revision"], before + 1);
assert_eq!(delta["changes"][0]["pos"], json!(hit));
assert!(
game.client(
&a.id,
json!({"type":"input","seq":1,"yaw":0,"pitch":0,"forward":1000,"strafe":0,"jump":false})
)
.is_err()
);
}
#[test]
fn trampoline_executes_wasm_despite_small_contact_gap() {
let (_dir, mut game) = fixture();
game.store.create_world("spring", None).unwrap();
let spring = game.id("shacraft:trampoline").unwrap();
edit(
&mut game,
"spring",
vec![BlockChange {
pos: [0, 0, 0],
block: spring,
}],
);
let mut client = connect(&mut game, "spring", "Jumper");
client.drain();
let p = game.players.get_mut(&client.id).unwrap();
p.position = [0.5, 1.075, 0.5];
p.grounded = true;
p.velocity = 0.;
p.input.jump = true;
p.last_input = Instant::now();
let before = game.jump.metrics()["calls"].as_u64().unwrap();
game.step();
client.drain();
assert_eq!(
game.jump.metrics()["calls"].as_u64().unwrap(),
before + 1,
"grounded spring contact must invoke module"
);
assert!(
game.players[&client.id].position[1] > 1.45,
"WASM impulse must affect authoritative position"
);
}
#[test]
fn eliminated_spleef_players_cannot_reenter_and_late_joins_spectate() {
let (_dir, mut game) = fixture();
let mut a = connect(&mut game, "arena_1", "A");
let mut b = connect(&mut game, "arena_1", "B");
let mut c = connect(&mut game, "arena_1", "C");
a.drain();
b.drain();
c.drain();
game.start_match("arena_1").unwrap();
ticks(&mut game, &mut [&mut a, &mut b, &mut c], 60);
assert_eq!(game.match_json("arena_1")["phase"], "active");
game.players.get_mut(&a.id).unwrap().position[1] = -7.;
ticks(&mut game, &mut [&mut a, &mut b, &mut c], 1);
assert!(!game.players[&a.id].alive);
assert_eq!(game.match_json("arena_1")["phase"], "active");
game.players.get_mut(&a.id).unwrap().last_snapshot = Instant::now() - Duration::from_secs(5);
game.client(&a.id, json!({"type":"switch_world","world":"lobby"}))
.unwrap();
a.drain();
game.players.get_mut(&a.id).unwrap().last_snapshot = Instant::now() - Duration::from_secs(5);
let result = game.client(&a.id, json!({"type":"switch_world","world":"arena_1"}));
if result.is_ok() {
assert!(
!game.players[&a.id].alive,
"eliminated participant became alive by switching worlds"
);
}
let mut late = connect(&mut game, "arena_1", "Late");
let welcome = late.drain();
assert_eq!(
welcome[0]["revision"],
game.store.revision("arena_1").unwrap()
);
assert!(
!game.players[&late.id].alive,
"mid-round joins must be spectators"
);
assert!(
game.client(&late.id, json!({"type":"break","pos":[0,0,0]}))
.is_err()
);
}
#[test]
fn entities_are_durable_and_failed_commits_do_not_publish_changes() {
let (dir, mut game) = fixture();
let spawned = entity_spawn(&mut game, "minecraft:cow").unwrap();
let entity = spawned["entity"].clone();
let id = entity["id"].as_str().unwrap().to_owned();
let revision = game.meta.revision;
drop(game);
let mut game = open_game(dir.path());
assert_eq!(game.meta.entities.get(&id), Some(&entity));
assert!(game.meta.revision >= revision);
let before = game.meta.entities.clone();
let revision = game.meta.revision;
game.db.execute_batch("PRAGMA query_only=ON;").unwrap();
assert!(entity_spawn(&mut game, "minecraft:pig").is_err());
assert_eq!(
game.meta.entities, before,
"failed persistent mutation leaked into visible RAM state"
);
assert_eq!(
game.meta.revision, revision,
"failed persistent mutation advanced public revision"
);
game.db.execute_batch("PRAGMA query_only=OFF;").unwrap();
}
#[test]
fn restart_recovers_arena_configuration_and_cleans_interrupted_round() {
let (dir, mut game) = fixture();
let mut a = connect(&mut game, "arena_1", "A");
let mut b = connect(&mut game, "arena_1", "B");
a.drain();
b.drain();
game.query("arena.configure",json!({"world":"arena_1","spawn":[3.,2.,5.],"expected_revision":game.meta.revision,"operation_id":Uuid::new_v4().to_string()})).unwrap();
game.start_match("arena_1").unwrap();
edit(
&mut game,
"arena_1",
vec![BlockChange {
pos: [0, 0, 0],
block: 0,
}],
);
let dirty_revision = game.store.revision("arena_1").unwrap();
drop(game);
let mut reopened = open_game(dir.path());
assert_eq!(reopened.config("arena_1").spawn, [3., 2., 5.]);
assert_eq!(reopened.match_json("arena_1")["phase"], "waiting");
assert_eq!(
reopened.store.get_block("arena_1", [0, 0, 0]).unwrap(),
reopened.id("minecraft:snow_block").unwrap(),
"interrupted round left a damaged waiting arena"
);
assert!(reopened.store.revision("arena_1").unwrap() > dirty_revision);
assert_eq!(
reopened.store.get_block("arena_2", [0, 0, 0]).unwrap(),
reopened.id("minecraft:snow_block").unwrap()
);
}
#[test]
fn snapshots_carry_revision_and_slow_consumers_are_disconnected() {
let (_dir, mut game) = fixture();
let mut client = connect(&mut game, "lobby", "Slow");
let welcome = client.drain();
assert_eq!(welcome[0]["type"], "welcome");
assert_eq!(
welcome[0]["revision"],
game.store.revision("lobby").unwrap()
);
assert!(
welcome[0]["materials"]
.as_array()
.unwrap()
.iter()
.all(|m| m["id"].is_u64())
);
for _ in 0..20 {
game.step();
}
assert!(
!game.players.contains_key(&client.id),
"full bounded outbox must evict slow player"
);
assert_eq!(game.slow_clients, 1);
assert!(client.bytes.load(Ordering::Relaxed) <= 8 * 1024 * 1024);
}
#[test]
fn outbox_has_byte_and_message_limits() {
let (tx, _rx) = tmpsc::channel(16);
let bytes = Arc::new(AtomicUsize::new(0));
let out = Outbox {
tx,
bytes: bytes.clone(),
};
assert!(!out.send_shared(Arc::new("x".repeat(8 * 1024 * 1024 + 1))));
assert_eq!(bytes.load(Ordering::Relaxed), 0);
for _ in 0..16 {
assert!(out.send(json!({"a":1})));
}
let before = bytes.load(Ordering::Relaxed);
assert!(!out.send(json!({"a":2})));
assert_eq!(bytes.load(Ordering::Relaxed), before);
}
#[test]
fn legacy_world_configuration_gets_safe_spleef_defaults() {
let conf: WorldConfig =
serde_json::from_value(json!({"spawn":[1,2,3],"mode":"spleef"})).unwrap();
assert_eq!(conf.spawn, [1., 2., 3.]);
assert_eq!(conf.countdown_seconds, 3);
assert_eq!(conf.round_seconds, 180);
assert_eq!(conf.min_players, 2);
assert_eq!(conf.elimination_y, -6.);
assert!(conf.spawn_points.is_empty());
assert_eq!(conf.spectator_spawn, [13., 2., 0.]);
assert_eq!(conf.floor_block, "minecraft:snow_block");
}
#[test]
fn arena_configuration_validates_before_mutation() {
let (_dir, mut game) = fixture();
let before = serde_json::to_value(&game.meta).unwrap();
for (key, value) in [
("countdown_seconds", json!(0)),
("countdown_seconds", json!(31)),
("round_seconds", json!(0)),
("round_seconds", json!(3601)),
("min_players", json!(1)),
("min_players", json!(33)),
("spawn", json!([32701, 2, 0])),
("spectator_spawn", json!([0, -7, 0])),
("elimination_y", json!(5)),
("floor_block", json!("minecraft:water")),
("floor_block", json!("unknown:missing")),
("spawn_points", json!([[0, 2, 0]])),
("spawn_points", json!([[0, 2, 0], [0, 2, 0]])),
("spawn_points", json!([[0, 2, 0], [1, 2, 0], [32701, 2, 0]])),
("spawn_points", json!(vec![[0, 2, 0]; 33])),
("min_players", Value::Null),
] {
let mut params = json!({"world":"arena_1"});
params[key] = value;
assert!(
game.query("arena.configure", params).is_err(),
"accepted invalid {key}"
);
assert_eq!(
serde_json::to_value(&game.meta).unwrap(),
before,
"failed {key} changed persistent metadata"
);
}
assert!(entity_position(&json!([32701, 1, 0])).is_err());
}
#[test]
fn configured_spleef_rules_control_full_round_and_survive_restart() {
let (dir, mut game) = fixture();
game.store.create_world("stone_template", None).unwrap();
let stone = game.id("minecraft:stone").unwrap();
let mut cells = Vec::new();
for x in -4..=4 {
for z in -4..=4 {
cells.push(BlockChange {
pos: [x, 0, z],
block: stone,
});
}
}
edit(&mut game, "stone_template", cells);
game.query(
"world.create",
json!({"world":"stone_arena","template":"stone_template"}),
)
.unwrap();
let configured=game.query("arena.configure",json!({"world":"stone_arena","mode":"spleef","spawn":[0.5,2.,3.5],"countdown_seconds":1,"round_seconds":2,"min_players":3,"elimination_y":-2,"spawn_points":[[0.5,1.,2.5],[2.5,1.,2.5],[-2.5,1.,2.5]],"spectator_spawn":[13.,2.,4.],"floor_block":"minecraft:stone"})).unwrap();
let mut a = connect(&mut game, "stone_arena", "A");
let mut b = connect(&mut game, "stone_arena", "B");
a.drain();
b.drain();
assert!(game.start_match("stone_arena").is_err());
let mut c = connect(&mut game, "stone_arena", "C");
c.drain();
game.start_match("stone_arena").unwrap();
let spawns: Vec<_> = game
.players
.values()
.filter(|p| p.world == "stone_arena")
.map(|p| p.position)
.collect();
for point in [[0.5, 1., 2.5], [2.5, 1., 2.5], [-2.5, 1., 2.5]] {
assert!(spawns.contains(&point));
}
assert_eq!(game.match_json("stone_arena")["remaining"], 1.);
assert!(
game.query(
"arena.configure",
json!({"world":"stone_arena","round_seconds":60})
)
.is_err()
);
ticks(&mut game, &mut [&mut a, &mut b, &mut c], 20);
assert_eq!(game.match_json("stone_arena")["phase"], "active");
assert_eq!(game.match_json("stone_arena")["remaining"], 2.);
let player = game.players.get_mut(&a.id).unwrap();
player.position = [0.5, 1., 2.5];
player.input.pitch = -1.2;
player.last_input = Instant::now();
let (hit, _) = game.raycast(&a.id).unwrap().unwrap();
assert_eq!(game.store.get_block("stone_arena", hit).unwrap(), stone);
game.client(&a.id, json!({"type":"break","pos":hit}))
.unwrap();
assert_eq!(game.store.get_block("stone_arena", hit).unwrap(), 0);
a.drain();
b.drain();
c.drain();
game.players.get_mut(&a.id).unwrap().position[1] = -3.;
ticks(&mut game, &mut [&mut a, &mut b, &mut c], 1);
assert!(!game.players[&a.id].alive);
assert_eq!(game.players[&a.id].position, [13., 2., 4.]);
let mut late = connect(&mut game, "stone_arena", "Late");
late.drain();
assert_eq!(game.players[&late.id].position, [13., 2., 4.]);
assert!(!game.players[&late.id].alive);
game.tick = game.matches["stone_arena"].deadline - 1;
ticks(&mut game, &mut [&mut a, &mut b, &mut c, &mut late], 1);
assert_eq!(game.match_json("stone_arena")["phase"], "finished");
assert_eq!(game.match_json("stone_arena")["winner"], Value::Null);
game.tick = game.matches["stone_arena"].deadline - 1;
ticks(&mut game, &mut [&mut a, &mut b, &mut c, &mut late], 1);
assert_eq!(game.match_json("stone_arena")["phase"], "waiting");
assert_eq!(game.store.get_block("stone_arena", hit).unwrap(), stone);
assert_eq!(game.players[&a.id].position, [0.5, 2., 3.5]);
assert!(!game.meta.active_matches.contains("stone_arena"));
drop(game);
let mut reopened = open_game(dir.path());
assert_eq!(
serde_json::to_value(reopened.config("stone_arena")).unwrap(),
configured
);
assert_eq!(
reopened.store.get_block("stone_template", hit).unwrap(),
stone
);
}
#[test]
fn gallery_contains_every_default_state_over_a_bounded_floor() {
let (_dir, mut game) = fixture();
assert_eq!(game.config("gallery").mode, "creative");
let samples = game.catalog.sampler();
assert!(samples.len() >= 1197);
for sample in samples {
assert_eq!(
game.store.get_block("gallery", sample.position).unwrap(),
game.id(&sample.state).unwrap(),
"{} sample missing",
sample.state
);
assert_ne!(
game.store
.get_block("gallery", [sample.position[0], 0, sample.position[2]])
.unwrap(),
0
);
}
assert_eq!(game.store.get_block("gallery", [64, 0, 0]).unwrap(), 0);
assert_ne!(game.store.get_block("gallery", [-64, 0, -64]).unwrap(), 0);
}
#[test]
fn material_payloads_are_bounded_and_runtime_id_lookup_works_without_registry_dump() {
let (_dir, mut game) = fixture();
let cells = game
.store
.registry()
.iter()
.enumerate()
.map(|(id, _)| BlockChange {
pos: [0, 0, 0],
block: id as u32,
})
.collect::<Vec<_>>();
let materials = game.materials(&cells);
assert!(materials.len() <= 256);
assert!(serde_json::to_vec(&materials).unwrap().len() <= 129 * 1024);
let stone = game.id("minecraft:stone").unwrap();
let result = game
.query(
"catalog.search",
json!({"ids":stone.to_string(),"limit":128}),
)
.unwrap();
assert_eq!(result["items"].as_array().unwrap().len(), 1);
assert_eq!(result["items"][0]["state"], "minecraft:stone");
}
#[test]
fn interrupted_creation_of_managed_world_can_finish_its_seed() {
let (_dir, mut game) = fixture();
// Simulate an interrupted bootstrap after the core committed create_world,
// before its first seed edit. Only reserved demonstration worlds are rebuilt.
let scratch = tempfile::tempdir().unwrap();
game.store = WorldStore::open(scratch.path(), 8).unwrap();
for state in &game.catalog.states {
game.store.register_block(&state.state).unwrap();
}
game.store.create_world("lobby", None).unwrap();
game.ids = game
.store
.registry()
.iter()
.enumerate()
.map(|(id, s)| (s.clone(), id as u32))
.collect();
game.seed().unwrap();
assert_eq!(game.store.revision("lobby").unwrap(), 1);
assert_ne!(game.store.get_block("lobby", [0, 0, 0]).unwrap(), 0);
}