vintage_schematics/formats/
schematic.rs

1//! Schematica format support.
2
3use std::io::Read;
4
5use color_eyre::{
6	eyre,
7	eyre::{Context, bail, eyre},
8};
9use serde::{Deserialize, Serialize};
10use tracing::debug;
11
12use crate::{
13	Map,
14	convert::block::{FENCE_PATTERN, STAIR_PATTERN},
15	formats::{
16		Loadable, ToInternal,
17		common::{MODERNISED_BLOCKS, MinecraftBlockEntity, Size},
18		internal::{Block, BlockCodes, Internal, MinecraftBlockCode, PropertyMap},
19		read, should_reject,
20	},
21};
22
23const WOOD_VALUES: [&str; 6] = ["oak", "spruce", "birch", "jungle", "acacia", "dark_oak"];
24
25const COLOURS: [&str; 16] = [
26	"white",
27	"orange",
28	"magenta",
29	"light_blue",
30	"yellow",
31	"lime",
32	"pink",
33	"gray",
34	"light_gray",
35	"cyan",
36	"purple",
37	"blue",
38	"brown",
39	"green",
40	"red",
41	"black",
42];
43
44/// South, west, north, east.
45///
46/// Used as an input to `data_facing` for fence gates and beds.
47const DIRS_SWNE: [&str; 4] = ["south", "west", "north", "east"];
48
49/// North, south, west, east.
50///
51/// Used as an input to `data_facing` for signs and chests.
52const DIRS_NSWE: [&str; 4] = ["north", "south", "west", "east"];
53
54/// East, west, south, north.
55///
56/// Used as an input to `data_facing` for torches and stairs.
57const DIRS_EWSN: [&str; 4] = ["east", "west", "south", "north"];
58
59/// The [Schematic](https://minecraft.wiki/w/Schematic_file_format) format.
60#[derive(Debug, Clone, Deserialize, Serialize)]
61#[serde(rename_all = "PascalCase")]
62pub struct Schematic {
63	/// Schematic size.
64	#[serde(flatten)]
65	pub size: Size,
66
67	/// Block IDs.
68	/// The block's code can be found in the `schematica_mapping` map.
69	pub blocks: Vec<u8>,
70
71	/// Additional [block data](https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values#Data).
72	pub data: Vec<u8>,
73
74	/// Block entity data.
75	pub tile_entities: Vec<MinecraftBlockEntity>,
76
77	/// Mapping of block names to IDs.
78	/// Used with the `blocks` field.
79	pub schematica_mapping: Map<String, u16>,
80
81	/// The schematic's material version.
82	/// Only "Alpha" is currently supported.
83	pub materials: String,
84}
85
86#[derive(Clone, Copy, PartialEq, Eq, Debug)]
87enum CacheState {
88	Rejected,
89	Unseen,
90	Cached(u16),
91}
92
93impl Loadable for Schematic {
94	fn load(reader: impl Read, compressed: bool) -> eyre::Result<Self>
95	where
96		Self: Sized,
97	{
98		let mut schematic: Self = read(reader, compressed).context("failed to load schematic")?;
99
100		if schematic.materials != "Alpha" {
101			bail!("unsupported schematic material version: expected 'Alpha', found '{}'", schematic.materials);
102		}
103
104		if schematic.blocks.len() != schematic.data.len() {
105			bail!(
106				"blocks and data arrays have different lengths ({}, {})",
107				schematic.blocks.len(),
108				schematic.data.len()
109			);
110		}
111
112		schematic.modernise();
113
114		Ok(schematic)
115	}
116
117	fn modernise(&mut self) {
118		for (old, new) in MODERNISED_BLOCKS {
119			if let Some(value) = self.schematica_mapping.remove(*old) {
120				self.schematica_mapping.insert(new.to_string(), value);
121			}
122		}
123	}
124}
125
126impl ToInternal for Schematic {
127	fn to_internal(self) -> eyre::Result<Internal> {
128		// block codes
129		// we can't convert these all in one go because they need to be paired with the data values from self.data
130		let mut block_codes: Vec<MinecraftBlockCode> = Vec::with_capacity(self.schematica_mapping.len());
131
132		// vec of block IDs to names
133		// block IDs are eight-bit ints, so there can only be 256 possible values
134		let mut names: Box<[Option<&str>; 256]> = Box::new([None; 256]);
135		for (name, &id) in &self.schematica_mapping {
136			if u8::try_from(id).is_ok() {
137				// should always be the case
138				names[id as usize] = Some(name);
139			}
140		}
141
142		// data values are four-bit ints.
143		// this means there are (eight for block ID) + (four for data) = twelve bits of data, or 4096 possible values.
144		// set up a cache for them
145		let mut cache = Box::new([CacheState::Unseen; 4096]);
146
147		// converted blocks
148		let mut blocks = Vec::with_capacity(self.blocks.len());
149
150		// index of the air block, which is probably zero
151		let air = self.schematica_mapping.iter().find(|(k, _)| **k == "minecraft:air").map(|(_, v)| *v);
152
153		for (i, (block, data)) in (0..=i32::MAX).zip(self.blocks.into_iter().zip(self.data.into_iter())) {
154			if air.is_some_and(|a| a == u16::from(block)) {
155				continue;
156			}
157
158			let id = if data < 16 {
159				let cache_idx = ((block as usize) << 4) | data as usize;
160				let cached = cache[cache_idx];
161
162				match cached {
163					CacheState::Rejected => continue,
164					CacheState::Unseen => {
165						// convert
166						let name = names[block as usize].ok_or_else(|| eyre!("unknown block ID: {}", block))?;
167						let code = convert_block(name, data)?;
168
169						if should_reject(&code) {
170							// remember that we've rejected this pair so we don't bother converting it again later
171							cache[cache_idx] = CacheState::Rejected;
172							continue;
173						}
174
175						// multiple (block, data) pairs can share the same code, so we should check to see if this code is a repeat.
176						// if it isn't, we need to insert it
177						let id = block_codes.iter().position(|c| c == &code).unwrap_or_else(|| {
178							block_codes.push(code);
179							block_codes.len() - 1
180						});
181
182						#[allow(clippy::cast_possible_truncation)]
183						let cached = id as u16;
184						cache[cache_idx] = CacheState::Cached(cached);
185
186						id
187					}
188					CacheState::Cached(id) => id as usize,
189				}
190			} else {
191				bail!("data value for block {i} out of range: expected 0-15, got {}", data);
192			};
193
194			blocks.push(Block {
195				id,
196				position: self.size.index_position(i),
197			});
198		}
199
200		Ok(Internal {
201			block_codes: BlockCodes::Minecraft(block_codes),
202			blocks,
203			tile_entities: self.tile_entities,
204			size: self.size.into(),
205		})
206	}
207}
208
209fn convert_block(name: &str, data: u8) -> eyre::Result<MinecraftBlockCode> {
210	// convert data to properties if necessary
211	let (name, properties) = match name {
212		"minecraft:torch" | "minecraft:redstone_torch" => convert_torch(name, data)?,
213		"minecraft:leaves" => convert_leaves(data, false),
214		"minecraft:leaves2" => convert_leaves(data, true),
215		"minecraft:standing_sign" => convert_sign(data, false)?,
216		"minecraft:wall_sign" => convert_sign(data, true)?,
217		"minecraft:chest" => convert_chest(data)?,
218		"minecraft:log" => convert_log(data),
219
220		// https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values#Wood_Planks
221		"minecraft:planks" => (format!("minecraft:{}_planks", WOOD_VALUES.get(data as usize).unwrap_or(&"oak")), None),
222
223		"minecraft:bed" => convert_bed(data)?,
224
225		// colourful blocks
226		"minecraft:wool" => convert_colourful("wool", data),
227		"minecraft:stained_hardened_clay" => convert_colourful("terracotta", data),
228		"minecraft:carpet" => convert_colourful("carpet", data),
229		"minecraft:stained_glass" => convert_colourful("stained_glass", data),
230		"minecraft:stained_glass_pane" => convert_colourful("stained_glass_pane", data),
231
232		_ => {
233			if name.ends_with("_gate") && FENCE_PATTERN.is_match(name) {
234				convert_gate(data, name)?
235			} else if name.ends_with("_stairs") && STAIR_PATTERN.is_match(name) {
236				convert_stairs(name, data)?
237			} else {
238				if tracing::enabled!(tracing::Level::DEBUG) {
239					debug!("passthrough: {name} with {data} ({data:#08b})");
240				}
241				(String::from(name), None)
242			}
243		}
244	};
245
246	let properties = properties.unwrap_or_default();
247	let code = MinecraftBlockCode { name, properties };
248	Ok(code)
249}
250
251fn convert_torch(name: &str, data: u8) -> eyre::Result<(String, Option<PropertyMap>)> {
252	// https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values#Torches_and_Redstone_Torches
253	let redstone = name == "minecraft:redstone_torch";
254
255	Ok(if data == 5 {
256		(String::from(name), None)
257	} else {
258		let facing = data_facing(data, DIRS_EWSN, 1)?;
259
260		let torch = if redstone {
261			"minecraft:redstone_wall_torch"
262		} else {
263			"minecraft:wall_torch"
264		};
265
266		(String::from(torch), Some(Map::from([(String::from("facing"), facing.to_string())])))
267	})
268}
269
270fn convert_leaves(data: u8, group_2: bool) -> (String, Option<PropertyMap>) {
271	// https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values#Leaves
272	let leaf_array = if group_2 {
273		&["acacia", "dark_oak", "oak", "oak"]
274	} else {
275		&WOOD_VALUES[0..4]
276	};
277
278	let leaf_type = leaf_array[(data & 0b11) as usize];
279	(format!("minecraft:{leaf_type}_leaves"), None)
280}
281
282fn convert_sign(data: u8, wall: bool) -> eyre::Result<(String, Option<PropertyMap>)> {
283	let properties = if wall {
284		(String::from("facing"), data_facing(data, DIRS_NSWE, 2)?.to_string())
285	} else {
286		(String::from("rotation"), data.to_string())
287	};
288
289	let name = if wall {
290		"minecraft:oak_wall_sign"
291	} else {
292		"minecraft:oak_sign"
293	};
294
295	Ok((String::from(name), Some(Map::from([properties]))))
296}
297
298fn convert_bed(data: u8) -> eyre::Result<(String, Option<PropertyMap>)> {
299	// https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values#Beds
300	let facing = data_facing(data, DIRS_SWNE, 0)?;
301	let part = if data & 0b1000 != 0 { "head" } else { "foot" };
302
303	Ok((
304		String::from("minecraft:red_bed"),
305		Some(Map::from([(String::from("facing"), facing.to_string()), (String::from("part"), part.to_string())])),
306	))
307}
308
309fn convert_chest(data: u8) -> eyre::Result<(String, Option<PropertyMap>)> {
310	Ok((
311		String::from("minecraft:chest"),
312		Some(Map::from([
313			(String::from("facing"), data_facing(data, DIRS_NSWE, 2)?.to_string()),
314			(String::from("type"), String::from("single")),
315		])),
316	))
317}
318
319fn convert_log(data: u8) -> (String, Option<PropertyMap>) {
320	// https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values#Wood
321	let wood = WOOD_VALUES[(data & 0b11) as usize];
322
323	let axis = match data >> 2 {
324		0 => "y",
325		1 => "x",
326		2 => "z",
327
328		// bark
329		_ => return (format!("minecraft:{wood}_wood"), None),
330	};
331
332	(format!("minecraft:{wood}_log"), Some(Map::from([(String::from("axis"), axis.to_string())])))
333}
334
335fn convert_gate(data: u8, name: &str) -> eyre::Result<(String, Option<PropertyMap>)> {
336	let facing = data_facing(data, DIRS_SWNE, 0)?;
337
338	let opened = if data & 0b100 != 0 { "opened" } else { "closed" };
339
340	Ok((
341		String::from(name),
342		Some(Map::from([
343			(String::from("facing"), String::from(facing)),
344			(String::from("open"), String::from(opened)),
345		])),
346	))
347}
348
349fn convert_stairs(name: &str, data: u8) -> eyre::Result<(String, Option<PropertyMap>)> {
350	// https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values#Stairs
351
352	let facing = data_facing(data, DIRS_EWSN, 0)?;
353	let half = if data & 0b100 != 0 { "top" } else { "bottom" };
354
355	Ok((
356		name.to_string(),
357		Some(Map::from([
358			(String::from("facing"), String::from(facing)),
359			(String::from("half"), String::from(half)),
360		])),
361	))
362}
363
364fn convert_colourful(name: &str, data: u8) -> (String, Option<PropertyMap>) {
365	let colour = COLOURS[(data & 0b1111) as usize];
366	(format!("minecraft:{colour}_{name}"), None)
367}
368
369/// Converts a [data value](https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values) to a facing direction.
370///
371/// The `start` parameter specifies the starting index for the facing directions array.
372fn data_facing(data: u8, directions: [&str; 4], start: u8) -> eyre::Result<&str> {
373	directions.get(((data - start) & 0b11) as usize).copied().ok_or_else(|| {
374		eyre!(
375			"couldn't determine facing direction: expected data value between {start} and {}, got {data}",
376			start + 4
377		)
378	})
379}
380
381#[test]
382fn test_data_facing() {
383	// beds: https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values#Beds
384	let bed_facings = DIRS_SWNE;
385	assert_eq!(data_facing(0, bed_facings, 0).unwrap(), "south");
386	assert_eq!(data_facing(1, bed_facings, 0).unwrap(), "west");
387
388	// torches: https://minecraft.wiki/w/Java_Edition_pre-flattening_data_values#Torches_and_Redstone_Torches
389	let torch_facings = DIRS_EWSN;
390	assert_eq!(data_facing(1, torch_facings, 1).unwrap(), "east");
391	assert_eq!(data_facing(3, torch_facings, 1).unwrap(), "south");
392}