Skip to main content

vintage_schematics/formats/
schematic.rs

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