1use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, PartialEq)]
22pub struct Part {
23 pub grid_index: [i32; 3],
24 pub offset: [i32; 3],
26 pub size: [i32; 3],
27}
28
29#[derive(Debug, Clone)]
31pub struct SplitPlan {
32 pub grid: [i32; 3],
33 pub parts: Vec<Part>,
34}
35
36impl SplitPlan {
37 pub fn is_single(&self) -> bool {
39 self.grid == [1, 1, 1]
40 }
41}
42
43fn ceil_div(a: i32, b: i32) -> i32 {
44 (a + b - 1) / b
45}
46
47pub fn plan_split(size: [i32; 3], part_max: i32) -> SplitPlan {
49 let max = part_max.max(1);
50 let grid = [
51 ceil_div(size[0], max).max(1),
52 ceil_div(size[1], max).max(1),
53 ceil_div(size[2], max).max(1),
54 ];
55 let mut parts = Vec::with_capacity((grid[0] * grid[1] * grid[2]) as usize);
56 for i in 0..grid[0] {
57 for j in 0..grid[1] {
58 for k in 0..grid[2] {
59 let offset = [i * max, j * max, k * max];
60 let part_size = [
61 (size[0] - offset[0]).min(max),
62 (size[1] - offset[1]).min(max),
63 (size[2] - offset[2]).min(max),
64 ];
65 parts.push(Part {
66 grid_index: [i, j, k],
67 offset,
68 size: part_size,
69 });
70 }
71 }
72 }
73 SplitPlan { grid, parts }
74}
75
76pub fn part_filename(base: &str, grid_index: [i32; 3]) -> String {
78 format!(
79 "{base}.x{}y{}z{}.nbt",
80 grid_index[0], grid_index[1], grid_index[2]
81 )
82}
83
84pub fn manifest_filename(base: &str) -> String {
86 format!("{base}.split.json")
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct TileSet {
103 pub base: String,
105 pub size: [i32; 3],
107 pub part_max: i32,
109 pub grid: [i32; 3],
111 pub data_version: i32,
113 #[serde(default)]
115 pub generator: String,
116 pub parts: Vec<TilePart>,
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct TilePart {
123 pub file: String,
125 pub id: String,
127 pub grid_index: [i32; 3],
129 pub offset: [i32; 3],
133 pub size: [i32; 3],
135}
136
137impl TileSet {
138 pub fn validate(&self) -> Result<(), String> {
146 if self.parts.is_empty() {
147 return Err("the manifest lists no tiles".to_string());
148 }
149 let mut covered: i64 = 0;
150 for part in &self.parts {
151 for axis in 0..3 {
152 if part.size[axis] <= 0 {
153 return Err(format!("tile {:?} has a zero or negative axis", part.file));
154 }
155 if part.size[axis] > self.part_max {
156 return Err(format!(
157 "tile {:?} is {} on axis {axis}, past the declared cap of {}",
158 part.file, part.size[axis], self.part_max
159 ));
160 }
161 if part.offset[axis] < 0 || part.offset[axis] + part.size[axis] > self.size[axis] {
162 return Err(format!(
163 "tile {:?} runs outside the {}x{}x{} volume on axis {axis}",
164 part.file, self.size[0], self.size[1], self.size[2]
165 ));
166 }
167 }
168 covered += part.size[0] as i64 * part.size[1] as i64 * part.size[2] as i64;
169 }
170 let whole = self.size[0] as i64 * self.size[1] as i64 * self.size[2] as i64;
171 if covered != whole {
172 return Err(format!(
173 "the {} tile(s) cover {covered} cell(s) of a {whole}-cell volume — a tile set that \
174 does not tile its volume exactly would reassemble with a hole or an overlap",
175 self.parts.len()
176 ));
177 }
178 Ok(())
179 }
180}
181
182pub fn read_tile_set(path: &Path) -> Result<Option<TileSet>, String> {
197 let text =
198 std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
199 let meta = crate::prefab::PrefabMeta::from_json(&text)
200 .map_err(|e| format!("{}: {e}", path.display()))?;
201 Ok(meta.structure_set)
202}
203
204#[derive(Serialize)]
205struct PartManifest {
206 file: String,
207 grid_index: [i32; 3],
208 offset: [i32; 3],
209 size: [i32; 3],
210}
211
212#[derive(Serialize)]
213struct SplitManifest {
214 base: String,
215 data_version: i32,
216 source_size: [i32; 3],
217 source_offset: [i32; 3],
218 part_max: i32,
219 grid: [i32; 3],
220 parts: Vec<PartManifest>,
221}
222
223pub fn tile_filename(name: &str) -> Option<(&str, [i32; 3])> {
230 let stem = name.strip_suffix(".nbt")?;
231 let (base, suffix) = stem.rsplit_once('.')?;
232 let rest = suffix.strip_prefix('x')?;
233 let (i, rest) = rest.split_once('y')?;
234 let (j, k) = rest.split_once('z')?;
235 Some((base, [i.parse().ok()?, j.parse().ok()?, k.parse().ok()?]))
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
241pub enum TileEvidence {
242 Whole,
244 Tile {
247 base: String,
249 manifest: Option<std::path::PathBuf>,
251 },
252}
253
254pub fn tile_evidence(nbt_path: &Path) -> Result<TileEvidence, String> {
277 let Some(name) = nbt_path.file_name().and_then(|s| s.to_str()) else {
278 return Ok(TileEvidence::Whole);
279 };
280 let Some((base, _)) = tile_filename(name) else {
281 return Ok(TileEvidence::Whole);
282 };
283 let manifest = nbt_path.with_file_name(format!("{base}.json"));
284 let claimed = manifest.exists()
285 && read_tile_set(&manifest)?.is_some_and(|set| set.parts.iter().any(|p| p.file == name));
286 Ok(TileEvidence::Tile {
287 base: base.to_string(),
288 manifest: claimed.then_some(manifest),
289 })
290}
291
292pub fn fragment_refusal(
299 nbt_path: &Path,
300 evidence: &TileEvidence,
301 verb: &str,
302 consequence: &str,
303) -> Option<String> {
304 let TileEvidence::Tile { base, manifest } = evidence else {
305 return None;
306 };
307 Some(match manifest {
308 Some(m) => format!(
309 "{} is one tile of the zone described by {} — to {verb} it would {consequence}. \
310 Use the whole zone: pass {}",
311 nbt_path.display(),
312 m.display(),
313 m.display()
314 ),
315 None => format!(
316 "{} is one tile of a tiled zone (`{base}`) that has been separated from its set — to \
317 {verb} it would {consequence}, and its manifest is not beside it, so there is \
318 nothing here to reassemble the zone from. Put the tile back with its `{base}.json` \
319 manifest and the rest of its tiles, and pass the manifest",
320 nbt_path.display()
321 ),
322 })
323}
324
325pub fn manifest_claiming(nbt_path: &Path) -> Result<Option<std::path::PathBuf>, String> {
327 match tile_evidence(nbt_path)? {
328 TileEvidence::Tile { manifest, .. } => Ok(manifest),
329 TileEvidence::Whole => Ok(None),
330 }
331}
332
333pub fn manifest_json(
336 base: &str,
337 data_version: i32,
338 source_size: [i32; 3],
339 source_offset: [i32; 3],
340 part_max: i32,
341 plan: &SplitPlan,
342) -> String {
343 let manifest = SplitManifest {
344 base: base.to_string(),
345 data_version,
346 source_size,
347 source_offset,
348 part_max,
349 grid: plan.grid,
350 parts: plan
351 .parts
352 .iter()
353 .map(|p| PartManifest {
354 file: part_filename(base, p.grid_index),
355 grid_index: p.grid_index,
356 offset: p.offset,
357 size: p.size,
358 })
359 .collect(),
360 };
361 serde_json::to_string_pretty(&manifest).expect("manifest serializes")
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 fn tile_set(size: [i32; 3], parts: Vec<([i32; 3], [i32; 3])>) -> TileSet {
369 TileSet {
370 base: "zone".to_string(),
371 size,
372 part_max: 48,
373 grid: [1, 1, parts.len() as i32],
374 data_version: 4671,
375 generator: "crates/grammar".to_string(),
376 parts: parts
377 .into_iter()
378 .enumerate()
379 .map(|(i, (offset, size))| TilePart {
380 file: format!("zone.x0y0z{i}.nbt"),
381 id: format!("zone.x0y0z{i}"),
382 grid_index: [0, 0, i as i32],
383 offset,
384 size,
385 })
386 .collect(),
387 }
388 }
389
390 #[test]
394 fn every_plan_split_tiling_validates() {
395 for size in [
396 [1, 1, 1],
397 [48, 48, 48],
398 [49, 1, 1],
399 [20, 10, 84],
400 [90, 14, 130],
401 [200, 100, 200],
402 ] {
403 let plan = plan_split(size, 48);
404 let set = TileSet {
405 base: "zone".to_string(),
406 size,
407 part_max: 48,
408 grid: plan.grid,
409 data_version: 4671,
410 generator: String::new(),
411 parts: plan
412 .parts
413 .iter()
414 .map(|p| TilePart {
415 file: part_filename("zone", p.grid_index),
416 id: format!("zone{:?}", p.grid_index),
417 grid_index: p.grid_index,
418 offset: p.offset,
419 size: p.size,
420 })
421 .collect(),
422 };
423 assert_eq!(set.validate(), Ok(()), "{size:?}");
424 }
425 }
426
427 #[test]
431 fn a_tiling_with_a_gap_is_refused() {
432 let short = tile_set([4, 4, 100], vec![([0, 0, 0], [4, 4, 48])]);
433 let err = short.validate().unwrap_err();
434 assert!(err.contains("cover"), "{err}");
435
436 let overlap = tile_set(
438 [4, 4, 48],
439 vec![([0, 0, 0], [4, 4, 24]), ([0, 0, 0], [4, 4, 24])],
440 );
441 assert_eq!(overlap.validate(), Ok(()), "volume alone cannot see this");
442 let outside = tile_set(
443 [4, 4, 48],
444 vec![([0, 0, 0], [4, 4, 24]), ([0, 0, 40], [4, 4, 24])],
445 );
446 assert!(
447 outside.validate().unwrap_err().contains("outside"),
448 "a part running past the volume is caught"
449 );
450 }
451
452 #[test]
455 fn a_part_past_the_declared_cap_is_refused() {
456 let big = tile_set([4, 4, 49], vec![([0, 0, 0], [4, 4, 49])]);
457 assert!(
458 big.validate()
459 .unwrap_err()
460 .contains("past the declared cap"),
461 "{:?}",
462 big.validate()
463 );
464 }
465
466 #[test]
469 fn a_manifest_with_no_tiles_is_refused() {
470 let empty = tile_set([4, 4, 4], vec![]);
471 assert!(empty.validate().unwrap_err().contains("no tiles"));
472 }
473
474 #[test]
481 fn a_tile_is_recognised_by_its_own_name_wherever_it_is_put() {
482 let dir = std::env::temp_dir().join(format!("dw-split-evid-{}", std::process::id()));
483 let _ = std::fs::remove_dir_all(&dir);
484 std::fs::create_dir_all(dir.join("elsewhere")).unwrap();
485
486 let set = tile_set(
487 [4, 4, 60],
488 vec![([0, 0, 0], [4, 4, 48]), ([0, 0, 48], [4, 4, 12])],
489 );
490 std::fs::write(
491 dir.join("zone.json"),
492 serde_json::to_string(
493 &serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": set }),
494 )
495 .unwrap(),
496 )
497 .unwrap();
498 for part in &set.parts {
499 std::fs::write(dir.join(&part.file), b"not really nbt").unwrap();
500 }
501
502 assert_eq!(
504 tile_evidence(&dir.join("zone.x0y0z1.nbt")).unwrap(),
505 TileEvidence::Tile {
506 base: "zone".to_string(),
507 manifest: Some(dir.join("zone.json")),
508 }
509 );
510
511 std::fs::copy(
513 dir.join("zone.x0y0z1.nbt"),
514 dir.join("elsewhere/zone.x0y0z1.nbt"),
515 )
516 .unwrap();
517 assert_eq!(
518 tile_evidence(&dir.join("elsewhere/zone.x0y0z1.nbt")).unwrap(),
519 TileEvidence::Tile {
520 base: "zone".to_string(),
521 manifest: None,
522 },
523 "a guard a `cp` defeats is not a property of the artifact"
524 );
525
526 assert_eq!(
528 tile_evidence(&dir.join("keep-gate-room.nbt")).unwrap(),
529 TileEvidence::Whole
530 );
531 assert_eq!(tile_filename("keep-gate-room.nbt"), None);
532 assert_eq!(
533 tile_filename("zone.x0y10z2.nbt"),
534 Some(("zone", [0, 10, 2]))
535 );
536 assert_eq!(
537 tile_filename("cave.mouth.nbt"),
538 None,
539 "a dotted name is not a grid suffix"
540 );
541
542 let beside = tile_evidence(&dir.join("zone.x0y0z0.nbt")).unwrap();
544 let msg = fragment_refusal(&dir.join("zone.x0y0z0.nbt"), &beside, "audit", "lie").unwrap();
545 assert!(msg.contains("zone.json"), "{msg}");
546 let orphan = tile_evidence(&dir.join("elsewhere/zone.x0y0z0.nbt")).unwrap();
547 let msg = fragment_refusal(
548 &dir.join("elsewhere/zone.x0y0z0.nbt"),
549 &orphan,
550 "audit",
551 "lie",
552 )
553 .unwrap();
554 assert!(msg.contains("separated from its set"), "{msg}");
555 assert_eq!(
556 fragment_refusal(&dir, &TileEvidence::Whole, "audit", "lie"),
557 None
558 );
559
560 std::fs::remove_dir_all(&dir).unwrap();
561 }
562
563 #[test]
567 fn the_two_metadata_shapes_are_told_apart() {
568 let dir = std::env::temp_dir().join(format!("dw-split-shape-{}", std::process::id()));
569 let _ = std::fs::remove_dir_all(&dir);
570 std::fs::create_dir_all(&dir).unwrap();
571
572 let single = dir.join("single.json");
573 std::fs::write(
574 &single,
575 r#"{"prefab_id":"prefab/x","structure":{"file":"x.nbt","id":"x","size":[2,2,2],"data_version":4671}}"#,
576 )
577 .unwrap();
578 assert_eq!(read_tile_set(&single).unwrap(), None);
579
580 let set = tile_set(
581 [4, 4, 60],
582 vec![([0, 0, 0], [4, 4, 48]), ([0, 0, 48], [4, 4, 12])],
583 );
584 let tiled = dir.join("tiled.json");
585 std::fs::write(
586 &tiled,
587 serde_json::to_string(
588 &serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": set }),
589 )
590 .unwrap(),
591 )
592 .unwrap();
593 assert_eq!(read_tile_set(&tiled).unwrap().unwrap(), set);
594
595 let broken = dir.join("broken.json");
598 let bad = tile_set([4, 4, 60], vec![([0, 0, 0], [4, 4, 48])]);
599 std::fs::write(
600 &broken,
601 serde_json::to_string(
602 &serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": bad }),
603 )
604 .unwrap(),
605 )
606 .unwrap();
607 assert!(read_tile_set(&broken).unwrap_err().contains("cover"));
608
609 std::fs::remove_dir_all(&dir).unwrap();
610 }
611}