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

This commit is contained in:
Emil
2026-09-14 17:35:17 +03:00
parent cf8d1aae2e
commit b6ba064339
98 changed files with 58116 additions and 194 deletions
+565
View File
@@ -0,0 +1,565 @@
//! Independent factual content catalog and authored render templates.
//!
//! Minecraft IDs are provenance, never Shacraft runtime BlockIds. Collision
//! measurements use an explicitly documented world/entity context; they do not
//! claim to reproduce entity-dependent or block-entity-dependent behavior.
use anyhow::{Context, Result, bail, ensure};
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
mod packages;
mod shapes;
pub use packages::*;
pub const MINECRAFT_VERSION: &str = "26.2";
pub const DATA_VERSION: i32 = 4903;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct Aabb {
pub min: [f32; 3],
pub max: [f32; 3],
}
impl Aabb {
pub const fn new(min: [f32; 3], max: [f32; 3]) -> Self {
Self { min, max }
}
pub fn volume(&self) -> f32 {
(0..3).map(|i| self.max[i] - self.min[i]).product()
}
pub fn valid(&self) -> bool {
(0..3).all(|i| {
self.min[i].is_finite() && self.max[i].is_finite() && self.max[i] > self.min[i]
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Coverage {
pub identity: String,
pub render: String,
pub collision: String,
pub behavior: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateDefinition {
pub state: String,
/// Target Java numerical state ID, unrelated to the persistent runtime registry.
pub minecraft_id: u32,
pub color: [u8; 3],
pub opacity: f32,
pub collision: Vec<Aabb>,
pub render: Vec<Aabb>,
pub coverage: Coverage,
pub family: String,
pub light: u8,
pub hardness: f32,
}
impl StateDefinition {
pub fn solid(&self) -> bool {
!self.collision.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockDefinition {
pub name: String,
pub default_state: String,
pub properties: BTreeMap<String, Vec<String>>,
pub family: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockSample {
pub state: String,
pub position: [i32; 3],
pub family: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityDefinition {
pub name: String,
pub width: f32,
pub height: f32,
pub color: [u8; 3],
/// Authored boxes in entity-local coordinates, relative to the feet.
pub render: Vec<Aabb>,
/// Default physical dimensions, registry flags, attributes, and model family.
/// Instance properties and unknown NBT belong to the entity persistence layer.
pub properties: BTreeMap<String, Value>,
pub coverage: Coverage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Catalog {
pub version: String,
pub data_version: i32,
pub blocks: Vec<BlockDefinition>,
pub states: Vec<StateDefinition>,
pub entities: Vec<EntityDefinition>,
#[serde(skip)]
state_index: HashMap<String, usize>,
#[serde(skip)]
entity_index: HashMap<String, usize>,
}
#[derive(Deserialize)]
struct RawCatalog {
version: String,
data_version: i32,
blocks: Vec<BlockDefinition>,
states: Vec<RawState>,
shapes: Vec<Vec<[f32; 6]>>,
entities: BTreeMap<String, BTreeMap<String, Value>>,
}
#[derive(Deserialize)]
struct RawState {
state: String,
minecraft_id: u32,
block: usize,
shape: usize,
context: bool,
light: u8,
hardness: f32,
}
fn decompress(bytes: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
GzDecoder::new(bytes)
.take(32 * 1024 * 1024)
.read_to_end(&mut out)?;
ensure!(
out.len() < 32 * 1024 * 1024,
"catalog exceeds maximum decompressed size"
);
Ok(out)
}
impl Catalog {
pub fn load_builtin() -> Result<Self> {
let raw: RawCatalog =
serde_json::from_slice(&decompress(include_bytes!("../data/catalog.json.gz"))?)?;
ensure!(
raw.version == MINECRAFT_VERSION && raw.data_version == DATA_VERSION,
"unexpected built-in catalog version"
);
let mut states = Vec::with_capacity(raw.states.len() + 1);
for s in raw.states {
let block = raw.blocks.get(s.block).context("invalid block index")?;
let collision = raw
.shapes
.get(s.shape)
.context("invalid shape index")?
.iter()
.map(|b| Aabb::new([b[0], b[1], b[2]], [b[3], b[4], b[5]]))
.collect::<Vec<_>>();
let (render, render_coverage) =
shapes::block_render(&s.state, &block.family, &collision);
let contextual = s.context
|| matches!(
block.family.as_str(),
"powder_snow" | "shulker_box" | "moving_piston" | "big_dripleaf"
);
states.push(StateDefinition {
color: shapes::color(&block.name),
opacity: shapes::opacity(&block.name),
state: s.state,
minecraft_id: s.minecraft_id,
collision,
render,
coverage: Coverage {
identity: "official-report-exact".into(),
render: render_coverage.into(),
collision: if contextual {
"approximate-context"
} else {
"measured-empty-context"
}
.into(),
behavior: "storage-and-physics".into(),
},
family: block.family.clone(),
light: s.light,
hardness: s.hardness,
});
}
let mut entities = Vec::new();
for (name, mut properties) in raw.entities {
let width = properties
.get("width")
.and_then(Value::as_f64)
.context("entity width missing")? as f32;
let height = properties
.get("height")
.and_then(Value::as_f64)
.context("entity height missing")? as f32;
let (render, family) = shapes::entity_render(&name, width, height);
properties.insert("model_family".into(), json!(family));
entities.push(EntityDefinition {
name: name.clone(),
width,
height,
color: shapes::entity_color(&name),
render,
properties,
coverage: Coverage {
identity: "official-report-exact".into(),
render: if family == "invisible" {
"intentionally-invisible"
} else {
"authored-procedural"
}
.into(),
collision: "measured-default-dimensions".into(),
behavior: "persistent-instance-no-vanilla-ai".into(),
},
});
}
let mut result = Self {
version: raw.version,
data_version: raw.data_version,
blocks: raw.blocks,
states,
entities,
state_index: HashMap::new(),
entity_index: HashMap::new(),
};
result.add_trampoline();
result.rebuild_indexes()?;
Ok(result)
}
pub fn rebuild_indexes(&mut self) -> Result<()> {
self.state_index.clear();
self.entity_index.clear();
for (i, s) in self.states.iter().enumerate() {
ensure!(
self.state_index.insert(s.state.clone(), i).is_none(),
"duplicate canonical state {}",
s.state
);
ensure!(
s.collision.iter().chain(s.render.iter()).all(Aabb::valid),
"invalid geometry for {}",
s.state
);
}
for (i, e) in self.entities.iter().enumerate() {
ensure!(
self.entity_index.insert(e.name.clone(), i).is_none(),
"duplicate entity {}",
e.name
);
ensure!(
e.render.iter().all(Aabb::valid),
"invalid entity model {}",
e.name
);
}
Ok(())
}
pub fn state(&self, canonical: &str) -> Option<&StateDefinition> {
self.state_index
.get(canonical)
.map(|i| &self.states[*i])
.or_else(|| {
self.blocks
.iter()
.find(|b| b.name == canonical)
.and_then(|b| self.state_index.get(&b.default_state))
.map(|i| &self.states[*i])
})
}
pub fn entity(&self, name: &str) -> Option<&EntityDefinition> {
self.entity_index.get(name).map(|i| &self.entities[*i])
}
pub fn default_state(&self, name: &str) -> Option<&str> {
self.blocks
.iter()
.find(|b| b.name == name)
.map(|b| b.default_state.as_str())
}
pub fn palette(&self) -> BTreeMap<String, String> {
[
("grass", "minecraft:grass_block"),
("stone", "minecraft:stone"),
("snow", "minecraft:snow_block"),
("glass", "minecraft:glass"),
("wood", "minecraft:oak_planks"),
("stairs", "minecraft:oak_stairs"),
("slab", "minecraft:oak_slab"),
("fence", "minecraft:oak_fence"),
("water", "minecraft:water"),
("sand", "minecraft:sand"),
("light", "minecraft:sea_lantern"),
("trampoline", "shacraft:trampoline"),
]
.into_iter()
.filter_map(|(key, name)| {
self.default_state(name)
.map(|s| (key.to_owned(), s.to_owned()))
})
.collect()
}
/// One default state per block, in an original 32-column inspection garden.
/// The floor is caller-owned; every sample is one cell above it.
pub fn sampler(&self) -> Vec<BlockSample> {
self.blocks
.iter()
.enumerate()
.map(|(i, b)| BlockSample {
state: b.default_state.clone(),
family: b.family.clone(),
position: [-48 + (i % 32) as i32 * 3, 1, -57 + (i / 32) as i32 * 3],
})
.collect()
}
pub fn summary(&self) -> Value {
let mut render = BTreeMap::<&str, usize>::new();
let mut collision = BTreeMap::<&str, usize>::new();
for s in &self.states {
*render.entry(&s.coverage.render).or_default() += 1;
*collision.entry(&s.coverage.collision).or_default() += 1;
}
json!({"minecraft_version":self.version,"data_version":self.data_version,
"minecraft_blocks":self.blocks.iter().filter(|b|b.name.starts_with("minecraft:")).count(),
"minecraft_states":self.states.iter().filter(|s|s.state.starts_with("minecraft:")).count(),
"entities":self.entities.len(),"total_states":self.states.len(),"render":render,"collision":collision,
"palette":self.palette(),"collision_context":"BlockPos.ZERO, air neighbors, empty entity context; contextual classes flagged separately",
"simulation":"server movement, block editing, authored modules; vanilla AI/redstone/fluid simulation are not implemented"})
}
fn add_trampoline(&mut self) {
self.blocks.push(BlockDefinition {
name: "shacraft:trampoline".into(),
default_state: "shacraft:trampoline".into(),
properties: BTreeMap::new(),
family: "trampoline".into(),
});
self.states.push(StateDefinition {
state: "shacraft:trampoline".into(),
minecraft_id: u32::MAX,
color: [97, 205, 151],
opacity: 1.,
collision: vec![Aabb::new([0., 0., 0.], [1., 1., 1.])],
render: vec![
Aabb::new([0., 0., 0.], [1., 0.18, 1.]),
Aabb::new([0.05, 0.18, 0.05], [0.95, 0.86, 0.95]),
Aabb::new([0., 0.86, 0.], [1., 1., 1.]),
],
coverage: Coverage {
identity: "authored-package".into(),
render: "authored-procedural".into(),
collision: "authored-exact".into(),
behavior: "wasm-on-jump".into(),
},
family: "trampoline".into(),
light: 0,
hardness: 1.,
});
}
}
/// Canonicalize registry state keys without discarding unknown properties.
pub fn canonical_state(name: &str, properties: &BTreeMap<String, String>) -> Result<String> {
ensure!(
!name.is_empty()
&& name.is_ascii()
&& name.contains(':')
&& !name
.bytes()
.any(|b| b.is_ascii_whitespace() || b"[]=,".contains(&b)),
"invalid resource name"
);
for (k, v) in properties {
if k.is_empty()
|| v.is_empty()
|| !k.is_ascii()
|| !v.is_ascii()
|| k.bytes()
.chain(v.bytes())
.any(|b| b.is_ascii_whitespace() || b"[]=,:".contains(&b))
{
bail!("invalid property");
}
}
let result = if properties.is_empty() {
name.to_owned()
} else {
format!(
"{}[{}]",
name,
properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",")
)
};
ensure!(result.len() <= 512, "canonical state too long");
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::sync::OnceLock;
fn catalog() -> &'static Catalog {
static C: OnceLock<Catalog> = OnceLock::new();
C.get_or_init(|| Catalog::load_builtin().unwrap())
}
#[test]
fn complete_against_independent_official_report_projection() {
let c = catalog();
let report: Value = serde_json::from_slice(
&decompress(include_bytes!("../data/report-identities.json.gz")).unwrap(),
)
.unwrap();
let blocks = report["blocks"].as_object().unwrap();
assert_eq!(blocks.len(), 1196);
assert_eq!(c.data_version, 4903);
let mut actual = HashMap::new();
for s in &c.states {
if s.state.starts_with("minecraft:") {
actual.insert(s.state.clone(), s.minecraft_id);
}
}
let mut expected = HashMap::new();
for (name, b) in blocks {
let def = c.blocks.iter().find(|b| &b.name == name).unwrap();
assert_eq!(
serde_json::to_value(&def.properties).unwrap(),
b["properties"]
);
for state in b["states"].as_array().unwrap() {
let props =
serde_json::from_value(state.get("properties").cloned().unwrap_or(json!({})))
.unwrap();
let name = canonical_state(name, &props).unwrap();
expected.insert(name.clone(), state["id"].as_u64().unwrap() as u32);
if state.get("default") == Some(&json!(true)) {
assert_eq!(def.default_state, name);
}
}
}
assert_eq!(actual.len(), 32366);
assert_eq!(actual, expected);
assert_eq!(
c.entities
.iter()
.map(|e| e.name.clone())
.collect::<HashSet<_>>(),
report["entities"]
.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap().to_owned())
.collect()
);
assert!(c.state("minecraft:potent_sulfur").is_some());
assert!(c.entity("minecraft:sulfur_cube").is_some());
}
#[test]
fn every_state_has_valid_shapes_and_explicit_coverage() {
for s in &catalog().states {
assert!(!s.coverage.render.is_empty());
assert!(!s.coverage.collision.is_empty());
for b in s.collision.iter().chain(&s.render) {
assert!(b.valid(), "{} {b:?}", s.state);
assert!(
b.min.iter().chain(&b.max).all(|v| (-2.0..=3.0).contains(v)),
"{} {b:?}",
s.state
);
}
if s.family != "air"
&& !matches!(
s.family.as_str(),
"light" | "structure_void" | "barrier" | "moving_piston"
)
{
assert!(!s.render.is_empty(), "missing {}", s.state);
}
}
}
#[test]
fn official_entity_dimensions_and_authored_models_present() {
assert_eq!(catalog().entities.len(), 158);
for e in &catalog().entities {
assert!(e.width >= 0. && e.height >= 0.);
assert!(e.properties.contains_key("attributes"));
if e.properties["model_family"] != "invisible" {
assert!(!e.render.is_empty());
}
}
assert_eq!(catalog().entity("minecraft:player").unwrap().width, 0.6);
assert_eq!(
catalog().entity("minecraft:cow").unwrap().properties["model_family"],
"quadruped"
);
}
#[test]
fn sampler_defaults_and_palette_are_registered() {
let c = catalog();
let samples = c.sampler();
assert_eq!(samples.len(), 1197);
let positions = samples.iter().map(|s| s.position).collect::<HashSet<_>>();
assert_eq!(positions.len(), samples.len());
for sample in samples {
assert!(c.state(&sample.state).is_some());
assert!(sample.position[0].abs() <= 64 && sample.position[2].abs() <= 64);
}
for state in c.palette().values() {
assert!(c.state(state).is_some());
}
}
#[test]
fn key_collision_states_and_orientation() {
let c = catalog();
let bottom = c
.state("minecraft:oak_slab[type=bottom,waterlogged=false]")
.unwrap();
let top = c
.state("minecraft:oak_slab[type=top,waterlogged=false]")
.unwrap();
assert_eq!(bottom.collision[0], Aabb::new([0., 0., 0.], [1., 0.5, 1.]));
assert_eq!(top.collision[0].min[1], 0.5);
for half in ["bottom", "top"] {
for facing in ["north", "south", "east", "west"] {
let s=c.state(&format!("minecraft:oak_stairs[facing={facing},half={half},shape=straight,waterlogged=false]")).unwrap();
assert!((s.collision.iter().map(Aabb::volume).sum::<f32>() - 0.75).abs() < 0.0001);
}
}
let door = c
.state(
"minecraft:oak_door[facing=north,half=lower,hinge=left,open=false,powered=false]",
)
.unwrap();
let open = c
.state("minecraft:oak_door[facing=north,half=lower,hinge=left,open=true,powered=false]")
.unwrap();
assert_ne!(door.collision, open.collision);
assert!(
c.state("minecraft:water[level=0]")
.unwrap()
.collision
.is_empty()
);
assert!(
c.state("minecraft:oak_fence")
.unwrap()
.collision
.iter()
.any(|b| b.max[1] == 1.5)
);
assert!(
c.state("minecraft:scaffolding")
.unwrap()
.coverage
.collision
.contains("approximate")
);
assert!(
c.state("minecraft:powder_snow")
.unwrap()
.coverage
.collision
.contains("approximate")
);
}
}
+365
View File
@@ -0,0 +1,365 @@
//! Unified packages: verified resources with an explicit public/server boundary.
use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::path::{Component, Path, PathBuf};
pub const MAX_RESOURCE_BYTES: u64 = 16 * 1024 * 1024;
pub const MAX_PACKAGE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PackageDependency {
pub id: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PackageResource {
pub path: String,
pub scope: String,
pub role: String,
pub sha256: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PackageManifest {
pub schema: u32,
pub id: String,
pub version: String,
pub license: String,
pub dependencies: Vec<PackageDependency>,
pub capabilities: Vec<String>,
pub resources: Vec<PackageResource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Package {
pub directory: PathBuf,
pub manifest: PackageManifest,
}
fn token(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
}
fn version(s: &str) -> bool {
let p = s.split('.').collect::<Vec<_>>();
p.len() == 3
&& p.iter().all(|s| {
!s.is_empty()
&& s.len() <= 5
&& (s.len() == 1 || !s.starts_with('0'))
&& s.bytes().all(|b| b.is_ascii_digit())
})
}
fn safe_path(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 256
&& s.is_ascii()
&& !s.contains('\\')
&& !s.contains(':')
&& !s.contains('%')
&& !s.bytes().any(|b| b.is_ascii_control())
&& !Path::new(s).is_absolute()
&& Path::new(s)
.components()
.all(|c| matches!(c, Component::Normal(_)))
&& s.split('/').all(|s| !s.is_empty() && s != "." && s != "..")
}
impl PackageManifest {
pub fn validate(&self) -> Result<()> {
ensure!(self.schema == 1, "unsupported package schema");
ensure!(token(&self.id), "invalid package id");
ensure!(version(&self.version), "invalid exact semantic version");
ensure!(
!self.license.is_empty()
&& self.license.len() <= 256
&& !self.license.chars().any(char::is_control),
"license required"
);
ensure!(
self.resources.len() <= 128 && !self.resources.is_empty(),
"invalid package resource count"
);
ensure!(
self.dependencies.len() <= 32 && self.capabilities.len() <= 32,
"too many dependencies or capabilities"
);
let mut paths = HashSet::new();
let mut total = 0u64;
for r in &self.resources {
ensure!(safe_path(&r.path), "unsafe resource path {}", r.path);
ensure!(paths.insert(&r.path), "duplicate resource path");
ensure!(
matches!(r.scope.as_str(), "client" | "common" | "server"),
"invalid resource scope"
);
ensure!(token(&r.role), "invalid resource role");
ensure!(
r.sha256.len() == 64
&& r.sha256
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
"invalid SHA-256"
);
ensure!(r.size <= MAX_RESOURCE_BYTES, "resource too large");
total = total.checked_add(r.size).context("package size overflow")?;
if r.role == "wasm-on-jump" {
ensure!(
r.scope == "server" && r.path.ends_with(".wasm"),
"executable jump hook must be server-only WASM"
);
}
}
ensure!(
total <= MAX_PACKAGE_BYTES,
"package exceeds total size limit"
);
let mut deps = HashSet::new();
for d in &self.dependencies {
ensure!(
token(&d.id) && version(&d.version) && d.id != self.id && deps.insert(&d.id),
"invalid or duplicate dependency"
);
}
for c in &self.capabilities {
ensure!(
matches!(
c.as_str(),
"blocks.define"
| "entities.define"
| "client.texture"
| "client.shader"
| "client.sound"
| "client.style"
| "server.on_jump"
),
"unsupported capability {c}"
);
}
ensure!(
self.resources.iter().all(|r| r.role != "wasm-on-jump"
|| self.capabilities.iter().any(|c| c == "server.on_jump")),
"WASM hook capability missing"
);
Ok(())
}
/// Clients never receive server resource names, contents, sizes, or hashes.
pub fn client_manifest(&self) -> Value {
json!({"schema":self.schema,"id":self.id,"version":self.version,"license":self.license,
"dependencies":self.dependencies,"capabilities":self.capabilities.iter().filter(|s|!s.starts_with("server.")).collect::<Vec<_>>(),
"resources":self.resources.iter().filter(|r|r.scope!="server").collect::<Vec<_>>()})
}
}
impl Package {
pub fn load(directory: impl AsRef<Path>) -> Result<Self> {
let dir = directory.as_ref();
ensure!(
!dir.symlink_metadata()?.file_type().is_symlink(),
"package directory is a symlink"
);
let directory = dir.canonicalize()?;
let path = directory.join("manifest.json");
ensure!(
!path.symlink_metadata()?.file_type().is_symlink(),
"package manifest is a symlink"
);
ensure!(path.metadata()?.len() <= 256 * 1024, "manifest too large");
let manifest: PackageManifest = serde_json::from_slice(&std::fs::read(path)?)?;
manifest.validate()?;
let package = Self {
directory,
manifest,
};
for r in &package.manifest.resources {
package.read_resource(&r.path)?;
}
Ok(package)
}
/// Re-verifies the resource at read time to reject replacement after loading.
pub fn read_resource(&self, path: &str) -> Result<Vec<u8>> {
let resource = self
.manifest
.resources
.iter()
.find(|r| r.path == path)
.context("resource is not declared")?;
ensure!(safe_path(path), "unsafe resource path");
let mut real = self.directory.clone();
for part in Path::new(path).components() {
real.push(part);
ensure!(
!real.symlink_metadata()?.file_type().is_symlink(),
"resource paths may not contain symlinks"
);
}
let file = std::fs::File::open(&real)?;
ensure!(
file.metadata()?.is_file()
&& file.metadata()?.len() == resource.size
&& resource.size <= MAX_RESOURCE_BYTES,
"resource size mismatch"
);
use std::io::Read;
let mut bytes = Vec::new();
file.take(MAX_RESOURCE_BYTES + 1).read_to_end(&mut bytes)?;
ensure!(
bytes.len() as u64 == resource.size,
"resource changed while reading"
);
ensure!(
format!("{:x}", Sha256::digest(&bytes)) == resource.sha256,
"resource hash mismatch: {path}"
);
Ok(bytes)
}
pub fn public_resource(&self, path: &str) -> Result<Vec<u8>> {
ensure!(
self.manifest
.resources
.iter()
.any(|r| r.path == path && r.scope != "server"),
"resource is not public"
);
self.read_resource(path)
}
}
/// Load and verify the entire dependency graph. Missing or incompatible required
/// packages and cycles fail before the server exposes any package to clients.
pub fn load_packages(root: impl AsRef<Path>) -> Result<Vec<Package>> {
let mut dirs = std::fs::read_dir(root)?
.map(|e| e.map(|e| e.path()))
.collect::<std::io::Result<Vec<_>>>()?;
dirs.sort();
ensure!(dirs.len() <= 128, "too many package entries");
let mut out = Vec::new();
let mut ids = BTreeMap::new();
for path in dirs {
if path.is_dir() && path.join("manifest.json").exists() {
let p = Package::load(path)?;
ensure!(
ids.insert(p.manifest.id.clone(), out.len()).is_none(),
"duplicate package id"
);
out.push(p);
}
}
for p in &out {
for d in &p.manifest.dependencies {
let i = *ids
.get(&d.id)
.with_context(|| format!("missing required package {}", d.id))?;
ensure!(
out[i].manifest.version == d.version,
"dependency version mismatch for {}",
d.id
);
}
}
fn visit(
i: usize,
p: &[Package],
ids: &BTreeMap<String, usize>,
visiting: &mut HashSet<usize>,
done: &mut HashSet<usize>,
) -> Result<()> {
if done.contains(&i) {
return Ok(());
}
if !visiting.insert(i) {
bail!("package dependency cycle")
}
for d in &p[i].manifest.dependencies {
visit(ids[&d.id], p, ids, visiting, done)?;
}
visiting.remove(&i);
done.insert(i);
Ok(())
}
let mut done = HashSet::new();
for i in 0..out.len() {
visit(i, &out, &ids, &mut HashSet::new(), &mut done)?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn package_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packages")
}
#[test]
fn bundled_packages_hashes_dependencies_and_privacy() {
let p = load_packages(package_root()).unwrap();
assert_eq!(p.len(), 2);
let t = p
.iter()
.find(|p| p.manifest.id == "shacraft.trampoline")
.unwrap();
assert!(
t.read_resource("server/trampoline.wasm")
.unwrap()
.starts_with(b"\0asm")
);
assert!(t.public_resource("server/trampoline.wasm").is_err());
let public = t.manifest.client_manifest().to_string();
assert!(!public.contains("server/"));
assert!(!public.contains("server.on_jump"));
}
#[test]
fn paths_sizes_and_versions_are_validated() {
let p = load_packages(package_root()).unwrap();
for path in [
"../secret",
"/etc/passwd",
"client/../../secret",
"client\\secret",
"client//file",
"client/./file",
"client/%2e%2e/key",
] {
let mut m = p[0].manifest.clone();
m.resources[0].path = path.into();
assert!(m.validate().is_err(), "{path}");
}
let mut m = p[0].manifest.clone();
m.resources[0].size = MAX_RESOURCE_BYTES + 1;
assert!(m.validate().is_err());
m = p[0].manifest.clone();
m.version = "latest".into();
assert!(m.validate().is_err());
}
#[test]
fn changed_resource_rejected() {
let dir = tempfile::tempdir().unwrap();
let bytes = b"own content";
std::fs::write(dir.path().join("asset"), bytes).unwrap();
let m = PackageManifest {
schema: 1,
id: "test.sample".into(),
version: "1.0.0".into(),
license: "MIT".into(),
dependencies: vec![],
capabilities: vec![],
resources: vec![PackageResource {
path: "asset".into(),
scope: "client".into(),
role: "texture".into(),
sha256: format!("{:x}", Sha256::digest(bytes)),
size: bytes.len() as u64,
}],
};
std::fs::write(
dir.path().join("manifest.json"),
serde_json::to_vec(&m).unwrap(),
)
.unwrap();
let p = Package::load(dir.path()).unwrap();
std::fs::write(dir.path().join("asset"), b"bad content").unwrap();
assert!(p.read_resource("asset").is_err());
}
}
+828
View File
@@ -0,0 +1,828 @@
//! Shacraft's original low-poly templates; no Minecraft visual models/assets.
use crate::Aabb;
use std::collections::BTreeMap;
fn bx(a: [f32; 3], b: [f32; 3]) -> Aabb {
Aabb::new(a, b)
}
fn props(state: &str) -> BTreeMap<&str, &str> {
state
.split_once('[')
.map(|(_, tail)| {
tail.trim_end_matches(']')
.split(',')
.filter_map(|p| p.split_once('='))
.collect()
})
.unwrap_or_default()
}
fn get<'a>(p: &BTreeMap<&'a str, &'a str>, key: &str, default: &'a str) -> &'a str {
p.get(key).copied().unwrap_or(default)
}
fn num(p: &BTreeMap<&str, &str>, key: &str, default: f32) -> f32 {
p.get(key).and_then(|s| s.parse().ok()).unwrap_or(default)
}
fn rotate(boxes: Vec<Aabb>, facing: &str) -> Vec<Aabb> {
let steps = match facing {
"east" => 1,
"south" => 2,
"west" => 3,
_ => 0,
};
boxes
.into_iter()
.map(|mut b| {
for _ in 0..steps {
b = bx(
[1. - b.max[2], b.min[1], b.min[0]],
[1. - b.min[2], b.max[1], b.max[0]],
)
}
b
})
.collect()
}
const FULL: Aabb = Aabb::new([0., 0., 0.], [1., 1., 1.]);
pub(crate) fn block_render(
state: &str,
family: &str,
collision: &[Aabb],
) -> (Vec<Aabb>, &'static str) {
let p = props(state);
let name = state.split('[').next().unwrap_or(state);
let facing = get(&p, "facing", "north");
let b = match family {
"air" | "barrier" | "light" | "structure_void" | "moving_piston" => {
return (vec![], "intentionally-invisible");
}
"liquid" | "bubble_column" => vec![bx(
[0., 0., 0.],
[
1.,
if num(&p, "level", 0.) >= 8. {
1.
} else {
(8. - num(&p, "level", 0.)).max(1.) / 9.
},
1.,
],
)],
"button" | "lever" => {
let pressed = get(&p, "powered", "false") == "true";
let depth = if pressed { 0.0625 } else { 0.125 };
let face = get(&p, "face", "wall");
let mut b = vec![match face {
"floor" => bx([0.3125, 0., 0.375], [0.6875, depth, 0.625]),
"ceiling" => bx([0.3125, 1. - depth, 0.375], [0.6875, 1., 0.625]),
_ => bx([0.3125, 0.375, 1. - depth], [0.6875, 0.625, 1.]),
}];
if family == "lever" {
b.push(match face {
"floor" => bx(
[0.46875, 0.0625, if pressed { 0.55 } else { 0.25 }],
[0.53125, 0.375, if pressed { 0.75 } else { 0.45 }],
),
"ceiling" => bx(
[0.46875, 0.625, if pressed { 0.55 } else { 0.25 }],
[0.53125, 0.9375, if pressed { 0.75 } else { 0.45 }],
),
_ => bx(
[0.46875, if pressed { 0.25 } else { 0.55 }, 0.625],
[0.53125, if pressed { 0.45 } else { 0.75 }, 0.9375],
),
});
}
rotate(b, facing)
}
"pressure_plate" | "weighted_pressure_plate" => vec![bx(
[0.0625, 0., 0.0625],
[
0.9375,
if get(&p, "powered", "false") == "true" || num(&p, "power", 0.) > 0. {
0.03125
} else {
0.0625
},
0.9375,
],
)],
"trip_wire_hook" => rotate(
vec![
bx([0.375, 0.25, 0.875], [0.625, 0.75, 1.]),
bx([0.46875, 0.4375, 0.5], [0.53125, 0.5, 0.9375]),
],
facing,
),
"snow_layer" => vec![bx([0., 0., 0.], [1., num(&p, "layers", 1.) / 8., 1.])],
"mossy_carpet" => {
let mut b = vec![bx([0., 0., 0.], [1., 0.0625, 1.])];
for (dir, a, z) in [
("north", [0., 0., 0.], [1., 0., 0.0625]),
("south", [0., 0., 0.9375], [1., 0., 1.]),
("west", [0., 0., 0.], [0.0625, 0., 1.]),
("east", [0.9375, 0., 0.], [1., 0., 1.]),
] {
let v = get(&p, dir, "none");
if v != "none" {
b.push(bx(a, [z[0], if v == "tall" { 1. } else { 0.5 }, z[2]]));
}
}
b
}
"big_dripleaf" | "big_dripleaf_stem" => {
let mut b = vec![bx([0.4375, 0., 0.4375], [0.5625, 0.875, 0.5625])];
if family == "big_dripleaf" {
let tilt = get(&p, "tilt", "none");
b.push(if tilt == "full" {
bx([0.0625, 0.375, 0.75], [0.9375, 0.875, 0.8125])
} else {
bx(
[
0.0625,
if tilt == "partial" { 0.625 } else { 0.8125 },
0.0625,
],
[0.9375, 0.875, 0.9375],
)
});
}
rotate(b, facing)
}
"fence" => {
let mut b = vec![bx([0.375, 0., 0.375], [0.625, 1., 0.625])];
for (dir, a, z) in [
("north", [0.4375, 0., 0.], [0.5625, 0., 0.5]),
("south", [0.4375, 0., 0.5], [0.5625, 0., 1.]),
("west", [0., 0., 0.4375], [0.5, 0., 0.5625]),
("east", [0.5, 0., 0.4375], [1., 0., 0.5625]),
] {
if get(&p, dir, "false") == "true" {
for y in [0.375, 0.75] {
b.push(bx([a[0], y, a[2]], [z[0], y + 0.1875, z[2]]));
}
}
}
b
}
"wall" => {
let mut b = vec![];
if get(&p, "up", "true") == "true" {
b.push(bx([0.25, 0., 0.25], [0.75, 1., 0.75]));
}
for (dir, a, z) in [
("north", [0.3125, 0., 0.], [0.6875, 0., 0.5]),
("south", [0.3125, 0., 0.5], [0.6875, 0., 1.]),
("west", [0., 0., 0.3125], [0.5, 0., 0.6875]),
("east", [0.5, 0., 0.3125], [1., 0., 0.6875]),
] {
let value = get(&p, dir, "none");
if value != "none" {
b.push(bx(
a,
[z[0], if value == "tall" { 1. } else { 0.8125 }, z[2]],
));
}
}
if b.is_empty() {
b.push(bx([0.25, 0., 0.25], [0.75, 1., 0.75]));
}
b
}
"fence_gate" => {
let mut b = vec![
bx([0., 0.3125, 0.375], [0.125, 1., 0.625]),
bx([0.875, 0.3125, 0.375], [1., 1., 0.625]),
];
if get(&p, "open", "false") == "true" {
b.push(bx([0., 0.375, 0.5], [0.1875, 0.875, 1.]));
b.push(bx([0.8125, 0.375, 0.5], [1., 0.875, 1.]));
} else {
for y in [0.375, 0.75] {
b.push(bx([0.125, y, 0.4375], [0.875, y + 0.125, 0.5625]));
}
}
rotate(b, facing)
}
"rail" | "powered_rail" | "detector_rail" => {
let s = get(&p, "shape", "north_south");
let mut b = vec![];
if s.starts_with("ascending_") {
for i in 0..8 {
let z = i as f32 / 8.;
for x in [0.1875, 0.75] {
b.push(bx([x, 0.875 - z, z], [x + 0.0625, 0.9375 - z, z + 0.125]));
}
}
rotate(b, s.trim_start_matches("ascending_"))
} else {
for x in [0.1875, 0.75] {
b.push(bx([x, 0.0625, 0.], [x + 0.0625, 0.125, 1.]));
}
for z in [0.125, 0.5, 0.875] {
b.push(bx([0.125, 0., z], [0.875, 0.0625, (z + 0.0625).min(1.)]));
}
// Curved tracks use a visible orthogonal corner in this box renderer.
if s == "east_west" {
rotate(b, "east")
} else if s.contains('_') && s != "north_south" {
let mut c = vec![
bx([0.1875, 0.06, 0.5], [1., 0.125, 0.5625]),
bx([0.1875, 0.06, 0.], [0.25, 0.125, 0.56]),
bx([0.75, 0.06, 0.], [0.8125, 0.125, 0.94]),
bx([0.75, 0.06, 0.875], [1., 0.125, 0.9375]),
];
c = rotate(
c,
match s {
"south_east" => "east",
"south_west" => "south",
"north_west" => "west",
_ => "north",
},
);
c
} else {
b
}
}
}
"ladder" => rotate(
vec![
bx([0.125, 0., 0.9375], [0.25, 1., 1.]),
bx([0.75, 0., 0.9375], [0.875, 1., 1.]),
bx([0.125, 0.1875, 0.875], [0.875, 0.25, 1.]),
bx([0.125, 0.4375, 0.875], [0.875, 0.5, 1.]),
bx([0.125, 0.6875, 0.875], [0.875, 0.75, 1.]),
bx([0.125, 0.9375, 0.875], [0.875, 1., 1.]),
],
facing,
),
"standing_sign" | "banner" => rotate(
vec![
bx([0.46875, 0., 0.46875], [0.53125, 0.5, 0.53125]),
bx([0.0625, 0.5, 0.46875], [0.9375, 0.9375, 0.53125]),
],
rotation_facing(num(&p, "rotation", 0.)),
),
"wall_sign" | "wall_banner" => rotate(
vec![bx([0.0625, 0.25, 0.9375], [0.9375, 0.8125, 1.])],
facing,
),
"ceiling_hanging_sign" | "wall_hanging_sign" => rotate(
vec![
bx([0.0625, 0.125, 0.4375], [0.9375, 0.6875, 0.5625]),
bx([0.1875, 0.6875, 0.46875], [0.25, 1., 0.53125]),
bx([0.75, 0.6875, 0.46875], [0.8125, 1., 0.53125]),
],
facing,
),
"torch" | "redstone_torch" => vec![
bx([0.4375, 0., 0.4375], [0.5625, 0.625, 0.5625]),
bx([0.375, 0.5, 0.375], [0.625, 0.75, 0.625]),
],
"wall_torch" | "redstone_wall_torch" => rotate(
vec![
bx([0.4375, 0.125, 0.6875], [0.5625, 0.5, 0.875]),
bx([0.375, 0.4375, 0.625], [0.625, 0.6875, 0.875]),
],
facing,
),
"redstone_wire" | "tripwire" => {
let mut b = vec![bx([0.3125, 0.01, 0.3125], [0.6875, 0.04, 0.6875])];
for (dir, a, z) in [
("north", [0.4375, 0.01, 0.], [0.5625, 0.04, 0.5]),
("south", [0.4375, 0.01, 0.5], [0.5625, 0.04, 1.]),
("west", [0., 0.01, 0.4375], [0.5, 0.04, 0.5625]),
("east", [0.5, 0.01, 0.4375], [1., 0.04, 0.5625]),
] {
let v = get(&p, dir, "none");
if v != "none" && v != "false" {
b.push(bx(a, z));
}
}
b
}
"nether_portal" => {
if get(&p, "axis", "x") == "x" {
vec![bx([0., 0., 0.4375], [1., 1., 0.5625])]
} else {
vec![bx([0.4375, 0., 0.], [0.5625, 1., 1.])]
}
}
"end_portal" | "end_gateway" => vec![bx([0., 0.125, 0.], [1., 0.1875, 1.])],
"fire" | "soul_fire" => vec![
bx([0.125, 0., 0.46875], [0.875, 0.875, 0.53125]),
bx([0.46875, 0., 0.125], [0.53125, 0.75, 0.875]),
],
"vine" | "glow_lichen" | "sculk_vein" | "multiface" => {
let mut b = vec![];
for (dir, a, z) in [
("north", [0., 0., 0.], [1., 1., 0.03125]),
("south", [0., 0., 0.96875], [1., 1., 1.]),
("west", [0., 0., 0.], [0.03125, 1., 1.]),
("east", [0.96875, 0., 0.], [1., 1., 1.]),
("up", [0., 0.96875, 0.], [1., 1., 1.]),
("down", [0., 0., 0.], [1., 0.03125, 1.]),
] {
if get(&p, dir, "false") == "true" {
b.push(bx(a, z));
}
}
if b.is_empty() {
b.push(bx([0., 0., 0.], [1., 1., 0.03125]));
}
b
}
"flower_pot" => {
let mut b = vec![bx([0.3125, 0., 0.3125], [0.6875, 0.375, 0.6875])];
if name != "minecraft:flower_pot" {
b.extend(plant(0.8));
}
b
}
"crop" | "beetroot" | "potato" | "carrot" | "torchflower_crop" | "pitcher_crop"
| "nether_wart" => plant(0.2 + num(&p, "age", 0.).min(7.) / 10.),
"flower"
| "wither_rose"
| "eyeblossom"
| "tall_flower"
| "double_plant"
| "sapling"
| "bush"
| "tall_grass"
| "short_dry_grass"
| "tall_dry_grass"
| "dry_vegetation"
| "sugar_cane"
| "mushroom"
| "nether_fungus"
| "nether_roots"
| "nether_sprouts"
| "hanging_roots"
| "hanging_moss"
| "firefly_bush"
| "sweet_berry_bush"
| "mangrove_propagule"
| "bamboo_sapling"
| "stem"
| "attached_stem"
| "seagrass"
| "tall_seagrass"
| "kelp"
| "kelp_plant"
| "twisting_vines"
| "twisting_vines_plant"
| "weeping_vines"
| "weeping_vines_plant"
| "cave_vines"
| "cave_vines_plant"
| "small_dripleaf"
| "spore_blossom"
| "cactus_flower" => plant(if family == "mushroom" { 0.45 } else { 0.85 }),
"leaf_litter" | "flower_bed" | "frogspawn" => {
vec![bx([0.0625, 0.015, 0.0625], [0.9375, 0.0625, 0.9375])]
}
"coral"
| "coral_plant"
| "base_coral_plant"
| "coral_fan"
| "base_coral_fan"
| "coral_wall_fan"
| "base_coral_wall_fan" => plant(0.625),
"web" => vec![
bx([0., 0., 0.46875], [1., 1., 0.53125]),
bx([0.46875, 0., 0.], [0.53125, 1., 1.]),
],
"powder_snow" => vec![FULL],
_ => {
if !collision.is_empty() {
collision.to_vec()
} else {
// Noncollidable interaction geometry authored from the family, no
// silent air substitution. Rare unsupported visual details remain
// explicitly approximate in the coverage field.
return (
vec![bx([0.125, 0., 0.125], [0.875, 0.5, 0.875])],
"authored-approximate",
);
}
}
};
(b, "authored-procedural")
}
fn rotation_facing(v: f32) -> &'static str {
match ((v + 2.) as i32 / 4).rem_euclid(4) {
0 => "south",
1 => "west",
2 => "north",
_ => "east",
}
}
fn plant(height: f32) -> Vec<Aabb> {
vec![
bx([0.46875, 0., 0.46875], [0.53125, height, 0.53125]),
bx(
[0.1875, height * 0.35, 0.46875],
[0.8125, height * 0.75, 0.53125],
),
bx(
[0.46875, height * 0.25, 0.1875],
[0.53125, height * 0.65, 0.8125],
),
]
}
pub(crate) fn color(name: &str) -> [u8; 3] {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let colors = [
("light_blue", [108, 169, 193]),
("light_gray", [170, 173, 163]),
("magenta", [175, 97, 153]),
("orange", [207, 136, 66]),
("yellow", [220, 191, 80]),
("purple", [130, 96, 164]),
("cyan", [61, 155, 156]),
("blue", [74, 111, 181]),
("brown", [119, 88, 65]),
("green", [91, 132, 75]),
("lime", [148, 185, 87]),
("pink", [216, 153, 165]),
("red", [179, 78, 64]),
("black", [54, 59, 62]),
("gray", [104, 113, 112]),
("white", [215, 219, 207]),
];
for (prefix, c) in colors {
if name.starts_with(&format!("{prefix}_")) {
return c;
}
}
if name.contains("sulfur") {
return [220, 210, 78];
}
if name.contains("cinnabar") {
return [185, 78, 63];
}
if name.contains("snow") || name.contains("quartz") || name.contains("calcite") {
return [217, 229, 228];
}
if name.contains("water") {
return [69, 133, 182];
}
if name.contains("lava") {
return [226, 123, 49];
}
if name.contains("glass") || name.contains("ice") {
return [139, 190, 203];
}
if name.contains("grass") || name.contains("leaves") || name.contains("moss") {
return [109, 148, 82];
}
if name.contains("cherry") {
return [191, 141, 142];
}
if name.contains("pale_oak") {
return [194, 192, 169];
}
if name.contains("crimson") {
return [132, 63, 82];
}
if name.contains("warped") {
return [58, 139, 135];
}
if name.contains("dark_oak") || name.contains("spruce") {
return [106, 85, 60];
}
if name.contains("birch") || name.contains("bamboo") {
return [190, 179, 121];
}
if name.contains("acacia") || name.contains("mangrove") {
return [159, 102, 71];
}
if name.contains("oak")
|| name.contains("jungle")
|| name.contains("wood")
|| name.contains("chest")
{
return [163, 134, 91];
}
if name.contains("copper") {
return if name.contains("oxidized") {
[79, 155, 131]
} else {
[179, 115, 83]
};
}
if name.contains("gold") {
return [210, 181, 81];
}
if name.contains("diamond") {
return [91, 189, 191];
}
if name.contains("emerald") {
return [67, 161, 112];
}
if name.contains("redstone") {
return [162, 70, 57];
}
if name.contains("sand") || name.contains("end_stone") {
return [195, 184, 144];
}
if name.contains("dirt") || name.contains("mud") {
return [131, 108, 83];
}
if name.contains("deepslate") || name.contains("blackstone") || name.contains("obsidian") {
return [69, 72, 80];
}
if name.contains("stone") || name.contains("andesite") || name.contains("cobble") {
return [141, 150, 146];
}
let hash = name
.bytes()
.fold(2166136261u32, |h, b| (h ^ b as u32).wrapping_mul(16777619));
[
100 + (hash % 82) as u8,
100 + ((hash >> 8) % 82) as u8,
100 + ((hash >> 16) % 82) as u8,
]
}
pub(crate) fn opacity(name: &str) -> f32 {
if name.contains("glass") {
0.42
} else if name.contains("water") || name.contains("ice") {
0.68
} else {
1.
}
}
pub(crate) fn entity_color(name: &str) -> [u8; 3] {
match name.strip_prefix("minecraft:").unwrap_or(name) {
"player" | "mannequin" => [78, 154, 169],
"cow" | "mooshroom" => [132, 108, 90],
"pig" => [209, 150, 155],
"sheep" => [219, 216, 196],
"creeper" | "zombie" | "zombie_villager" => [110, 152, 98],
"skeleton" | "stray" | "parched" => [201, 202, 179],
"enderman" | "ender_dragon" => [66, 60, 83],
"sulfur_cube" => [213, 209, 75],
"slime" => [129, 188, 103],
"magma_cube" => [149, 76, 44],
"allay" => [96, 199, 212],
"bee" => [213, 182, 72],
"fox" => [191, 123, 71],
"wolf" => [169, 177, 166],
_ => color(name),
}
}
pub(crate) fn entity_render(name: &str, width: f32, height: f32) -> (Vec<Aabb>, &'static str) {
let n = name.strip_prefix("minecraft:").unwrap_or(name);
let family = if n.ends_with("_boat") || n.ends_with("_raft") {
"boat"
} else if n.ends_with("minecart") {
"cart"
} else {
match n {
"marker" | "interaction" | "area_effect_cloud" | "lightning_bolt" => "invisible",
"cow" | "mooshroom" | "pig" | "sheep" | "wolf" | "cat" | "ocelot" | "fox" | "panda"
| "polar_bear" | "goat" | "horse" | "skeleton_horse" | "zombie_horse" | "donkey"
| "mule" | "llama" | "trader_llama" | "camel" | "camel_husk" | "hoglin" | "zoglin"
| "ravager" | "sniffer" | "armadillo" => "quadruped",
"creeper" => "creeper",
"spider" | "cave_spider" | "silverfish" | "endermite" => "arthropod",
"cod" | "salmon" | "tropical_fish" | "pufferfish" | "dolphin" | "tadpole"
| "axolotl" | "guardian" | "elder_guardian" | "nautilus" | "zombie_nautilus" => {
"aquatic"
}
"squid" | "glow_squid" => "squid",
"ghast" | "happy_ghast" => "ghast",
"slime" | "magma_cube" | "sulfur_cube" | "shulker" => "cube-creature",
"bat" | "bee" | "allay" | "vex" | "parrot" | "phantom" => "winged",
"chicken" => "bird",
"rabbit" => "rabbit",
"frog" | "turtle" => "low-quadruped",
"ender_dragon" => "dragon",
"wither" => "wither",
"snow_golem" => "snow-golem",
"blaze" | "breeze" => "elemental",
"player" | "mannequin" | "zombie" | "zombie_villager" | "drowned" | "husk"
| "skeleton" | "stray" | "parched" | "bogged" | "wither_skeleton"
| "zombified_piglin" | "piglin" | "piglin_brute" | "villager" | "wandering_trader"
| "pillager" | "vindicator" | "evoker" | "illusioner" | "witch" | "enderman"
| "iron_golem" | "copper_golem" | "creaking" | "warden" | "giant" | "armor_stand"
| "strider" => "biped",
"painting" | "item_frame" | "glow_item_frame" => "frame",
"block_display" | "item_display" | "text_display" => "property-dependent-display",
"arrow" | "spectral_arrow" | "trident" | "firework_rocket" => "bolt",
"falling_block" | "tnt" => "block",
"end_crystal" | "ominous_item_spawner" => "crystal",
"evoker_fangs" => "fangs",
"item" | "leash_knot" | "fishing_bobber" | "egg" | "ender_pearl"
| "experience_bottle" | "experience_orb" | "eye_of_ender" | "fireball"
| "small_fireball" | "dragon_fireball" | "snowball" | "splash_potion"
| "lingering_potion" | "llama_spit" | "shulker_bullet" | "wind_charge"
| "breeze_wind_charge" | "wither_skull" => "small-object",
_ => "small-object",
}
};
if family == "invisible" {
return (vec![], family);
}
let mut b = match family {
"biped" => vec![
bx([-0.3, 0.38, -0.18], [0.3, 0.77, 0.18]),
bx([-0.23, 0.77, -0.23], [0.23, 1., 0.23]),
bx([-0.27, 0., -0.16], [-0.04, 0.4, 0.16]),
bx([0.04, 0., -0.16], [0.27, 0.4, 0.16]),
bx([-0.5, 0.4, -0.14], [-0.31, 0.75, 0.14]),
bx([0.31, 0.4, -0.14], [0.5, 0.75, 0.14]),
],
"quadruped" | "low-quadruped" => {
let mut b = vec![
bx([-0.4, 0.3, -0.35], [0.4, 0.8, 0.35]),
bx([-0.3, 0.58, -0.5], [0.3, 0.98, -0.2]),
];
for x in [-0.35, 0.15] {
for z in [-0.3, 0.18] {
b.push(bx([x, 0., z], [x + 0.2, 0.4, z + 0.15]));
}
}
b
}
"creeper" => vec![
bx([-0.27, 0.22, -0.2], [0.27, 0.73, 0.2]),
bx([-0.38, 0.7, -0.38], [0.38, 1., 0.38]),
bx([-0.38, 0., -0.38], [-0.02, 0.25, -0.06]),
bx([0.02, 0., -0.38], [0.38, 0.25, -0.06]),
bx([-0.38, 0., 0.06], [-0.02, 0.25, 0.38]),
bx([0.02, 0., 0.06], [0.38, 0.25, 0.38]),
],
"arthropod" => {
let mut b = vec![
bx([-0.27, 0.3, -0.35], [0.27, 0.7, 0.35]),
bx([-0.2, 0.32, -0.5], [0.2, 0.62, -0.25]),
];
for z in [-0.32, -0.12, 0.12, 0.32] {
b.push(bx([-0.5, 0.1, z], [-0.2, 0.35, z + 0.07]));
b.push(bx([0.2, 0.1, z], [0.5, 0.35, z + 0.07]));
}
b
}
"aquatic" => vec![
bx([-0.24, 0.25, -0.45], [0.24, 0.7, 0.25]),
bx([-0.4, 0.3, 0.2], [0.4, 0.62, 0.5]),
bx([-0.03, 0.7, -0.15], [0.03, 1., 0.1]),
bx([-0.45, 0.25, -0.2], [0.45, 0.35, 0.1]),
],
"squid" | "ghast" => {
let mut b = vec![bx([-0.4, 0.4, -0.4], [0.4, 1., 0.4])];
for x in [-0.3, 0., 0.3] {
for z in [-0.3, 0., 0.3] {
b.push(bx([x - 0.04, 0., z - 0.04], [x + 0.04, 0.45, z + 0.04]));
}
}
b
}
"cube-creature" => vec![
bx([-0.5, 0., -0.5], [0.5, 1., 0.5]),
bx([-0.3, 0.55, -0.52], [-0.12, 0.72, -0.49]),
bx([0.12, 0.55, -0.52], [0.3, 0.72, -0.49]),
],
"winged" => vec![
bx([-0.19, 0.2, -0.3], [0.19, 0.8, 0.3]),
bx([-0.23, 0.65, -0.4], [0.23, 1., -0.05]),
bx([-0.5, 0.45, -0.1], [-0.15, 0.6, 0.3]),
bx([0.15, 0.45, -0.1], [0.5, 0.6, 0.3]),
],
"bird" => vec![
bx([-0.34, 0.25, -0.25], [0.34, 0.7, 0.25]),
bx([-0.23, 0.65, -0.35], [0.23, 1., -0.02]),
bx([-0.12, 0.73, -0.5], [0.12, 0.82, -0.3]),
bx([-0.22, 0., -0.03], [-0.08, 0.3, 0.06]),
bx([0.08, 0., -0.03], [0.22, 0.3, 0.06]),
],
"rabbit" => vec![
bx([-0.3, 0.12, -0.25], [0.3, 0.6, 0.3]),
bx([-0.22, 0.5, -0.4], [0.22, 0.78, -0.05]),
bx([-0.2, 0.7, -0.25], [-0.08, 1., -0.1]),
bx([0.08, 0.7, -0.25], [0.2, 1., -0.1]),
bx([-0.4, 0., 0.05], [-0.1, 0.2, 0.4]),
bx([0.1, 0., 0.05], [0.4, 0.2, 0.4]),
],
"dragon" => vec![
bx([-0.15, 0.3, -0.3], [0.15, 0.7, 0.3]),
bx([-0.1, 0.4, -0.5], [0.1, 0.65, -0.2]),
bx([-0.5, 0.48, -0.1], [-0.12, 0.56, 0.3]),
bx([0.12, 0.48, -0.1], [0.5, 0.56, 0.3]),
bx([-0.055, 0.35, 0.25], [0.055, 0.45, 0.5]),
],
"wither" => vec![
bx([-0.1, 0.05, -0.15], [0.1, 0.75, 0.15]),
bx([-0.4, 0.65, -0.12], [0.4, 0.75, 0.12]),
bx([-0.17, 0.75, -0.2], [0.17, 1., 0.2]),
bx([-0.5, 0.65, -0.18], [-0.25, 0.9, 0.18]),
bx([0.25, 0.65, -0.18], [0.5, 0.9, 0.18]),
],
"snow-golem" => vec![
bx([-0.45, 0., -0.45], [0.45, 0.4, 0.45]),
bx([-0.35, 0.4, -0.35], [0.35, 0.75, 0.35]),
bx([-0.25, 0.75, -0.25], [0.25, 1., 0.25]),
bx([-0.5, 0.5, -0.04], [0.5, 0.6, 0.04]),
],
"elemental" => {
let mut b = vec![bx([-0.3, 0.7, -0.3], [0.3, 1., 0.3])];
for (x, z) in [(-0.4, -0.3), (0.3, -0.3), (-0.4, 0.3), (0.3, 0.3)] {
b.push(bx([x, 0.15, z], [x + 0.1, 0.65, z + 0.1]));
}
b
}
"boat" => vec![
bx([-0.5, 0., -0.5], [0.5, 0.2, 0.5]),
bx([-0.5, 0.2, -0.5], [-0.36, 0.7, 0.5]),
bx([0.36, 0.2, -0.5], [0.5, 0.7, 0.5]),
bx([-0.36, 0.2, -0.5], [0.36, 0.7, -0.38]),
bx([-0.36, 0.2, 0.38], [0.36, 0.7, 0.5]),
bx([-0.36, 0.32, -0.08], [0.36, 0.42, 0.08]),
],
"cart" => vec![
bx([-0.5, 0.1, -0.5], [0.5, 0.3, 0.5]),
bx([-0.5, 0.3, -0.5], [-0.38, 0.85, 0.5]),
bx([0.38, 0.3, -0.5], [0.5, 0.85, 0.5]),
bx([-0.38, 0.3, -0.5], [0.38, 0.85, -0.38]),
bx([-0.38, 0.3, 0.38], [0.38, 0.85, 0.5]),
],
"frame" | "property-dependent-display" => vec![bx([-0.5, 0., -0.05], [0.5, 1., 0.05])],
"bolt" => vec![
bx([-0.04, 0.45, -0.5], [0.04, 0.55, 0.5]),
bx([-0.18, 0.43, -0.5], [0.18, 0.57, -0.3]),
],
"block" => vec![bx([-0.5, 0., -0.5], [0.5, 1., 0.5])],
"crystal" => vec![
bx([-0.3, 0.25, -0.3], [0.3, 0.75, 0.3]),
bx([-0.15, 0.1, -0.15], [0.15, 0.9, 0.15]),
bx([-0.45, 0., -0.45], [0.45, 0.08, 0.45]),
],
"fangs" => vec![
bx([-0.35, 0., -0.3], [-0.1, 1., 0.3]),
bx([0.1, 0., -0.3], [0.35, 1., 0.3]),
],
_ => vec![
bx([-0.3, 0.2, -0.4], [0.3, 0.8, 0.4]),
bx([-0.4, 0.3, -0.25], [0.4, 0.7, 0.25]),
],
};
if family == "boat" && n.contains("chest") {
b.push(bx([-0.25, 0.4, 0.08], [0.25, 0.95, 0.42]));
}
if family == "cart" && n != "minecart" {
b.push(bx([-0.3, 0.3, -0.3], [0.3, 1., 0.3]));
}
let w = width.max(0.25);
let h = height.max(0.25);
for v in &mut b {
for i in 0..3 {
v.min[i] *= if i == 1 { h } else { w };
v.max[i] *= if i == 1 { h } else { w };
}
}
(b, family)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn four_quarter_rotations_restore_box() {
let initial = vec![bx([0.125, 0.25, 0.375], [0.25, 0.5, 0.75])];
let mut b = initial.clone();
for _ in 0..4 {
b = rotate(b, "east");
}
assert_eq!(b, initial);
}
#[test]
fn connected_fence_has_two_added_rails() {
let (a, _) = block_render(
"minecraft:oak_fence[east=false,north=false,south=false,waterlogged=false,west=false]",
"fence",
&[],
);
let (b, _) = block_render(
"minecraft:oak_fence[east=true,north=false,south=false,waterlogged=false,west=false]",
"fence",
&[],
);
assert_eq!(b.len(), a.len() + 2);
assert!(b.iter().all(|b| b.max[1] <= 1.));
}
#[test]
fn rail_ascent_reaches_next_block() {
let (b, _) = block_render(
"minecraft:rail[shape=ascending_north,waterlogged=false]",
"rail",
&[],
);
assert_eq!(b.len(), 16);
assert!(b.iter().any(|b| b.max[1] > 0.9));
assert!(b.iter().all(Aabb::valid));
}
}