Skip to main content

vintage_schematics/convert/
block.rs

1//! Block conversion logic.
2
3use std::{
4	borrow::Cow,
5	sync::{LazyLock, Mutex},
6};
7
8use tracing::{trace, warn};
9
10use crate::{
11	Map, Regex, Set,
12	convert::{
13		determine_orientation, material,
14		material::{convert_wood, is_stone, is_tree},
15		mods::Mod,
16		orientation_to_radians, reverse_orientation,
17	},
18	entity::Value,
19	formats::{
20		Settings,
21		internal::{EntityMap, MinecraftBlockCode, PropertyMap, PropertyMapExt},
22	},
23};
24
25// The maximum fluid depth level in Vintage Story.
26const MAX_FLUID: u8 = 7;
27
28/// The maximum snow layers in Minecraft.
29const MAX_SNOW: u8 = 8;
30
31/// The number of inventory slots in a Vintage Story chest.
32pub(crate) const VS_CHEST_SLOTS: i32 = 16;
33
34/// The number of inventory slots in a Vintage Story trunk.
35pub(crate) const VS_TRUNK_SLOTS: i32 = 36;
36
37pub(crate) const VS_FLOWER_POT_CODE: &str = "game:flowerpot-red-fired";
38
39/// Crop conversion data.
40struct CropData {
41	/// This crop's Vintage Story name.
42	name: &'static str,
43
44	/// The maximum age of this crop in Minecraft.
45	mc_max_age: u8,
46
47	/// The maximum age of this crop in Vintage Story.
48	vs_max_age: u8,
49}
50
51/// Mapping of Minecraft crop names to [`CropData`] structs.
52static CROPS: LazyLock<Map<&'static str, CropData>> = LazyLock::new(|| {
53	// TODO: all the other crops!
54
55	Map::from([
56		(
57			"carrots",
58			CropData {
59				name: "carrot",
60				mc_max_age: 7,
61				vs_max_age: 7,
62			},
63		),
64		(
65			"potatoes",
66			CropData {
67				name: "parsnip",
68				mc_max_age: 7,
69				vs_max_age: 8,
70			},
71		),
72		(
73			"wheat",
74			CropData {
75				name: "spelt",
76				mc_max_age: 7,
77				vs_max_age: 9,
78			},
79		),
80		(
81			"beetroots",
82			CropData {
83				name: "onion",
84				mc_max_age: 3,
85				vs_max_age: 7,
86			},
87		),
88	])
89});
90
91/// Regex pattern for Minecraft wood blocks and tree foliage (leaves, saplings, etc.).
92/// Used in [`convert_wood_block`].
93static WOOD_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
94	// (?x) flag enables insignificant whitespace and comments
95	// https://docs.rs/regex/1/regex/#example-verbose-mode
96	Regex::new(
97		r#"(?x)
98		# matches block codes like "oak_planks" and "jungle_slab"
99		# optional prefix: stripped_
100		^(?:(?P<prefix>stripped)_)?
101		# tree: pale_oak, jungle, etc
102		(?P<tree>(?:pale_|dark_)?oak|spruce|birch|jungle|acacia|mangrove|cherry|bamboo|crimson|warped|(?:flowering_)?azalea)
103		_
104		# block variant: log, planks, etc
105		(?P<variant>log|stem|planks|door|leaves|wood|trapdoor|sign|wall_sign|sapling|pressure_plate)$
106	"#,
107	)
108	.unwrap()
109});
110
111/// Regex pattern for Minecraft stairs.
112/// Used in [`convert_stairs`].
113pub(crate) static STAIR_PATTERN: LazyLock<Regex> =
114	LazyLock::new(|| Regex::new(r"^(?:minecraft:)?(?P<material>.+?)_stairs$").unwrap());
115
116/// Regex pattern for Minecraft slabs.
117/// Used in [`convert_slab`].
118static SLAB_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?P<material>.+?)_slab$").unwrap());
119
120/// Regex pattern for Minecraft fences and iron and copper bars.
121/// Used in [`convert_fence`].
122pub(crate) static FENCE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
123	Regex::new(r"^(?:minecraft:)?(?P<material>.+?)_(?<fence>bars|fence|wall)(?P<gate>_gate)?$").unwrap()
124});
125
126/// Regex pattern for colourful blocks.
127/// Used in [`convert_colourful`].
128pub(crate) static COLOURFUL_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
129	Regex::new(
130		r"^(?x)
131			# colour and separator
132			(?P<colour>
133				white|orange|magenta|light_blue|yellow|lime|pink|gray|light_gray|cyan|purple|blue|brown|green|red|black
134			)_?
135
136			# the block:
137			(?P<block>
138				# - stained glass blocks and panes
139				stained_glass(?:_pane)?
140				|
141				# - terracotta
142				(?:glazed_)?terracotta
143				|
144				# - concrete
145				concrete(?:_powder)?
146				|
147				# - everything else
148				wool|bed|carpet|candle|banner|wall_banner
149			)$",
150	)
151	.unwrap()
152});
153
154/// Regex pattern for Minecraft copper blocks.
155/// Used in [`convert_metal`] and [`convert_lighting`].
156pub(crate) static COPPER_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
157	Regex::new(r"^(?:waxed_)?(?P<variant>weathered|exposed|oxidized)?_?(?:cut_)?copper(?:_block)?$").unwrap()
158});
159
160/// Regex pattern for Minecraft ore blocks.
161/// Used in [`convert_ore`].
162static ORE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
163	Regex::new(
164		r"^(?P<variant>nether|deepslate)?_?(?P<ore>coal|copper|iron|gold|redstone|lapis|diamond|emerald|quartz)_ore$",
165	)
166	.unwrap()
167});
168
169/// Cache for "simple" blocks (those without properties).
170/// Used in [`convert_block`].
171pub(crate) static SIMPLE_CACHE: LazyLock<Mutex<Map<String, String>>> = LazyLock::new(Default::default);
172
173/// Converts a Minecraft block code to a Vintage Story block code.
174///
175/// If there is no known Vintage Story replacement for this block, returns `None`.
176///
177/// Otherwise, a tuple of (`String`, `Option<EntityMap>`) will be returned.
178/// The first element is the [block code](https://wiki.vintagestory.at/Block_codes), and the second is the
179/// [block entity properties](https://wiki.vintagestory.at/Modding:Block_Entity_Classes).
180#[allow(clippy::missing_panics_doc, reason = "no need to document mutex panic")]
181#[must_use]
182pub fn convert_block(block: &MinecraftBlockCode, settings: &Settings) -> Option<(String, Option<EntityMap>)> {
183	let name = block.name.strip_prefix("minecraft:").unwrap_or(block.name.as_str());
184	trace!("block: {name}");
185	trace!("properties: {:?}", block.properties);
186
187	let empty_properties = block.properties.is_empty();
188	if empty_properties && let Some(result) = SIMPLE_CACHE.lock().expect("lock should not be poisoned").get(name) {
189		return Some((result.clone(), None));
190	}
191
192	// individual conversion functions for classes of blocks
193	let functions = [
194		convert_common,
195		convert_climbable,
196		convert_foliage,
197		convert_decor,
198		convert_lighting,
199		convert_utility,
200		convert_clutter,
201		convert_farming,
202		convert_crops,
203		convert_nether,
204		convert_ore,
205		convert_wood_block,
206		convert_stone_block,
207		convert_fence,
208		convert_glass,
209		convert_metal,
210		convert_stairs,
211		convert_slab,
212		convert_colourful,
213		convert_moss,
214		convert_miscellaneous,
215	];
216
217	for function in functions {
218		if let Some(result) = function(name, &block.properties, settings) {
219			if empty_properties && result.1.is_none() {
220				SIMPLE_CACHE.lock().expect("lock should not be poisoned").insert(name.to_string(), result.0.clone());
221			}
222			return Some(result);
223		}
224	}
225
226	warn!("unknown block: {name}");
227	None
228}
229
230/// Converts common Minecraft blocks, such as dirt and cobblestone, to their Vintage Story equivalents.
231fn convert_common(name: &str, properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
232	Some(match name {
233		"chest" | "trapped_chest" | "ender_chest" => convert_chest(properties),
234		"sand" => (String::from("game:sand-sandstone"), None),
235		"red_sand" => (String::from("game:sand-bauxite"), None),
236		"cobblestone" => (String::from("game:cobblestone-granite"), None),
237		"dirt" | "packed_mud" => (String::from("game:soil-low-none"), None),
238		"dirt_path" => (String::from("game:packeddirt"), None),
239		"podzol" | "mud" | "muddy_mangrove_roots" => (String::from("game:dirtygravel-wetverydark-plain"), None),
240		"coarse_dirt" => (String::from("game:soil-verylow-none"), None),
241		"grass_block" => (String::from("game:soil-low-normal"), None),
242		"clay" => (String::from("game:rawclay-blue-none"), None),
243		"gravel" => (String::from("game:gravel-granite"), None),
244		"cobweb" => (String::from("game:spiderweb"), None),
245		"ice" => (String::from("game:lakeice"), None),
246		"packed_ice" => (String::from("game:glacierice"), None),
247		"blue_ice" => (String::from("game:packedglacierice"), None),
248
249		"snow" | "powder_snow" | "snow_block" => {
250			// powder and block don't have layers, so treat them as maximum size
251			let layer = properties.get_u8("layers").unwrap_or(MAX_SNOW);
252			if layer >= MAX_SNOW {
253				(String::from("game:snowblock"), None)
254			} else {
255				(format!("game:snowlayer-{layer}"), None)
256			}
257		}
258
259		"water" | "lava" => {
260			let level = MAX_FLUID - properties.get_u8("level").unwrap_or_default().min(MAX_FLUID);
261
262			(format!("game:{name}-still-{level}"), None)
263		}
264		_ => return None,
265	})
266}
267
268/// Converts Minecraft wood blocks, such as logs, planks, or slabs, to their Vintage Story equivalents.
269pub fn convert_wood_block(
270	name: &str,
271	properties: &PropertyMap,
272	settings: &Settings,
273) -> Option<(String, Option<EntityMap>)> {
274	let matches = WOOD_PATTERN.captures(name)?;
275	let prefix = matches.name("prefix").map(|m| m.as_str());
276	let wood = matches.name("tree").map_or("", |m| m.as_str());
277	let variant = matches.name("variant").map_or("", |m| m.as_str());
278
279	let wood = if wood == "bamboo" && variant == "planks" {
280		// special case: bamboo planks should be handled specially, as they become stacked bamboo
281		"bamboo"
282	} else {
283		convert_wood(wood, true)?
284	};
285
286	let variant = match variant {
287		"stem" | "wood" => "log",
288		other => other,
289	};
290
291	let orientation = match properties.get("axis").map(String::as_str) {
292		Some("x") => "we",
293		Some("y") => "ud",
294
295		// interpreting double slabs as planks
296		_ if properties.get("type").is_some_and(|t| t == "double") => "ud",
297
298		_ => "ns",
299	};
300
301	let sign_wood = || settings.mods.contains(&Mod::VanillaVariants).then_some(wood);
302
303	Some(match variant {
304		"log" if prefix == Some("stripped") => (format!("game:debarkedlog-{wood}-{orientation}"), None),
305		"log" => (format!("game:{variant}-placed-{wood}-{orientation}"), None),
306		"planks" if wood == "bamboo" => (format!("game:stackedbamboo-{orientation}"), None),
307		"planks" => (format!("game:{variant}-{wood}-{orientation}"), None),
308		"door" => convert_door(wood, properties),
309		"trapdoor" => convert_trapdoor(wood, properties),
310		"sign" => convert_wooden_sign(false, sign_wood(), properties),
311		"wall_sign" => convert_wooden_sign(true, sign_wood(), properties),
312
313		// special case: purpleheart leaves don't exist, these are similar
314		"leaves" if wood == "purpleheart" => (String::from("game:leaves-placed-crimsonkingmaple"), None),
315		"leaves" => (format!("game:{variant}-placed-{wood}"), None),
316
317		"sapling" => (format!("game:{variant}-{wood}-free"), None),
318		"pressure_plate" => (String::from("game:metalsheet-cupronickel-free"), None),
319		// _ => (format!("error:{variant}-{tree}-{orientation}-???"), None),
320		_ => return None,
321	})
322}
323
324/// Converts Minecraft trapdoors to their Vintage Story equivalents.
325fn convert_trapdoor(material: &str, properties: &PropertyMap) -> (String, Option<EntityMap>) {
326	let rotation = if properties.get("half").is_some_and(|o| o == "bottom") {
327		180
328	} else {
329		-180
330	};
331	let face = match properties.get_facing_or_north() {
332		"north" => 0,
333		"east" => 1,
334		"south" => 2,
335		_ => 3,
336	};
337
338	let new_properties = Map::from([
339		(String::from("opened"), Value::Boolean(properties.is_true("open"))),
340		(String::from("rotDeg"), Value::Integer(rotation)),
341		(String::from("attachedFace"), Value::Integer(face)),
342	]);
343
344	let code = if material == "iron" {
345		String::from("game:trapdoor-bars-iron-1")
346	} else {
347		// minecraft's spruce, pale oak, and dark oak trapdoors don't have windows
348		let which = if matches!(material, "walnut" | "baldcypress" | "ebony") {
349			"solid"
350		} else {
351			"window"
352		};
353		format!("game:trapdoor-{which}-{material}-1")
354	};
355
356	(code, Some(new_properties))
357}
358
359/// Converts Minecraft wooden doors to their Vintage Story equivalents.
360fn convert_door(material: &str, properties: &PropertyMap) -> (String, Option<EntityMap>) {
361	// radians tomfoolery
362	let rotation = orientation_to_radians(properties.get_facing_or_north());
363
364	let new_properties = Map::from([
365		(String::from("opened"), Value::Boolean(properties.is_true("open"))),
366		(String::from("invertHandles"), Value::Boolean(properties.get("hinge").is_some_and(|o| o == "right"))),
367		(String::from("rotateYRad"), Value::Float(rotation)),
368	]);
369
370	let code = if material == "iron" {
371		String::from("game:metaldoor-sleek-windowed-iron")
372	} else {
373		format!("game:door-sleek-windowed-{material}")
374	};
375
376	(code, Some(new_properties))
377}
378
379/// Creates a Vintage Story sign from a Minecraft wooden sign's properties.
380fn convert_wooden_sign(wall: bool, wood: Option<&str>, properties: &PropertyMap) -> (String, Option<EntityMap>) {
381	// standing signs will use the north block
382	let facing = reverse_orientation(properties.get_facing_or_north());
383
384	// /!\ jank alert /!\
385	// create a property map with a "uniqueness marker" so that we can add the sign's text later
386	let mut new_properties = Map::from([(String::from("unique!"), Value::Boolean(true))]);
387
388	// handle standing signs
389	if !wall {
390		let rotation = properties.get_u8("rotation").unwrap_or_default();
391		let rotation = stepped_rotation(rotation);
392		new_properties.insert(String::from("meshAngle"), Value::Float(rotation));
393	}
394
395	let location = if wall { "wall" } else { "ground" };
396	let code = wood.filter(|&w| w != "oak").map_or_else(
397		|| format!("game:sign-{location}-{facing}"),
398		|wood| format!("vanvar:sign-{wood}-{location}-{facing}"),
399	);
400
401	(code, Some(new_properties))
402}
403
404/// Converts Minecraft stone blocks, such as stones, bricks, and cobblestone, to their Vintage Story equivalents.
405#[must_use]
406pub fn convert_stone_block(
407	name: &str,
408	_properties: &PropertyMap,
409	settings: &Settings,
410) -> Option<(String, Option<EntityMap>)> {
411	if name == "obsidian" || name == "coal_block" {
412		return Some((String::from("game:rock-obsidian"), None));
413	} else if name == "crying_obsidian" {
414		return Some((String::from("game:crackedrock-obsidian"), None));
415	} else if name == "purpur_block" {
416		return if settings.mods.contains(&Mod::Bricklayers) {
417			Some((String::from("bricklayers:glazedbricks-milky-purple"), None))
418		} else {
419			Some((String::from("game:planks-purpleheart-ud"), None))
420		};
421	}
422
423	let (material, variant) = material::convert_stone(name, false)?;
424
425	let (material, variant) = match (material, variant) {
426		("stonebrick", Some("chalk")) => ("quartz", None),
427		("stonebrick", _) => ("stonebricks", variant),
428		("clayshingle", _) => ("clayshingleblock", variant),
429		("brick", _) => {
430			// brick blocks are e.g. `brickcourse-four-running-red` instead of `brick-red`
431			let (count, colour) = variant.unwrap_or("four-red").split_once('-').unwrap_or(("four", "red"));
432			return Some((format!("game:brickcourse-{count}-running-{colour}"), None));
433		}
434		_ => (material, variant),
435	};
436
437	let material = format!("game:{material}");
438	Some((variant.map_or_else(|| material.clone(), |variant| format!("{material}-{variant}")), None))
439}
440
441/// Converts Minecraft stairs to their Vintage Story equivalents.
442///
443/// Stairs in Minecraft can take on different "corner" shapes depending on their placement:
444/// <https://minecraft.wiki/w/Stairs#Placement>.
445/// This behaviour is not possible in Vintage Story, so such shapes won't be carried over.
446pub fn convert_stairs(
447	name: &str,
448	properties: &PropertyMap,
449	_settings: &Settings,
450) -> Option<(String, Option<EntityMap>)> {
451	if !name.ends_with("stairs") {
452		return None;
453	}
454
455	let matches = STAIR_PATTERN.captures(name)?;
456	let material = matches.name("material").map_or("", |m| m.as_str());
457
458	let material: Cow<str> = if let Some(tree) = convert_wood(material, false)
459		&& !material.starts_with("bamboo")
460	{
461		format!("plankstairs-{tree}").into()
462	} else if material.ends_with("cut_copper") {
463		// copper stairs
464		// unoxidised and exposed become bauxite, the other variants become peridotite
465		match material.trim_end_matches("cut_copper").trim_start_matches("waxed_") {
466			"" | "exposed_" => "stonebrickstairs-bauxite",
467			_ => "stonebrickstairs-peridotite",
468		}
469		.into()
470	} else {
471		match material::convert_wood_or_stone(material) {
472			Some((material, Some(variant))) => format!("{material}stairs-{variant}"),
473			Some((stone, None)) => format!("{stone}stairs"),
474			None => return None,
475		}
476		.into()
477	};
478
479	let facing = properties.get_facing_or_north();
480	let half = match properties.get("half").map_or("bottom", String::as_str) {
481		"bottom" => "up",
482		_ => "down",
483	};
484
485	Some((format!("game:{material}-{half}-{facing}-free"), None))
486}
487
488/// Converts Minecraft slabs to Vintage Story slabs, plank blocks, or stone blocks.
489pub fn convert_slab(name: &str, properties: &PropertyMap, settings: &Settings) -> Option<(String, Option<EntityMap>)> {
490	if !name.ends_with("slab") {
491		return None;
492	}
493
494	let matches = SLAB_PATTERN.captures(name)?;
495	let material = matches.name("material").map_or("", |m| m.as_str());
496	let slab_type = match properties.get("type").map_or("bottom", String::as_str) {
497		"top" => "up",
498		"bottom" => "down",
499		"double" => {
500			if is_tree(material) {
501				// wood blocks
502				return convert_wood_block(&format!("{material}_planks"), properties, settings);
503			} else if is_stone(material) {
504				// stone blocks
505				return convert_stone_block(material, properties, settings);
506			}
507
508			// double copper slabs
509			"double"
510		}
511		other => {
512			warn!("unknown slab type: {other}");
513			return None;
514		}
515	};
516
517	Some((
518		match material {
519			// minecraft copper my beloathed
520			"cut_copper" | "waxed_cut_copper" | "exposed_cut_copper" | "waxed_exposed_cut_copper" => {
521				if slab_type == "double" {
522					String::from("game:stonebricks-bauxite")
523				} else {
524					format!("game:stonebrickslab-bauxite-{slab_type}-free")
525				}
526			}
527			"weathered_cut_copper" | "waxed_weathered_cut_copper" | "oxidized_cut_copper" | "waxed_oxidized_cut_copper" => {
528				match (slab_type, settings.mods.contains(&Mod::Bricklayers)) {
529					("double", true) => String::from("bricklayers:claytiles-polished-malachite"),
530					("double", false) => String::from("game:stonebricks-kimberlite"),
531					(_, true) => format!("bricklayers:claytilesslab-polished-malachite-{slab_type}-free"),
532					(_, false) => format!("game:stonebrickslab-kimberlite-{slab_type}-free"),
533				}
534			}
535
536			_ => match material::convert_wood_or_stone(material) {
537				Some((material, Some(variant))) if material == "clayshingle" => {
538					// they spelt slab wrong!!
539					// https://wiki.vintagestory.at/Block_codes#:~:text=clayshinglelabs
540					format!("game:{material}labs-{variant}-{slab_type}-free")
541				}
542				Some((material, Some(variant))) if material == "brick" => {
543					// brick slabs are `brickslabs-whatever` instead of `brickslab-whatever` (slabs plural)!
544					format!("game:{material}slabs-{variant}-{slab_type}-free")
545				}
546				Some((material, Some(variant))) => format!("game:{material}slab-{variant}-{slab_type}-free"),
547				Some((material, None)) => format!("game:{material}slab-{slab_type}-free"),
548
549				None => return None,
550			},
551		},
552		None,
553	))
554}
555
556/// Converts Minecraft fences, gates, bars, and walls to Vintage Story fences and gates.
557pub fn convert_fence(
558	name: &str,
559	properties: &PropertyMap,
560	_settings: &Settings,
561) -> Option<(String, Option<EntityMap>)> {
562	let matches = FENCE_PATTERN.captures(name)?;
563	let material = matches.name("material").map_or("", |m| m.as_str());
564	let fence = matches.name("fence").map_or("", |f| f.as_str());
565	let gate = matches.name("gate").is_some();
566
567	let directions = if gate {
568		match properties.get_facing_or_north() {
569			"north" | "south" => String::from("n"),
570			_ => String::from("w"),
571		}
572	} else {
573		let mut directions = String::new();
574
575		for (direction, character) in [("north", 'n'), ("east", 'e'), ("south", 's'), ("west", 'w')] {
576			if properties.get(direction).is_some_and(|d| d == "low" || d == "tall") {
577				directions.push(character);
578			}
579		}
580
581		if directions.is_empty() {
582			String::from("empty")
583		} else {
584			directions
585		}
586	};
587
588	let code = match (material::convert_wood_or_stone(material), is_tree(material)) {
589		// wooden gate
590		(Some((_, Some(variant))), true) if gate => {
591			let opened = if properties.is_true("open") { "opened" } else { "closed" };
592
593			// e.g. game:woodenfencegate-oak-n-opened-left-free
594			// universal prefix - wood variant - north or west - open? - hinge side - snow?
595			Some(format!("game:woodenfencegate-{variant}-{directions}-{opened}-left-free"))
596		}
597
598		// red/normal nether brick fence/wall
599		_ if material == "nether_brick" || material == "red_nether_brick" => {
600			Some(format!("game:drystonefence-chert-{directions}-free"))
601		}
602
603		// stone fence/wall
604		(Some((_, Some(variant))), false) => Some(format!("game:drystonefence-{variant}-{directions}-free")),
605
606		// wooden fence/wall
607		(Some((_, Some(variant))), true) => Some(format!("game:woodenfence-{variant}-{directions}-free")),
608
609		// iron/copper bars
610		_ if fence == "bars" => Some(format!("game:ironfence-base-{directions}")),
611
612		// fallback
613		_ if is_stone(material) => Some(format!("game:drystonefence-whitemarble-{directions}-free")),
614
615		_ => None,
616	}?;
617
618	Some((code, None))
619}
620
621/// Converts the various "colourful" Minecraft blocks to their Vintage Story equivalents.
622fn convert_colourful(name: &str, properties: &PropertyMap, settings: &Settings) -> Option<(String, Option<EntityMap>)> {
623	let matches = COLOURFUL_PATTERN.captures(name)?;
624	let colour = matches.name("colour").map_or("", |c| c.as_str());
625	let block = matches.name("block").map_or("", |v| v.as_str());
626
627	Some(match block {
628		"stained_glass" | "stained_glass_pane" => convert_colourful_glass(block, Some(colour), properties),
629		"carpet" => {
630			if settings.mods.contains(&Mod::WoolAndMore) {
631				let colour = convert_wool_colour(colour);
632
633				(format!("wool:wool-{colour}"), None)
634			} else {
635				let colour = match colour {
636					"black" | "blue" | "brown" | "red" | "purple" => colour,
637					"orange" => "red",
638					"magenta" | "pink" => "purple",
639					"light_blue" | "cyan" | "green" | "lime" | "yellow" => "turqoise", // sic
640					_ => "black",
641				};
642
643				(format!("game:smallcarpet-{colour}"), None)
644			}
645		}
646		"terracotta" | "glazed_terracotta" | "concrete" | "concrete_powder" => {
647			convert_terracotta_or_concrete(&settings.mods, colour)
648		}
649		"candle" => convert_candle(properties),
650		"bed" => convert_bed(properties),
651		"wool" => return convert_colourful_wool(colour, settings),
652		"banner" | "wall_banner" => {
653			let wall = block == "wall_banner";
654			let colour = match colour {
655				// direct mappings
656				"blue" | "green" | "orange" | "purple" | "red" | "yellow" => colour,
657
658				// other
659				"light_blue" | "cyan" => "blue",
660				"lime" => "green",
661				"pink" | "brown" | "gray" | "black" => "red",
662				"magenta" => "purple",
663				_ => "yellow",
664			};
665
666			let rotation = if wall {
667				orientation_to_radians(reverse_orientation(properties.get_facing_or_north()))
668			} else {
669				stepped_rotation(properties.get_u8("rotation").unwrap_or_default())
670			};
671
672			clutter("game:clutter", &format!("banner-sunsmall-{colour}-segment"), None, rotation)
673		}
674		_ => return None,
675	})
676}
677
678/// Converts Minecraft terracotta and concrete blocks to their Vintage Story equivalents.
679fn convert_terracotta_or_concrete(mods: &Set<Mod>, colour: &str) -> (String, Option<EntityMap>) {
680	if mods.contains(&Mod::MaterialNeeds) {
681		let colour = match colour {
682			"white" => return (String::from("materialneeds:plaster-plain"), None),
683			"light_gray" => "ashen",
684			"gray" => "charcoal",
685			"lime" => "moss",
686			"cyan" => "turquoise",
687			"light_blue" => "azure",
688			"purple" => "violet",
689			"pink" => "salmon",
690			colour => colour,
691		};
692
693		(format!("materialneeds:plaster-{colour}-plain"), None)
694	} else if mods.contains(&Mod::Bricklayers) {
695		let colour = match colour {
696			"blue" => "blurple",
697			"cyan" | "light_blue" => "malachite",
698			"lime" => "palegreen",
699			"magenta" => "purple",
700			"light_gray" => "light",
701			"gray" => "granite",
702			colour => colour,
703		};
704
705		(format!("bricklayers:claytiles-polished-{colour}"), None)
706	} else {
707		let colour = match colour {
708			"white" | "yellow" => "white",
709			"green" | "lime" => "green",
710			_ => "red",
711		};
712
713		(format!("game:rock-{colour}marble"), None)
714	}
715}
716
717/// Converts Minecraft glass panes and blocks to Vintage Story pine leaded glass panes and glass blocks, respectively.
718///
719/// Glass panes in Minecraft can take on a variety of shapes, such as L-bends, T-joins, or plus sign layouts.
720/// Vintage Story's glass panes don't support this, so such shapes won't be carried over.
721fn convert_glass(name: &str, properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
722	if matches!(name, "glass" | "glass_pane") {
723		Some(convert_colourful_glass(name, None, properties))
724	} else if name == "tinted_glass" {
725		Some(convert_colourful_glass("glass", Some("black"), properties))
726	} else {
727		None
728	}
729}
730
731/// Converts potentially colourful Minecraft glass panes and blocks to their Vintage Story equivalents.
732///
733/// See [`convert_glass`] for some caveats around panes.
734fn convert_colourful_glass(name: &str, colour: Option<&str>, properties: &PropertyMap) -> (String, Option<EntityMap>) {
735	(
736		if name.ends_with("pane") {
737			// glass panes
738			if properties.is_true("north") && properties.is_true("south") {
739				// pane is touching the north and south edges
740				// therefore, it's "facing" east and west
741				// TODO: what if it's doing something other that n/s or e/w? corner shapes? plus shapes?
742				String::from("game:glasspane-leaded-pine-ew")
743			} else {
744				String::from("game:glasspane-leaded-pine-ns")
745			}
746		} else {
747			// glass blocks
748			match colour {
749				None => String::from("game:glass-plain"),
750				Some("white") => String::from("game:glass-quartz"),
751				Some("brown" | "orange") => String::from("game:glass-brown"),
752				Some("red") => String::from("game:glass-red"),
753				Some("yellow") => String::from("game:glass-yellow"),
754				Some("green" | "lime") => String::from("game:glass-green"),
755				Some("cyan" | "light_blue" | "blue") => String::from("game:glass-blue"),
756				Some("purple" | "magenta") => String::from("game:glass-violet"),
757				Some("pink") => String::from("game:glass-pink"),
758				_ => String::from("game:glass-smoky"), // sic
759			}
760		},
761		None,
762	)
763}
764
765/// Converts Minecraft's metal blocks to their Vintage Story equivalents.
766fn convert_metal(name: &str, _properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
767	Some(match name {
768		"gold_block" => (String::from("game:metalblock-new-plain-gold"), None),
769		"iron_block" => (String::from("game:metalblock-new-plain-iron"), None),
770
771		_ if let Some(matches) = COPPER_PATTERN.captures(name) => {
772			if matches.name("variant").map(|v| v.as_str()).is_some() {
773				(String::from("game:shingleblock-copper"), None)
774			} else {
775				(String::from("game:metalblock-new-plain-copper"), None)
776			}
777		}
778		_ => return None,
779	})
780}
781
782/// Converts Minecraft's climbable blocks, such as ladders and vines, to their Vintage Story equivalents.
783fn convert_climbable(
784	name: &str,
785	properties: &PropertyMap,
786	_settings: &Settings,
787) -> Option<(String, Option<EntityMap>)> {
788	Some(match name {
789		"ladder" => {
790			let orientation = reverse_orientation(properties.get_facing_or_north());
791			(format!("game:ladder-wood-{orientation}"), None)
792		}
793		"vine" => {
794			let orientation = reverse_orientation(determine_orientation(properties).unwrap_or("north"));
795			(format!("game:wildvine-section-{orientation}"), None)
796		}
797		_ => return None,
798	})
799}
800
801/// Converts Minecraft farming-related blocks, such as farmland tiles, and pumpkins, to their Vintage Story
802/// equivalents.
803///
804/// This function does not handle crops.
805/// See [`convert_crops`].
806fn convert_farming(name: &str, properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
807	Some(match name {
808		"farmland" => {
809			let moisture = properties.get_u8("moisture").unwrap_or_default();
810
811			let code = if moisture == 7 {
812				"game:farmland-moist-verylow"
813			} else {
814				"game:farmland-dry-verylow"
815			};
816
817			let vs_moisture = if moisture == 0 { 0.0 } else { f32::from(moisture) / 7.0 };
818
819			(String::from(code), Some(Map::from([(String::from("moisture"), Value::Float(vs_moisture))])))
820		}
821
822		"hay_block" => (
823			String::from(match properties.get("axis").map_or("x", String::as_str) {
824				"x" => "game:hay-normal-we",
825				"y" => "game:hay-normal-ud",
826				_ => "game:hay-normal-ns",
827			}),
828			None,
829		),
830
831		"composter" | "barrel" => (String::from("game:barrel"), None),
832
833		"pumpkin" | "carved_pumpkin" | "jack_o_lantern" => (String::from("game:pumpkin-fruit-4"), None),
834
835		"sugar_cane" => (String::from("game:tallplant-tule-land-normal-free"), None),
836
837		_ => return None,
838	})
839}
840
841/// Converts Minecraft crops to their Vintage Story equivalents using data stored in the [`CROPS`] map.
842#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
843fn convert_crops(name: &str, properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
844	let crop_data = CROPS.get(name)?;
845
846	let vs_name = crop_data.name;
847	let age = properties.get_u8("age").unwrap_or_default() + 1;
848	let vs_age = ((f32::from(age) / f32::from(crop_data.mc_max_age)) * f32::from(crop_data.vs_max_age)).round() as u8;
849
850	Some((format!("game:crop-{vs_name}-{vs_age}"), None))
851}
852
853/// Converts Minecraft ores to their Vintage Story equivalents.
854fn convert_ore(name: &str, _properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
855	if !name.ends_with("ore") {
856		return None;
857	}
858
859	let matches = ORE_PATTERN.captures(name)?;
860	let variant = matches.name("variant").map(|v| v.as_str()).unwrap_or_default();
861	let ore = matches.name("ore").map(|m| m.as_str()).unwrap_or_default();
862
863	let stone = match variant {
864		"" => "granite",
865		"deepslate" => "basalt",
866		"nether" => "chert",
867		_ => return None,
868	};
869
870	let block = match ore {
871		"coal" => {
872			let stone = match stone {
873				"granite" => "limestone",
874				"basalt" => "shale",
875				_ => stone,
876			};
877
878			format!("ore-lignite-{stone}")
879		}
880		"copper" => format!("ore-medium-nativecopper-{stone}"),
881		"iron" => {
882			let ore = if stone == "granite" { "hematite" } else { "limonite" };
883			format!("ore-medium-{ore}-{stone}")
884		}
885		"gold" => format!("ore-medium-quartz_nativegold-{stone}"),
886		"redstone" => {
887			let stone = if stone == "chert" { "granite" } else { stone };
888			format!("ore-cinnabar-{stone}")
889		}
890		"lapis" => {
891			let stone = match stone {
892				"granite" => "limestone",
893				"chert" => "bauxite",
894				"basalt" => "redmarble",
895				_ => stone,
896			};
897			format!("ore-lapislazuli-{stone}")
898		}
899		"diamond" => String::from("ore-medium-diamond-kimberlite"),
900		"emerald" => {
901			let stone = match stone {
902				"granite" => "phyllite",
903				"chert" => "slate",
904				_ => stone,
905			};
906			format!("ore-medium-emerald-{stone}")
907		}
908		"quartz" => format!("ore-quartz-{stone}"),
909		_ => return None,
910	};
911
912	Some((block, None))
913}
914
915/// Converts Minecraft lighting-related blocks, such as torches and lanterns, to their Vintage Story equivalents.
916fn convert_lighting(name: &str, properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
917	Some(match name {
918		"torch" | "copper_torch" | "redstone_torch" | "soul_torch" => {
919			// torches that live "forever"!
920			let new_properties = Map::from([
921				(String::from("transitionHoursLeft"), Value::Double(f64::MAX)),
922				(String::from("lastCheckAtTotalDays"), Value::Double(f64::MAX)),
923			]);
924
925			let lit = properties.get("lit").is_none_or(|l| l == "true");
926			let code = if lit {
927				"game:torch-basic-lit-up"
928			} else {
929				"game:torch-basic-extinct-up"
930			};
931
932			(String::from(code), Some(new_properties))
933		}
934
935		"wall_torch" | "copper_wall_torch" | "redstone_wall_torch" | "soul_wall_torch" => {
936			// TODO: unlit redstone wall torches
937			let facing = reverse_orientation(properties.get_facing_or_north());
938
939			(format!("game:torchholder-aged-filled-{facing}"), None)
940		}
941
942		"lantern" => {
943			let hanging = properties.is_true("hanging");
944			lantern("brass", None, hanging)
945		}
946
947		"soul_lantern" => {
948			let hanging = properties.is_true("hanging");
949			lantern("steel", None, hanging)
950		}
951
952		"sea_lantern" | "redstone_lamp" => (String::from("game:paperlantern-on"), None),
953
954		"campfire" => (String::from("game:firepit-extinct"), None),
955
956		_ if name
957			.rsplit_once('_')
958			.is_some_and(|(copper, lantern)| COPPER_PATTERN.is_match(copper) && lantern == "lantern") =>
959		{
960			let hanging = properties.is_true("hanging");
961			lantern("copper", None, hanging)
962		}
963
964		"candle" => convert_candle(properties),
965
966		_ => return None,
967	})
968}
969
970fn convert_candle(properties: &PropertyMap) -> (String, Option<EntityMap>) {
971	let count = properties.get_u8("candles").unwrap_or(1).min(9);
972
973	(format!("game:bunchocandles-{count}"), None)
974}
975
976/// Converts Minecraft flowers, bushes, and other plants to their Vintage Story equivalents.
977fn convert_foliage(name: &str, properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
978	Some(match name {
979		// `grass` was renamed to `short_grass` in 1.20.3: https://minecraft.wiki/w/Java_Edition_1.20.3#Blocks
980		"short_grass" | "short_dry_grass" | "seagrass" | "grass" => (String::from("game:tallgrass-medium-free"), None),
981		"tall_grass" | "tall_dry_grass" | "tall_seagrass" => (String::from("game:tallgrass-verytall-free"), None),
982
983		"bush" | "azalea" | "flowering_azalea" | "dead_bush" | "fern" | "large_fern" | "firefly_bush" => {
984			(String::from("game:leavesbranchy-placed-birch"), None)
985		}
986		"cactus" => (String::from("game:saguarocactus-segment"), None),
987		"sweet_berry_bush" => (String::from("game:smallberrybush-cranberry-ripe"), None),
988		"bamboo" => {
989			let code = match properties.get("leaves").map_or("none", String::as_str) {
990				"large" => String::from("game:bamboo-placed-green-segment3"),
991				"small" => String::from("game:bamboo-placed-green-segment2"),
992				_ => String::from("game:bamboo-placed-green-segment1"),
993			};
994			(code, None)
995		}
996		"bamboo_sapling" => (String::from("game:sapling-greenbamboo-free"), None),
997
998		// aquatic plants
999		"lily_pad" => (String::from("game:aquatic-lilygiant-small"), None),
1000		"kelp" | "kelp_plant" => (String::from("game:aquatic-kelp-section"), None),
1001		"dried_kelp_block" => (String::from("game:stonebricks-peridotite"), None),
1002
1003		// coral
1004		"tube_coral" | "tube_coral_fan" | "tube_coral_wall_fan" => (String::from("game:coral-shrub-blue-1"), None),
1005
1006		"brain_coral"
1007		| "brain_coral_fan"
1008		| "brain_coral_wall_fan"
1009		| "bubble_coral"
1010		| "bubble_coral_fan"
1011		| "bubble_coral_wall_fan" => (String::from("game:coral-shrub-purple-1"), None),
1012
1013		"fire_coral" | "fire_coral_fan" | "fire_coral_wall_fan" => (String::from("game:coral-shrub-red-1"), None),
1014
1015		"horn_coral" | "horn_coral_fan" | "horn_coral_wall_fan" => (String::from("game:coral-shrub-yellow-1"), None),
1016
1017		// dead coral
1018		"dead_tube_coral"
1019		| "dead_tube_coral_fan"
1020		| "dead_tube_coral_wall_fan"
1021		| "dead_brain_coral"
1022		| "dead_brain_coral_fan"
1023		| "dead_brain_coral_wall_fan"
1024		| "dead_bubble_coral"
1025		| "dead_bubble_coral_fan"
1026		| "dead_bubble_coral_wall_fan"
1027		| "dead_fire_coral"
1028		| "dead_fire_coral_fan"
1029		| "dead_fire_coral_wall_fan"
1030		| "dead_horn_coral"
1031		| "dead_horn_coral_fan"
1032		| "dead_horn_coral_wall_fan" => (String::from("game:coral-shrub-dead-1"), None),
1033
1034		// flowers
1035		_ => {
1036			if let Some(flower) = convert_flower(name) {
1037				// flowers placed in the world
1038				(String::from(flower), None)
1039			} else {
1040				let flower = name
1041					.split_once('_')
1042					.and_then(|(potted, flower)| (potted == "potted").then(|| convert_flower(flower)))
1043					.flatten()?;
1044				// potted flowers: https://minecraft.wiki/w/Flower_Pot#Data_values
1045				// we need to an entry to the block_codes list, which we can't do from in here.
1046				// instead, we return an EntityMap with a marker value set, and rely on `Internal::convert_to_vintage_story` to
1047				// fix it up.
1048				(
1049					String::from(VS_FLOWER_POT_CODE),
1050					Some(Map::from([(String::from("flower"), Value::String(String::from(flower)))])),
1051				)
1052			}
1053		}
1054	})
1055}
1056
1057fn convert_flower(name: &str) -> Option<&'static str> {
1058	Some(match name {
1059		// TODO: the other flowers
1060		"cornflower" | "blue_orchid" => "game:flower-cornflower-free",
1061		"poppy" | "red_tulip" => "game:flower-lupine-red-free",
1062		"orange_tulip" | "dandelion" => "game:flower-lupine-orange-free",
1063		"white_tulip" => "game:flower-lupine-white-free",
1064		"pink_tulip" | "lilac" | "allium" | "peony" => "game:flower-lupine-purple-free",
1065		"oxeye_daisy" | "azure_bluet" => "game:flower-wilddaisy-free",
1066		"rose_bush" => "game:flower-redtopgrass-free",
1067		"lily_of_the_valley" => "game:flower-lilyofthevalley-free",
1068		_ => return None,
1069	})
1070}
1071
1072/// Converts Minecraft "decor" blocks, such as bookshelves and carpets, to their Vintage Story equivalents.
1073fn convert_decor(name: &str, properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
1074	Some(match name {
1075		// TODO: bookshelf with books on all sides somehow?
1076		"bookshelf" | "chiseled_bookshelf" => clutter(
1077			"game:clutteredbookshelf",
1078			"bookshelves/bookshelf-full",
1079			Some("full"),
1080			orientation_to_radians(properties.get_facing_or_north()),
1081		),
1082
1083		"bed" => convert_bed(properties),
1084
1085		"bell" if properties.get("attachment").is_some_and(|a| a == "ceiling") => {
1086			lantern("gold", properties.get("facing").map(String::as_str), true)
1087		}
1088
1089		"decorated_pot" => (String::from("game:storagevessel-red-fired"), None),
1090		"flower_pot" => (String::from("game:flowerpot-red-fired"), None), // TODO: pots with flowers
1091
1092		_ => return None,
1093	})
1094}
1095
1096fn convert_bed(properties: &PropertyMap) -> (String, Option<EntityMap>) {
1097	let facing = reverse_orientation(properties.get_facing_or_north());
1098	let half = match properties.get("part").map_or("foot", String::as_str) {
1099		"head" => "head",
1100		_ => "feet",
1101	};
1102
1103	(format!("game:bed-wood-{half}-{facing}"), None)
1104}
1105
1106/// Converts Minecraft utility blocks, such as furnaces and anvils, to their Vintage Story equivalents.
1107fn convert_utility(name: &str, properties: &PropertyMap, settings: &Settings) -> Option<(String, Option<EntityMap>)> {
1108	Some(match name {
1109		"furnace" | "blast_furnace" | "smoker" => (String::from("game:forge"), None),
1110		"anvil" | "chipped_anvil" | "damaged_anvil" => {
1111			let angle = orientation_to_radians(properties.get_facing_or_north());
1112			// rotate 90 degrees
1113			let angle = angle + std::f32::consts::FRAC_PI_2;
1114
1115			let new_properties = Map::from([(String::from("meshAngle"), Value::Float(angle))]);
1116
1117			(String::from("game:anvil-iron"), Some(new_properties))
1118		}
1119		"hopper" => {
1120			if settings.mods.contains(&Mod::VanillaVariants) {
1121				(String::from("vanvar:hopper-iron"), None)
1122			} else {
1123				(String::from("game:hopper"), None)
1124			}
1125		}
1126		"stone_pressure_plate" => (String::from("game:metalsheet-steel-down"), None),
1127		"polished_blackstone_pressure_plate" => (String::from("game:metalsheet-lead-down"), None),
1128		"heavy_weighted_pressure_plate" => (String::from("game:metalsheet-nickel-down"), None),
1129		"light_weighted_pressure_plate" => (String::from("game:metalsheet-gold-down"), None),
1130		"lodestone" | "observer" | "stonecutter" | "dropper" | "dispenser" => {
1131			(String::from("game:diamond-stone-light"), None)
1132		}
1133		"cauldron" | "water_cauldron" => (String::from("game:storagevessel-black-fired"), None),
1134		_ => return None,
1135	})
1136}
1137
1138/// Converts [Minecraft chests](https://minecraft.wiki/w/Chest) to
1139/// [Vintage Story chests and trunks](https://wiki.vintagestory.at/Chest).
1140///
1141/// Items contained within Minecraft chests are not currently converted.
1142fn convert_chest(properties: &PropertyMap) -> (String, Option<Map<String, Value>>) {
1143	// MC double chests become trunks.
1144	// MC chests are stored with type `single`, while double chests are stored with either `left` or `right`.
1145	// the `left` chests are rejected by `should_reject`, ensuring MC chests don't become two VS trunks.
1146	// VS chests have 16 slots, trunks have 36: https://wiki.vintagestory.at/Chest
1147	let (chest, slots) = if properties.get("type").is_some_and(|t| t == "single") {
1148		("chest", VS_CHEST_SLOTS)
1149	} else {
1150		("trunk", VS_TRUNK_SLOTS)
1151	};
1152
1153	let facing = reverse_orientation(properties.get_facing_or_north());
1154	let rotation = orientation_to_radians(facing);
1155	let code = format!("game:{chest}-{facing}");
1156
1157	// inventory tree
1158	let inventory = Map::from([
1159		// number of slots in this chest's inventory
1160		(String::from("qslots"), Value::Integer(slots)),
1161		// the items this chest is currently storing
1162		(String::from("slots"), Value::Tree(Map::new())),
1163	]);
1164
1165	let new_properties = Map::from([
1166		(String::from("inventory"), Value::Tree(inventory)),
1167		(String::from("meshAngle"), Value::Float(rotation)),
1168		// i don't know what these properties are for
1169		(String::from("forBlockCode"), Value::String(code.trim_start_matches("game:").to_owned())),
1170		(String::from("type"), Value::String(String::from("normal-generic"))),
1171	]);
1172
1173	(code, Some(new_properties))
1174}
1175
1176/// Converts Minecraft "clutter" blocks, such as lecterns, to their Vintage Story equivalents.
1177fn convert_clutter(name: &str, properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
1178	Some(match name {
1179		"lectern" => {
1180			let which = if properties.is_true("has_book") {
1181				// sic
1182				"bookshelves/lecturn-book-open"
1183			} else {
1184				"bookshelves/lecturn-empty"
1185			};
1186
1187			clutter(
1188				"game:clutter",
1189				which,
1190				None,
1191				orientation_to_radians(properties.get("facing").map_or("north", |f| reverse_orientation(f))),
1192			)
1193		}
1194		_ => return None,
1195	})
1196}
1197
1198/// Converts Nether blocks to their Vintage Story equivalents.
1199fn convert_nether(name: &str, _properties: &PropertyMap, _settings: &Settings) -> Option<(String, Option<EntityMap>)> {
1200	Some(match name {
1201		"netherrack" => (String::from("game:polishedrockold-cobbled-chert"), None),
1202		"soul_sand" => (String::from("game:cobbleskull-conglomerate"), None),
1203		"soul_soil" => (String::from("game:dirtygravel-wet-plain"), None),
1204		"glowstone" => (String::from("game:paperlantern-on"), None),
1205		_ => return None,
1206	})
1207}
1208
1209/// Creates a Vintage Story lantern.
1210fn lantern(material: &str, facing: Option<&str>, hanging: bool) -> (String, Option<EntityMap>) {
1211	let rotation = orientation_to_radians(facing.unwrap_or("north"));
1212
1213	let properties = Map::from([
1214		(String::from("meshAngle"), Value::Float(rotation)),
1215		(String::from("glass"), Value::String(String::from("quartz"))),
1216		(String::from("lining"), Value::String(String::from("plain"))),
1217		(String::from("material"), Value::String(material.to_owned())),
1218	]);
1219
1220	let code = if hanging {
1221		"game:lantern-down"
1222	} else {
1223		"game:lantern-up"
1224	};
1225
1226	(String::from(code), Some(properties))
1227}
1228
1229/// Creates a Vintage Story [clutter](https://wiki.vintagestory.at/Clutter) block.
1230fn clutter(code: &str, clutter_code: &str, variant: Option<&str>, angle: f32) -> (String, Option<EntityMap>) {
1231	let mut properties = Map::from([
1232		(String::from("meshAngle"), Value::Float(angle)),
1233		(String::from("type"), Value::String(clutter_code.to_owned())),
1234		(String::from("collected"), Value::Boolean(false)),
1235		(String::from("repairState"), Value::Float(0.0)),
1236	]);
1237
1238	if let Some(variant) = variant {
1239		properties.insert(String::from("variant"), Value::String(variant.to_owned()));
1240	}
1241
1242	(String::from(code), Some(properties))
1243}
1244
1245/// Converts colourful wool blocks to their Vintage Story equivalents.
1246fn convert_colourful_wool(colour: &str, settings: &Settings) -> Option<(String, Option<EntityMap>)> {
1247	if !settings.mods.contains(&Mod::WoolAndMore) {
1248		return None;
1249	}
1250
1251	let colour = convert_wool_colour(colour);
1252	Some((format!("wool:clothblock-wool-{colour}"), None))
1253}
1254
1255/// Converts a Minecraft wool colour to the corresponding Vintage Story 'Wool & More' wool colour.
1256fn convert_wool_colour(colour: &str) -> &str {
1257	match colour {
1258		"white" => "plain",
1259		"cyan" | "light_blue" => "blue",
1260		"blue" => "darkblue",
1261		"lime" => "green",
1262		"green" => "darkgreen",
1263		"magenta" => "purple",
1264		"light_gray" => "gray",
1265		_ => colour,
1266	}
1267}
1268
1269/// Converts [Minecraft's various moss blocks](https://minecraft.wiki/w/Moss) to their Vintage Story equivalents.
1270fn convert_moss(name: &str, _properties: &PropertyMap, settings: &Settings) -> Option<(String, Option<EntityMap>)> {
1271	Some(match name {
1272		"moss_block" => (String::from("game:daub-moss-normal"), None),
1273		"pale_moss_block" => (String::from("game:soil-verylow-none"), None),
1274		"moss_carpet" => {
1275			if settings.mods.contains(&Mod::WoolAndMore) {
1276				(String::from("wool:wool-green"), None)
1277			} else {
1278				(String::from("game:smallcarpet-turqoise"), None) // sic
1279			}
1280		}
1281		"pale_moss_carpet" => {
1282			if settings.mods.contains(&Mod::WoolAndMore) {
1283				(String::from("wool:wool-gray"), None)
1284			} else {
1285				(String::from("game:smallcarpet-purple"), None)
1286			}
1287		}
1288		_ => return None,
1289	})
1290}
1291
1292fn convert_miscellaneous(
1293	name: &str,
1294	properties: &PropertyMap,
1295	settings: &Settings,
1296) -> Option<(String, Option<EntityMap>)> {
1297	Some(match name {
1298		// TODO: replace these
1299		"red_mushroom_block" => (String::from("game:cobblestone-redmarble"), None),
1300		"mushroom_stem" => (String::from("game:log-placed-oak-ud"), None),
1301
1302		// TODO: move these to a separate function
1303		"terracotta" => (String::from("game:rock-redmarble"), None),
1304		"quartz_pillar" | "quartz_block" | "chiseled_quartz_block" | "bone_block" => {
1305			(String::from("game:stonebricks-chalk"), None)
1306		}
1307		"wool" if settings.mods.contains(&Mod::WoolAndMore) => (String::from("wool:clothblock-wool-plain"), None),
1308
1309		"iron_door" => convert_door("iron", properties),
1310		"iron_trapdoor" => convert_trapdoor("iron", properties),
1311
1312		"sponge" | "wet_sponge" => {
1313			if settings.mods.contains(&Mod::WoolAndMore) {
1314				(String::from("wool:clothblock-wool-yellow"), None)
1315			} else {
1316				(String::from("game:strawbedding"), None)
1317			}
1318		}
1319
1320		"slime_block" => (String::from("game:glass-green"), None),
1321		"raw_iron_block" => (String::from("game:cobblestone-claystone"), None),
1322
1323		_ => return None,
1324	})
1325}
1326
1327/// Converts a stepped rotation value (as used by [signs](https://minecraft.wiki/w/Sign#Block_states) and
1328/// [banners](https://minecraft.wiki/w/Banner#Block_states)) to a Vintage Story-compatible rotation value.
1329fn stepped_rotation(rotation: u8) -> f32 { (std::f32::consts::PI / 8.0) * f32::from(16 - rotation) }