1use 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
41const DIRS_SWNE: [&str; 4] = ["south", "west", "north", "east"];
45
46const DIRS_NSWE: [&str; 4] = ["north", "south", "west", "east"];
50
51const DIRS_EWSN: [&str; 4] = ["east", "west", "south", "north"];
55
56#[derive(Debug, Clone, Deserialize, Serialize)]
58#[serde(rename_all = "PascalCase")]
59pub struct Schematic {
60 #[serde(flatten)]
62 pub size: Size,
63
64 pub blocks: Vec<u8>,
67
68 pub data: Vec<u8>,
70
71 pub tile_entities: Vec<MinecraftBlockEntity>,
73
74 pub schematica_mapping: Map<String, u16>,
77
78 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 let mut block_codes: Vec<MinecraftBlockCode> = Vec::with_capacity(self.schematica_mapping.len());
128
129 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 names[id as usize] = Some(name);
136 }
137 }
138
139 let mut cache = Box::new([CacheState::Unseen; 4096]);
143
144 let mut blocks = Vec::with_capacity(self.blocks.len());
146
147 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 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 cache[cache_idx] = CacheState::Rejected;
169 continue;
170 }
171
172 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 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 "minecraft:planks" => (format!("minecraft:{}_planks", WOOD_VALUES.get(data as usize).unwrap_or(&"oak")), None),
219
220 "minecraft:bed" => convert_bed(data)?,
221
222 "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 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 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 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 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 _ => 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 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
366fn 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 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 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}