Skip to main content

delvewright_dsl/
split.rs

1//! Oversize splitting — the one tiling in this project.
2//!
3//! Vanilla structure templates cap each axis at 48. That is a limit on a file
4//! format, never on a design, so anything bigger is
5//! tiled into a deterministic grid of parts plus a manifest recording grid
6//! dimensions, per-part sizes and zone-local offsets, and every consumer
7//! reassembles losslessly from that manifest.
8//!
9//! Two producers write tilings — `delvec schem convert` for an oversize `.schem`
10//! import, and `delvec grammar expand` for a zone whose expansion outgrows one
11//! template — and they call the same [`plan_split`], so a volume tiles the same
12//! way whichever door it came in by. [`TileSet`] is the manifest contract
13//! itself: one struct, `Serialize` for the producers and `Deserialize` for the
14//! consumers, so the two halves cannot drift apart.
15
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20/// One grid cell of a split.
21#[derive(Debug, Clone, PartialEq)]
22pub struct Part {
23    pub grid_index: [i32; 3],
24    /// Source-local origin of this part.
25    pub offset: [i32; 3],
26    pub size: [i32; 3],
27}
28
29/// The plan for tiling a `source_size` volume with a `part_max`-cube cap.
30#[derive(Debug, Clone)]
31pub struct SplitPlan {
32    pub grid: [i32; 3],
33    pub parts: Vec<Part>,
34}
35
36impl SplitPlan {
37    /// True when the volume fits in a single part.
38    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
47/// Compute a split plan. Parts are emitted in x -> y -> z grid order.
48pub 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
76/// Part filename for a base name: `<base>.x<i>y<j>z<k>.nbt`.
77pub 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
84/// Manifest filename: `<base>.split.json`.
85pub fn manifest_filename(base: &str) -> String {
86    format!("{base}.split.json")
87}
88
89// ---------------------------------------------------------------------------
90// The tile-set manifest contract
91// ---------------------------------------------------------------------------
92
93/// A volume packaged as several structure templates.
94///
95/// This is the `structure_set` block of a tiled prefab's metadata file, and it
96/// is the whole contract: given it and the `.nbt` files it names, a consumer can
97/// rebuild the original volume without knowing anything about who tiled it or
98/// why. It is `Serialize` **and** `Deserialize` on purpose — the producer and
99/// the consumer share one definition, so a field cannot be added on one side and
100/// missed on the other.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct TileSet {
103    /// The filename stem every tile is named from.
104    pub base: String,
105    /// The whole volume's extent `[x, y, z]` — what the author asked for.
106    pub size: [i32; 3],
107    /// The per-axis cap the tiling had to respect.
108    pub part_max: i32,
109    /// How many tiles along each axis.
110    pub grid: [i32; 3],
111    /// The MC data version every tile targets (ADR-0009).
112    pub data_version: i32,
113    /// Provenance breadcrumb: what wrote the tiles.
114    #[serde(default)]
115    pub generator: String,
116    /// The tiles, in `x`→`y`→`z` grid order.
117    pub parts: Vec<TilePart>,
118}
119
120/// One tile of a [`TileSet`].
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct TilePart {
123    /// The `.nbt` filename, relative to the manifest.
124    pub file: String,
125    /// The datapack structure id (a path segment).
126    pub id: String,
127    /// Position in the tile grid.
128    pub grid_index: [i32; 3],
129    /// The tile's origin **in whole-volume coordinates** — add it to a
130    /// tile-local cell to get the volume cell. The only transform reassembly
131    /// needs.
132    pub offset: [i32; 3],
133    /// The tile's extent `[x, y, z]`, every axis `<= part_max`.
134    pub size: [i32; 3],
135}
136
137impl TileSet {
138    /// Refuse a manifest that does not describe an exact tiling of `size`.
139    ///
140    /// A consumer that skips this reassembles a volume with a hole in it and
141    /// reports success — the manifest is data on disk, and a truncated or
142    /// hand-edited one must be a refusal rather than a quietly smaller
143    /// building. Checks that every part lies inside the volume, that no axis
144    /// exceeds `part_max`, and that the parts' volumes sum to the whole.
145    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
182/// Read a prefab metadata file and say whether it describes a tile set.
183///
184/// `Ok(None)` means an ordinary single-template prefab — the caller carries on
185/// exactly as before. `Ok(Some(_))` is a validated tile set. `Err` is a
186/// malformed file, never a shrug: every consumer that opens prefab metadata
187/// must be able to tell the two shapes apart, and the way a tool "handles" a
188/// tile set it has never heard of is by reading none of its blocks.
189///
190/// **It reads the whole document through [`crate::prefab::PrefabMeta`], not a
191/// private view of one key.** A narrow reader here was a second reader: it
192/// accepted a document that declares no blocks at all as "not a tile set", and
193/// it validated the manifest on a path the prefab registry never took. One
194/// reader means a manifest is refused the same way by the renderer, the
195/// admission tools and world assembly, or by none of them.
196pub 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
223/// The base name and grid index a tile filename spells, if it spells one.
224///
225/// [`part_filename`] is the only thing that writes this shape, and it is the
226/// half of a tile's identity that **travels with the bytes**: a `cp`, an `mv`,
227/// an upload and a download all carry it, and nothing but a deliberate rename
228/// removes it.
229pub 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/// What a single `.nbt` path turned out to be: a whole template, or one tile of
239/// a tiling.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub enum TileEvidence {
242    /// Nothing about this file says it is a fragment.
243    Whole,
244    /// It is a tile. `manifest` is its set's manifest when that file is beside
245    /// it, and `None` when the tile has been separated from its set.
246    Tile {
247        /// The zone's base name.
248        base: String,
249        /// The manifest naming it, when one is beside it.
250        manifest: Option<std::path::PathBuf>,
251    },
252}
253
254/// Decide whether a single `.nbt` is one tile of a tiled zone.
255///
256/// Every tool that takes a single `.nbt` needs this, because every one of them
257/// will otherwise be handed a tile some day and answer about the fragment: the
258/// renderer draws a building sliced at a packaging plane, the auditor returns
259/// `"pass"` over a fifth of a zone, the light probe measures a fifth of a
260/// building and writes the answer into a metadata file it had to invent. All
261/// are answers, all are wrong, and none has any other detector. So the check
262/// lives here, once, beside the tiling it is about.
263///
264/// **The evidence is the file's own name, and the manifest only adds to it.**
265/// Binding the check to a sibling file was the defect: `cp tile.nbt elsewhere/`
266/// left a fragment that every tool then accepted as a whole prefab, because the
267/// only thing that knew otherwise had been left behind in the old directory. A
268/// guard that a copy defeats is not a property of the artifact. The name is —
269/// it is written by [`part_filename`], it is carried by the bytes wherever they
270/// go, and a *whole* prefab cannot accidentally acquire it, because
271/// `<base>.x<i>y<j>z<k>.nbt` is a shape no author writes by hand.
272///
273/// The manifest is still looked up, because a diagnostic that can say *which*
274/// zone this is a tile of and what to run instead is worth far more than one
275/// that can only refuse. Its absence downgrades the message, never the verdict.
276pub 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
292/// The refusal a whole-piece tool owes a fragment: one sentence saying what the
293/// file is, what answering about it would mean, and what to do instead.
294///
295/// One text because it is one fact. `verb` is what the caller would have done
296/// ("audit", "probe", "render"), and `consequence` is what that answer would
297/// have been read as.
298pub 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
325/// The tile-set manifest that names `nbt_path` as one of its tiles, if any.
326pub 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
333/// Render the split manifest as pretty JSON (deterministic — fixed field order,
334/// parts in grid order).
335pub 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    /// The tiling `plan_split` produces always validates. This is the binding
391    /// between the producer and the check every consumer runs: if the two ever
392    /// disagreed, every tiled export would be refused by every reader.
393    #[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    /// A manifest whose parts do not cover the volume is refused. A consumer
428    /// that skipped this would reassemble a building with a hole in it and
429    /// report success — the failure that has no other detector.
430    #[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        // ...and so is one that covers the right NUMBER of cells twice over.
437        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    /// A part bigger than the cap it declares cannot be a structure template,
453    /// so it is a refusal before any file is opened.
454    #[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    /// An empty manifest is a manifest that describes nothing, not a zone with
467    /// no blocks.
468    #[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    /// A tile is recognised by the name it carries, so a copy or a move cannot
475    /// launder it into a whole prefab.
476    ///
477    /// This is the whole point of keying the check to the artifact. Under the
478    /// old sibling-lookup rule the second assertion here returned "not a tile",
479    /// and every whole-piece tool then answered confidently about a fragment.
480    #[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        // beside its manifest: a tile, and the manifest is named.
503        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        // copied away from it: STILL a tile. Nothing beside it says so.
512        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        // an ordinary prefab is untouched by any of this.
527        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        // the refusal names what to do instead, and says which case it is.
543        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    /// `read_tile_set` tells the two metadata shapes apart, and says so rather
564    /// than shrugging: a single-template prefab is `None` (the caller carries
565    /// on), a tile set is `Some`, and neither is ever an empty success.
566    #[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        // A manifest that does not tile its volume is an error at the READER,
596        // not a surprise three steps later inside a reassembler.
597        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}