Complete local voxel MVP with server, client, MCP, packages and world interchange
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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 ¤t {
|
||||
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, ¤t)?;
|
||||
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 ¤t {
|
||||
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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
Binary file not shown.
@@ -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(())
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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 = ¶ms["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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user