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