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 schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21/// One grid cell of a split.
22#[derive(Debug, Clone, PartialEq)]
23pub struct Part {
24    pub grid_index: [i32; 3],
25    /// Source-local origin of this part.
26    pub offset: [i32; 3],
27    pub size: [i32; 3],
28}
29
30/// The plan for tiling a `source_size` volume with a `part_max`-cube cap.
31#[derive(Debug, Clone)]
32pub struct SplitPlan {
33    pub grid: [i32; 3],
34    pub parts: Vec<Part>,
35}
36
37impl SplitPlan {
38    /// True when the volume fits in a single part.
39    pub fn is_single(&self) -> bool {
40        self.grid == [1, 1, 1]
41    }
42}
43
44fn ceil_div(a: i32, b: i32) -> i32 {
45    (a + b - 1) / b
46}
47
48/// Compute a split plan. Parts are emitted in x -> y -> z grid order.
49pub fn plan_split(size: [i32; 3], part_max: i32) -> SplitPlan {
50    let max = part_max.max(1);
51    let grid = [
52        ceil_div(size[0], max).max(1),
53        ceil_div(size[1], max).max(1),
54        ceil_div(size[2], max).max(1),
55    ];
56    let mut parts = Vec::with_capacity((grid[0] * grid[1] * grid[2]) as usize);
57    for i in 0..grid[0] {
58        for j in 0..grid[1] {
59            for k in 0..grid[2] {
60                let offset = [i * max, j * max, k * max];
61                let part_size = [
62                    (size[0] - offset[0]).min(max),
63                    (size[1] - offset[1]).min(max),
64                    (size[2] - offset[2]).min(max),
65                ];
66                parts.push(Part {
67                    grid_index: [i, j, k],
68                    offset,
69                    size: part_size,
70                });
71            }
72        }
73    }
74    SplitPlan { grid, parts }
75}
76
77/// Part filename for a base name: `<base>.x<i>y<j>z<k>.nbt`.
78pub fn part_filename(base: &str, grid_index: [i32; 3]) -> String {
79    format!(
80        "{base}.x{}y{}z{}.nbt",
81        grid_index[0], grid_index[1], grid_index[2]
82    )
83}
84
85/// Manifest filename: `<base>.split.json`.
86pub fn manifest_filename(base: &str) -> String {
87    format!("{base}.split.json")
88}
89
90// ---------------------------------------------------------------------------
91// The tile-set manifest contract
92// ---------------------------------------------------------------------------
93
94/// A volume packaged as several structure templates.
95///
96/// This is the `structure_set` block of a tiled prefab's metadata file, and it
97/// is the whole contract: given it and the `.nbt` files it names, a consumer can
98/// rebuild the original volume without knowing anything about who tiled it or
99/// why. It is `Serialize` **and** `Deserialize` on purpose — the producer and
100/// the consumer share one definition, so a field cannot be added on one side and
101/// missed on the other.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
103pub struct TileSet {
104    /// The filename stem every tile is named from.
105    pub base: String,
106    /// The whole volume's extent `[x, y, z]` — what the author asked for.
107    pub size: [i32; 3],
108    /// The per-axis cap the tiling had to respect.
109    pub part_max: i32,
110    /// How many tiles along each axis.
111    pub grid: [i32; 3],
112    /// The MC data version every tile targets (ADR-0009).
113    pub data_version: i32,
114    /// Provenance breadcrumb: what wrote the tiles.
115    #[serde(default)]
116    pub generator: String,
117    /// The tiles, in `x`→`y`→`z` grid order.
118    pub parts: Vec<TilePart>,
119}
120
121/// One tile of a [`TileSet`].
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
123pub struct TilePart {
124    /// The `.nbt` filename, relative to the manifest.
125    pub file: String,
126    /// The datapack structure id (a path segment).
127    pub id: String,
128    /// Position in the tile grid.
129    pub grid_index: [i32; 3],
130    /// The tile's origin **in whole-volume coordinates** — add it to a
131    /// tile-local cell to get the volume cell. The only transform reassembly
132    /// needs.
133    pub offset: [i32; 3],
134    /// The tile's extent `[x, y, z]`, every axis `<= part_max`.
135    pub size: [i32; 3],
136}
137
138impl TileSet {
139    /// Refuse a manifest that does not describe an exact tiling of `size`.
140    ///
141    /// A consumer that skips this reassembles a volume with a hole in it and
142    /// reports success — the manifest is data on disk, and a truncated or
143    /// hand-edited one must be a refusal rather than a quietly smaller
144    /// building. Checks that every part lies inside the volume, that no axis
145    /// exceeds `part_max`, and that the parts' volumes sum to the whole.
146    pub fn validate(&self) -> Result<(), String> {
147        if self.parts.is_empty() {
148            return Err("the manifest lists no tiles".to_string());
149        }
150        let mut covered: i64 = 0;
151        for part in &self.parts {
152            for axis in 0..3 {
153                if part.size[axis] <= 0 {
154                    return Err(format!("tile {:?} has a zero or negative axis", part.file));
155                }
156                if part.size[axis] > self.part_max {
157                    return Err(format!(
158                        "tile {:?} is {} on axis {axis}, past the declared cap of {}",
159                        part.file, part.size[axis], self.part_max
160                    ));
161                }
162                if part.offset[axis] < 0 || part.offset[axis] + part.size[axis] > self.size[axis] {
163                    return Err(format!(
164                        "tile {:?} runs outside the {}x{}x{} volume on axis {axis}",
165                        part.file, self.size[0], self.size[1], self.size[2]
166                    ));
167                }
168            }
169            covered += part.size[0] as i64 * part.size[1] as i64 * part.size[2] as i64;
170        }
171        let whole = self.size[0] as i64 * self.size[1] as i64 * self.size[2] as i64;
172        if covered != whole {
173            return Err(format!(
174                "the {} tile(s) cover {covered} cell(s) of a {whole}-cell volume — a tile set that \
175                 does not tile its volume exactly would reassemble with a hole or an overlap",
176                self.parts.len()
177            ));
178        }
179        Ok(())
180    }
181}
182
183/// Read a prefab metadata file and say whether it describes a tile set.
184///
185/// `Ok(None)` means an ordinary single-template prefab — the caller carries on
186/// exactly as before. `Ok(Some(_))` is a validated tile set. `Err` is a
187/// malformed file, never a shrug: every consumer that opens prefab metadata
188/// must be able to tell the two shapes apart, and the way a tool "handles" a
189/// tile set it has never heard of is by reading none of its blocks.
190///
191/// **It reads the whole document through [`crate::prefab::PrefabMeta`], not a
192/// private view of one key.** A narrow reader here was a second reader: it
193/// accepted a document that declares no blocks at all as "not a tile set", and
194/// it validated the manifest on a path the prefab registry never took. One
195/// reader means a manifest is refused the same way by the renderer, the
196/// admission tools and world assembly, or by none of them.
197pub fn read_tile_set(path: &Path) -> Result<Option<TileSet>, String> {
198    let text =
199        std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
200    let meta = crate::prefab::PrefabMeta::from_json(&text)
201        .map_err(|e| format!("{}: {e}", path.display()))?;
202    Ok(meta.structure_set)
203}
204
205#[derive(Serialize)]
206struct PartManifest {
207    file: String,
208    grid_index: [i32; 3],
209    offset: [i32; 3],
210    size: [i32; 3],
211}
212
213#[derive(Serialize)]
214struct SplitManifest {
215    base: String,
216    data_version: i32,
217    source_size: [i32; 3],
218    source_offset: [i32; 3],
219    part_max: i32,
220    grid: [i32; 3],
221    parts: Vec<PartManifest>,
222}
223
224/// The base name and grid index a tile filename spells, if it spells one.
225///
226/// [`part_filename`] is the only thing that writes this shape, and it is the
227/// half of a tile's identity that **travels with the bytes**: a `cp`, an `mv`,
228/// an upload and a download all carry it, and nothing but a deliberate rename
229/// removes it.
230pub fn tile_filename(name: &str) -> Option<(&str, [i32; 3])> {
231    let stem = name.strip_suffix(".nbt")?;
232    let (base, suffix) = stem.rsplit_once('.')?;
233    let rest = suffix.strip_prefix('x')?;
234    let (i, rest) = rest.split_once('y')?;
235    let (j, k) = rest.split_once('z')?;
236    Some((base, [i.parse().ok()?, j.parse().ok()?, k.parse().ok()?]))
237}
238
239/// What a single `.nbt` path turned out to be: a whole template, or one tile of
240/// a tiling.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum TileEvidence {
243    /// Nothing about this file says it is a fragment.
244    Whole,
245    /// It is a tile. `manifest` is its set's manifest when that file is beside
246    /// it, and `None` when the tile has been separated from its set.
247    Tile {
248        /// The zone's base name.
249        base: String,
250        /// The manifest naming it, when one is beside it.
251        manifest: Option<std::path::PathBuf>,
252    },
253}
254
255/// Decide whether a single `.nbt` is one tile of a tiled zone.
256///
257/// Every tool that takes a single `.nbt` needs this, because every one of them
258/// will otherwise be handed a tile some day and answer about the fragment: the
259/// renderer draws a building sliced at a packaging plane, the auditor returns
260/// `"pass"` over a fifth of a zone, the light probe measures a fifth of a
261/// building and writes the answer into a metadata file it had to invent. All
262/// are answers, all are wrong, and none has any other detector. So the check
263/// lives here, once, beside the tiling it is about.
264///
265/// **The evidence is the file's own name, and the manifest only adds to it.**
266/// Binding the check to a sibling file was the defect: `cp tile.nbt elsewhere/`
267/// left a fragment that every tool then accepted as a whole prefab, because the
268/// only thing that knew otherwise had been left behind in the old directory. A
269/// guard that a copy defeats is not a property of the artifact. The name is —
270/// it is written by [`part_filename`], it is carried by the bytes wherever they
271/// go, and a *whole* prefab cannot accidentally acquire it, because
272/// `<base>.x<i>y<j>z<k>.nbt` is a shape no author writes by hand.
273///
274/// The manifest is still looked up, because a diagnostic that can say *which*
275/// zone this is a tile of and what to run instead is worth far more than one
276/// that can only refuse. Its absence downgrades the message, never the verdict.
277pub fn tile_evidence(nbt_path: &Path) -> Result<TileEvidence, String> {
278    let Some(name) = nbt_path.file_name().and_then(|s| s.to_str()) else {
279        return Ok(TileEvidence::Whole);
280    };
281    let Some((base, _)) = tile_filename(name) else {
282        return Ok(TileEvidence::Whole);
283    };
284    let manifest = nbt_path.with_file_name(format!("{base}.json"));
285    let claimed = manifest.exists()
286        && read_tile_set(&manifest)?.is_some_and(|set| set.parts.iter().any(|p| p.file == name));
287    Ok(TileEvidence::Tile {
288        base: base.to_string(),
289        manifest: claimed.then_some(manifest),
290    })
291}
292
293/// The refusal a whole-piece tool owes a fragment: one sentence saying what the
294/// file is, what answering about it would mean, and what to do instead.
295///
296/// One text because it is one fact. `verb` is what the caller would have done
297/// ("audit", "probe", "render"), and `consequence` is what that answer would
298/// have been read as.
299pub fn fragment_refusal(
300    nbt_path: &Path,
301    evidence: &TileEvidence,
302    verb: &str,
303    consequence: &str,
304) -> Option<String> {
305    let TileEvidence::Tile { base, manifest } = evidence else {
306        return None;
307    };
308    Some(match manifest {
309        Some(m) => format!(
310            "{} is one tile of the zone described by {} — to {verb} it would {consequence}. \
311             Use the whole zone: pass {}",
312            nbt_path.display(),
313            m.display(),
314            m.display()
315        ),
316        None => format!(
317            "{} is one tile of a tiled zone (`{base}`) that has been separated from its set — to \
318             {verb} it would {consequence}, and its manifest is not beside it, so there is \
319             nothing here to reassemble the zone from. Put the tile back with its `{base}.json` \
320             manifest and the rest of its tiles, and pass the manifest",
321            nbt_path.display()
322        ),
323    })
324}
325
326/// The tile-set manifest that names `nbt_path` as one of its tiles, if any.
327pub fn manifest_claiming(nbt_path: &Path) -> Result<Option<std::path::PathBuf>, String> {
328    match tile_evidence(nbt_path)? {
329        TileEvidence::Tile { manifest, .. } => Ok(manifest),
330        TileEvidence::Whole => Ok(None),
331    }
332}
333
334/// Render the split manifest as pretty JSON (deterministic — fixed field order,
335/// parts in grid order).
336pub fn manifest_json(
337    base: &str,
338    data_version: i32,
339    source_size: [i32; 3],
340    source_offset: [i32; 3],
341    part_max: i32,
342    plan: &SplitPlan,
343) -> String {
344    let manifest = SplitManifest {
345        base: base.to_string(),
346        data_version,
347        source_size,
348        source_offset,
349        part_max,
350        grid: plan.grid,
351        parts: plan
352            .parts
353            .iter()
354            .map(|p| PartManifest {
355                file: part_filename(base, p.grid_index),
356                grid_index: p.grid_index,
357                offset: p.offset,
358                size: p.size,
359            })
360            .collect(),
361    };
362    serde_json::to_string_pretty(&manifest).expect("manifest serializes")
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn tile_set(size: [i32; 3], parts: Vec<([i32; 3], [i32; 3])>) -> TileSet {
370        TileSet {
371            base: "zone".to_string(),
372            size,
373            part_max: 48,
374            grid: [1, 1, parts.len() as i32],
375            data_version: 4671,
376            generator: "crates/delvec/src/grammar".to_string(),
377            parts: parts
378                .into_iter()
379                .enumerate()
380                .map(|(i, (offset, size))| TilePart {
381                    file: format!("zone.x0y0z{i}.nbt"),
382                    id: format!("zone.x0y0z{i}"),
383                    grid_index: [0, 0, i as i32],
384                    offset,
385                    size,
386                })
387                .collect(),
388        }
389    }
390
391    /// The tiling `plan_split` produces always validates. This is the binding
392    /// between the producer and the check every consumer runs: if the two ever
393    /// disagreed, every tiled export would be refused by every reader.
394    #[test]
395    fn every_plan_split_tiling_validates() {
396        for size in [
397            [1, 1, 1],
398            [48, 48, 48],
399            [49, 1, 1],
400            [20, 10, 84],
401            [90, 14, 130],
402            [200, 100, 200],
403        ] {
404            let plan = plan_split(size, 48);
405            let set = TileSet {
406                base: "zone".to_string(),
407                size,
408                part_max: 48,
409                grid: plan.grid,
410                data_version: 4671,
411                generator: String::new(),
412                parts: plan
413                    .parts
414                    .iter()
415                    .map(|p| TilePart {
416                        file: part_filename("zone", p.grid_index),
417                        id: format!("zone{:?}", p.grid_index),
418                        grid_index: p.grid_index,
419                        offset: p.offset,
420                        size: p.size,
421                    })
422                    .collect(),
423            };
424            assert_eq!(set.validate(), Ok(()), "{size:?}");
425        }
426    }
427
428    /// A manifest whose parts do not cover the volume is refused. A consumer
429    /// that skipped this would reassemble a building with a hole in it and
430    /// report success — the failure that has no other detector.
431    #[test]
432    fn a_tiling_with_a_gap_is_refused() {
433        let short = tile_set([4, 4, 100], vec![([0, 0, 0], [4, 4, 48])]);
434        let err = short.validate().unwrap_err();
435        assert!(err.contains("cover"), "{err}");
436
437        // ...and so is one that covers the right NUMBER of cells twice over.
438        let overlap = tile_set(
439            [4, 4, 48],
440            vec![([0, 0, 0], [4, 4, 24]), ([0, 0, 0], [4, 4, 24])],
441        );
442        assert_eq!(overlap.validate(), Ok(()), "volume alone cannot see this");
443        let outside = tile_set(
444            [4, 4, 48],
445            vec![([0, 0, 0], [4, 4, 24]), ([0, 0, 40], [4, 4, 24])],
446        );
447        assert!(
448            outside.validate().unwrap_err().contains("outside"),
449            "a part running past the volume is caught"
450        );
451    }
452
453    /// A part bigger than the cap it declares cannot be a structure template,
454    /// so it is a refusal before any file is opened.
455    #[test]
456    fn a_part_past_the_declared_cap_is_refused() {
457        let big = tile_set([4, 4, 49], vec![([0, 0, 0], [4, 4, 49])]);
458        assert!(
459            big.validate()
460                .unwrap_err()
461                .contains("past the declared cap"),
462            "{:?}",
463            big.validate()
464        );
465    }
466
467    /// An empty manifest is a manifest that describes nothing, not a zone with
468    /// no blocks.
469    #[test]
470    fn a_manifest_with_no_tiles_is_refused() {
471        let empty = tile_set([4, 4, 4], vec![]);
472        assert!(empty.validate().unwrap_err().contains("no tiles"));
473    }
474
475    /// A tile is recognised by the name it carries, so a copy or a move cannot
476    /// launder it into a whole prefab.
477    ///
478    /// This is the whole point of keying the check to the artifact. Under the
479    /// old sibling-lookup rule the second assertion here returned "not a tile",
480    /// and every whole-piece tool then answered confidently about a fragment.
481    #[test]
482    fn a_tile_is_recognised_by_its_own_name_wherever_it_is_put() {
483        let dir = std::env::temp_dir().join(format!("dw-split-evid-{}", std::process::id()));
484        let _ = std::fs::remove_dir_all(&dir);
485        std::fs::create_dir_all(dir.join("elsewhere")).unwrap();
486
487        let set = tile_set(
488            [4, 4, 60],
489            vec![([0, 0, 0], [4, 4, 48]), ([0, 0, 48], [4, 4, 12])],
490        );
491        std::fs::write(
492            dir.join("zone.json"),
493            serde_json::to_string(
494                &serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": set }),
495            )
496            .unwrap(),
497        )
498        .unwrap();
499        for part in &set.parts {
500            std::fs::write(dir.join(&part.file), b"not really nbt").unwrap();
501        }
502
503        // beside its manifest: a tile, and the manifest is named.
504        assert_eq!(
505            tile_evidence(&dir.join("zone.x0y0z1.nbt")).unwrap(),
506            TileEvidence::Tile {
507                base: "zone".to_string(),
508                manifest: Some(dir.join("zone.json")),
509            }
510        );
511
512        // copied away from it: STILL a tile. Nothing beside it says so.
513        std::fs::copy(
514            dir.join("zone.x0y0z1.nbt"),
515            dir.join("elsewhere/zone.x0y0z1.nbt"),
516        )
517        .unwrap();
518        assert_eq!(
519            tile_evidence(&dir.join("elsewhere/zone.x0y0z1.nbt")).unwrap(),
520            TileEvidence::Tile {
521                base: "zone".to_string(),
522                manifest: None,
523            },
524            "a guard a `cp` defeats is not a property of the artifact"
525        );
526
527        // an ordinary prefab is untouched by any of this.
528        assert_eq!(
529            tile_evidence(&dir.join("keep-gate-room.nbt")).unwrap(),
530            TileEvidence::Whole
531        );
532        assert_eq!(tile_filename("keep-gate-room.nbt"), None);
533        assert_eq!(
534            tile_filename("zone.x0y10z2.nbt"),
535            Some(("zone", [0, 10, 2]))
536        );
537        assert_eq!(
538            tile_filename("cave.mouth.nbt"),
539            None,
540            "a dotted name is not a grid suffix"
541        );
542
543        // the refusal names what to do instead, and says which case it is.
544        let beside = tile_evidence(&dir.join("zone.x0y0z0.nbt")).unwrap();
545        let msg = fragment_refusal(&dir.join("zone.x0y0z0.nbt"), &beside, "audit", "lie").unwrap();
546        assert!(msg.contains("zone.json"), "{msg}");
547        let orphan = tile_evidence(&dir.join("elsewhere/zone.x0y0z0.nbt")).unwrap();
548        let msg = fragment_refusal(
549            &dir.join("elsewhere/zone.x0y0z0.nbt"),
550            &orphan,
551            "audit",
552            "lie",
553        )
554        .unwrap();
555        assert!(msg.contains("separated from its set"), "{msg}");
556        assert_eq!(
557            fragment_refusal(&dir, &TileEvidence::Whole, "audit", "lie"),
558            None
559        );
560
561        std::fs::remove_dir_all(&dir).unwrap();
562    }
563
564    /// `read_tile_set` tells the two metadata shapes apart, and says so rather
565    /// than shrugging: a single-template prefab is `None` (the caller carries
566    /// on), a tile set is `Some`, and neither is ever an empty success.
567    #[test]
568    fn the_two_metadata_shapes_are_told_apart() {
569        let dir = std::env::temp_dir().join(format!("dw-split-shape-{}", std::process::id()));
570        let _ = std::fs::remove_dir_all(&dir);
571        std::fs::create_dir_all(&dir).unwrap();
572
573        let single = dir.join("single.json");
574        std::fs::write(
575            &single,
576            r#"{"prefab_id":"prefab/x","structure":{"file":"x.nbt","id":"x","size":[2,2,2],"data_version":4671}}"#,
577        )
578        .unwrap();
579        assert_eq!(read_tile_set(&single).unwrap(), None);
580
581        let set = tile_set(
582            [4, 4, 60],
583            vec![([0, 0, 0], [4, 4, 48]), ([0, 0, 48], [4, 4, 12])],
584        );
585        let tiled = dir.join("tiled.json");
586        std::fs::write(
587            &tiled,
588            serde_json::to_string(
589                &serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": set }),
590            )
591            .unwrap(),
592        )
593        .unwrap();
594        assert_eq!(read_tile_set(&tiled).unwrap().unwrap(), set);
595
596        // A manifest that does not tile its volume is an error at the READER,
597        // not a surprise three steps later inside a reassembler.
598        let broken = dir.join("broken.json");
599        let bad = tile_set([4, 4, 60], vec![([0, 0, 0], [4, 4, 48])]);
600        std::fs::write(
601            &broken,
602            serde_json::to_string(
603                &serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": bad }),
604            )
605            .unwrap(),
606        )
607        .unwrap();
608        assert!(read_tile_set(&broken).unwrap_err().contains("cover"));
609
610        std::fs::remove_dir_all(&dir).unwrap();
611    }
612}