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