Skip to main content

tilewright_source/
lib.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! Loads and validates a Tilewright world source tree
5//! (`docs/spec/FORMAT.md`) into a typed, game-neutral [`World`].
6//!
7//! Loading is bounded and fail-closed: only the files the format defines are
8//! read, symbolic links and unexpected files are errors, every document is
9//! parsed as strict JSONC under the format's limits, and every problem is
10//! reported as a [`Diagnostic`] with its file, byte span and a stable code.
11//! A world is returned only when there are no diagnostics at all.
12//!
13//! A profile's terrain transitions ([`transitions`]) derive edge, corner and
14//! variant tiles from painted terrain; [`Transitions::resolve_chunk`] is the
15//! one resolver the compiler, editors and other tools share.
16//!
17//! The root path is resolved as the caller gives it and may be a link;
18//! nothing beneath it is followed. On Unix every path component beneath the
19//! root is opened through its parent directory's handle without following
20//! symbolic links, so a link swapped into the tree during a load is never
21//! followed. Other platforms check each component by
22//! path before using it; that is a best effort which a local process changing
23//! the tree during the load can race. Race-free loading is Unix-only.
24//!
25//! ```no_run
26//! let world = tilewright_source::load(std::path::Path::new("content"))
27//!     .map_err(|diagnostics| diagnostics.len())?;
28//! println!("{} planes", world.planes().len());
29//! # Ok::<(), usize>(())
30//! ```
31
32mod access;
33mod chunk;
34mod defs;
35mod diagnostic;
36mod files;
37mod plane;
38mod profile;
39mod read;
40mod schema;
41mod stairwell;
42pub mod transitions;
43mod uid;
44
45use std::{collections::BTreeMap, path::Path, sync::Arc};
46use tilewright_jsonc::Limits;
47
48pub use access::{
49    Access, AccessError, Audience, LOCK_NAME, Mounts, SourceDirectory, SourceLock, SourceRoot,
50};
51use diagnostic::Diagnostics;
52pub use diagnostic::{Code, Diagnostic};
53pub use files::is_jsonc_path;
54pub use read::{is_identifier, is_namespace};
55pub use schema::{FieldSpec, FieldType, MAXIMUM_FIELD_DEPTH, MAXIMUM_LIST_ITEMS, Schema};
56pub use transitions::{ResolvedChunk, TerrainSource, Transitions};
57
58/// Tiles along each side of a chunk in format 1.
59pub const CHUNK_SIZE: u16 = 32;
60
61/// Most ground layers a profile may declare.
62pub const MAXIMUM_GROUND_LAYERS: usize = 8;
63
64/// Largest `layer_height` a profile may declare. A stacked elevation,
65/// `layer × layer_height + height + z`, then stays within the format's
66/// integer range for every 32-bit layer, height and `z`.
67pub const MAXIMUM_LAYER_HEIGHT: u32 = 65_535;
68
69/// Bounds on what a load may read.
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub struct LoadOptions {
72    /// Limits for every JSONC document. They can only tighten the format's
73    /// limits: a value above [`Limits::default`] is lowered to it.
74    pub limits: Limits,
75    /// Most files read in one load.
76    pub maximum_files: usize,
77    /// Most directory entries listed in one load, counted before they are
78    /// examined, a directory that holds no file, which the layout ignores,
79    /// as much as any. Publishing a transaction lists the directories on
80    /// the way to its files against as large a budget, `profile/` included,
81    /// so planning or publishing its undo can need one more entry for each
82    /// directory it made (`docs/spec/TRANSACTIONS.md` section 8.1).
83    pub maximum_entries: usize,
84    /// Most bytes read in one load.
85    pub maximum_total_bytes: u64,
86    /// Most diagnostics kept, at least one; past this, one `source.budget`
87    /// diagnostic notes that the rest were dropped.
88    pub maximum_diagnostics: usize,
89}
90
91impl Default for LoadOptions {
92    fn default() -> Self {
93        Self {
94            limits: Limits::default(),
95            maximum_files: 500_000,
96            maximum_entries: 1_000_000,
97            maximum_total_bytes: 8 * 1024 * 1024 * 1024,
98            maximum_diagnostics: 10_000,
99        }
100    }
101}
102
103/// A validated world source tree.
104///
105/// A `World` can only be produced by [`load`] or [`load_with`], so every one
106/// has passed validation; its parts are read through accessors and cannot be
107/// changed in place.
108///
109/// ```compile_fail
110/// # fn planes(world: tilewright_source::World) {
111/// let planes = world.planes; // the fields are private
112/// # }
113/// ```
114#[derive(Clone, Debug, PartialEq)]
115pub struct World {
116    profile: Profile,
117    planes: BTreeMap<String, Plane>,
118    definitions: BTreeMap<(String, String), Definition>,
119    object_uids: BTreeMap<String, ObjectLocation>,
120    source_digest: [u8; 32],
121}
122
123impl World {
124    #[must_use]
125    pub const fn profile(&self) -> &Profile {
126        &self.profile
127    }
128
129    /// Planes by ID.
130    #[must_use]
131    pub const fn planes(&self) -> &BTreeMap<String, Plane> {
132        &self.planes
133    }
134
135    /// Definitions keyed by `(type, id)`.
136    #[must_use]
137    pub const fn definitions(&self) -> &BTreeMap<(String, String), Definition> {
138        &self.definitions
139    }
140
141    /// SHA-256 over every file read, as defined in `docs/spec/PACK.md`.
142    #[must_use]
143    pub const fn source_digest(&self) -> [u8; 32] {
144        self.source_digest
145    }
146
147    /// Every object uid of the world, with where its object is. Uids are
148    /// unique in the whole world, so a uid alone names one object.
149    #[must_use]
150    pub const fn object_uids(&self) -> &BTreeMap<String, ObjectLocation> {
151        &self.object_uids
152    }
153
154    /// Where the object with `uid` is.
155    #[must_use]
156    pub fn object_location(&self, uid: &str) -> Option<&ObjectLocation> {
157        self.object_uids.get(uid)
158    }
159
160    /// The object with `uid`.
161    #[must_use]
162    pub fn object(&self, uid: &str) -> Option<&Object> {
163        let location = self.object_uids.get(uid)?;
164        self.planes
165            .get(&location.plane)?
166            .chunks
167            .get(&location.chunk)?
168            .object(&location.path)
169    }
170}
171
172/// Where an object is: its chunk, and its place in the chunk.
173#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
174pub struct ObjectLocation {
175    pub plane: String,
176    /// The chunk's key in [`Plane::chunks`]: `(layer, cx, cy)`.
177    pub chunk: (i32, i64, i64),
178    /// The object's index in the chunk's `objects`, then, for an inventory
179    /// entry, its index in the inventory of each object down to it. A
180    /// top-level object's path has one index.
181    pub path: Vec<usize>,
182}
183
184/// The game profile.
185#[derive(Clone, Debug, PartialEq)]
186pub struct Profile {
187    pub id: String,
188    pub version: String,
189    pub projection: Projection,
190    pub render_layers: Vec<String>,
191    /// Ground layers in profile order; each chunk stores one grid per entry.
192    pub ground_layers: Vec<String>,
193    /// Schemas by definition type. Roles that name the same schema path
194    /// share one schema.
195    pub definition_types: BTreeMap<String, Arc<Schema>>,
196    pub object_fields: Arc<Schema>,
197    pub zone_properties: Arc<Schema>,
198    /// Height units per layer: layer `L` sits at `L × layer_height`, and
199    /// tile heights stay relative to their layer. `None` when the profile
200    /// does not stack layers.
201    pub layer_height: Option<u32>,
202    pub movement: Option<Movement>,
203    /// The terrain transitions the profile names (`docs/spec/FORMAT.md`
204    /// section 13), which derive edge, corner and variant tiles from
205    /// painted terrain.
206    pub transitions: Option<Arc<Transitions>>,
207    /// Canonical JSON of the profile document.
208    pub canonical: String,
209}
210
211impl Profile {
212    /// The position of a render layer in the profile's order.
213    #[must_use]
214    pub fn render_layer_index(&self, name: &str) -> Option<usize> {
215        self.render_layers.iter().position(|layer| layer == name)
216    }
217}
218
219#[derive(Clone, Copy, Debug, Eq, PartialEq)]
220pub enum Projection {
221    Isometric { tile_width: u32, tile_height: u32 },
222    TopDown { tile_size: u32 },
223}
224
225/// Movement analysis rules for the editor; the format does not interpret them.
226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
227pub struct Movement {
228    pub max_step: u32,
229    pub fall_unit: u32,
230}
231
232#[derive(Clone, Copy, Debug, Eq, PartialEq)]
233pub enum PlaneKind {
234    Shared,
235    Instanced,
236    PerPlayer,
237}
238
239impl PlaneKind {
240    #[must_use]
241    pub const fn as_str(self) -> &'static str {
242        match self {
243            Self::Shared => "shared",
244            Self::Instanced => "instanced",
245            Self::PerPlayer => "per_player",
246        }
247    }
248}
249
250/// One plane with its zones, anchors, stairwells and chunks.
251#[derive(Clone, Debug, PartialEq)]
252pub struct Plane {
253    pub id: String,
254    pub title: Option<String>,
255    pub kind: PlaneKind,
256    pub chunk_size: u16,
257    pub default_zone: Option<String>,
258    /// Canonical JSON of `plane.jsonc`.
259    pub canonical: String,
260    pub zones: BTreeMap<String, Zone>,
261    pub anchors: BTreeMap<String, Anchor>,
262    pub stairwells: BTreeMap<String, Stairwell>,
263    /// Chunks keyed by `(layer, cx, cy)`.
264    pub chunks: BTreeMap<(i32, i64, i64), Chunk>,
265}
266
267#[derive(Clone, Debug, PartialEq)]
268pub struct Zone {
269    pub id: String,
270    pub parent: Option<String>,
271    pub name: Option<String>,
272    /// Canonical JSON of the zone entry.
273    pub canonical: String,
274}
275
276#[derive(Clone, Copy, Debug, Eq, PartialEq)]
277pub struct Anchor {
278    pub layer: i32,
279    pub at: [i64; 2],
280}
281
282/// One exit spanning several layers: steps on adjacent layers, used end to
283/// end in one move.
284#[derive(Clone, Debug, Eq, PartialEq)]
285pub struct Stairwell {
286    pub id: String,
287    /// Steps in source order, at least two, each on the layer next to the
288    /// one before it, in one direction, and at most one tile away on each
289    /// axis.
290    pub steps: Vec<StairwellStep>,
291    pub direction: StairwellDirection,
292    /// Whether consumers draw only the step on the viewer's layer.
293    pub hidden_on_other_layers: bool,
294    /// Canonical JSON of the `custom` object.
295    pub custom: Option<String>,
296    /// Canonical JSON of the stairwell entry.
297    pub canonical: String,
298}
299
300impl Stairwell {
301    /// Whether `steps` form a stairwell: at least two, each on the layer
302    /// next to the one before it, all going up or all going down, and at
303    /// most one tile away from the step before it on each axis.
304    #[must_use]
305    pub fn connects(steps: &[StairwellStep]) -> bool {
306        let [first, second, ..] = steps else {
307            return false;
308        };
309        let up = second.layer > first.layer;
310        steps.windows(2).all(|pair| pair[0].leads_to(&pair[1], up))
311    }
312
313    /// The step on the lowest layer.
314    #[must_use]
315    pub fn lower_end(&self) -> Option<&StairwellStep> {
316        self.steps.iter().min_by_key(|step| step.layer)
317    }
318
319    /// The step on the highest layer.
320    #[must_use]
321    pub fn upper_end(&self) -> Option<&StairwellStep> {
322        self.steps.iter().max_by_key(|step| step.layer)
323    }
324}
325
326/// One step of a stairwell, in plane coordinates.
327#[derive(Clone, Debug, Eq, PartialEq)]
328pub struct StairwellStep {
329    pub layer: i32,
330    pub at: [i64; 2],
331    pub proto: String,
332}
333
334impl StairwellStep {
335    /// Whether `next` may follow this step in a stairwell that goes `up`
336    /// (or down): it is on the next layer that way and at most one tile
337    /// away on each axis.
338    #[must_use]
339    pub fn leads_to(&self, next: &Self, up: bool) -> bool {
340        adjacent((self.layer, self.at), (next.layer, next.at), up)
341    }
342}
343
344/// Whether a step at position `next` (layer and plane coordinates) may
345/// follow one at `before` in a stairwell that goes `up` (or down).
346pub(crate) fn adjacent(before: (i32, [i64; 2]), next: (i32, [i64; 2]), up: bool) -> bool {
347    let change = if up { 1 } else { -1 };
348    i64::from(next.0) - i64::from(before.0) == change
349        && before
350            .1
351            .iter()
352            .zip(next.1)
353            .all(|(before, after)| (i128::from(*before) - i128::from(after)).abs() <= 1)
354}
355
356/// Which way a stairwell may be used.
357#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
358pub enum StairwellDirection {
359    /// From either end to the other.
360    #[default]
361    Both,
362    /// Only from the upper end to the lower end.
363    Down,
364    /// Only from the lower end to the upper end.
365    Up,
366}
367
368impl StairwellDirection {
369    #[must_use]
370    pub const fn as_str(self) -> &'static str {
371        match self {
372            Self::Both => "both",
373            Self::Down => "down",
374            Self::Up => "up",
375        }
376    }
377
378    /// The direction a source or pack spells `text`.
379    #[must_use]
380    pub fn parse(text: &str) -> Option<Self> {
381        match text {
382            "both" => Some(Self::Both),
383            "down" => Some(Self::Down),
384            "up" => Some(Self::Up),
385            _ => None,
386        }
387    }
388}
389
390/// A character grid: one palette value per tile.
391#[derive(Clone, Debug, Default, Eq, PartialEq)]
392pub struct Grid {
393    /// Values in palette declaration order.
394    pub palette: Vec<String>,
395    /// `chunk_size²` cells, row-major; a cell is a palette index plus one,
396    /// or 0 for an empty tile.
397    pub cells: Vec<u16>,
398}
399
400impl Grid {
401    /// The value of a cell, or `None` for an empty tile.
402    #[must_use]
403    pub fn value(&self, index: usize) -> Option<&str> {
404        let cell = usize::from(*self.cells.get(index)?);
405        cell.checked_sub(1)
406            .and_then(|palette| self.palette.get(palette))
407            .map(String::as_str)
408    }
409}
410
411/// One chunk of one layer.
412#[derive(Clone, Debug, PartialEq)]
413pub struct Chunk {
414    /// Source path, relative to the root.
415    pub path: String,
416    /// One grid per profile ground layer, in profile order.
417    pub ground: Vec<Grid>,
418    /// `chunk_size²` tile heights, row-major.
419    pub heights: Vec<i32>,
420    /// Zone per tile; empty cells take the plane's default zone.
421    pub zones: Grid,
422    pub objects: Vec<Object>,
423}
424
425impl Chunk {
426    /// The object at `path`: an index into `objects`, then one into the
427    /// inventory of each object down to it, as in [`ObjectLocation::path`].
428    #[must_use]
429    pub fn object(&self, path: &[usize]) -> Option<&Object> {
430        let (first, rest) = path.split_first()?;
431        rest.iter()
432            .try_fold(self.objects.get(*first)?, |object, index| {
433                object.inventory.get(*index)
434            })
435    }
436
437    /// Every object of the chunk, inventory entries included, in
438    /// depth-first order: each object comes before its inventory entries,
439    /// and those before the object after it. An object's position in this
440    /// order is its index in the chunk (`docs/spec/FORMAT.md` section 7).
441    #[must_use]
442    pub fn depth_first(&self) -> DepthFirst<'_> {
443        DepthFirst {
444            levels: vec![self.objects.iter()],
445        }
446    }
447}
448
449/// The objects of a chunk in depth-first order; see [`Chunk::depth_first`].
450#[derive(Clone, Debug)]
451pub struct DepthFirst<'c> {
452    /// The objects left at each depth, outermost first.
453    levels: Vec<std::slice::Iter<'c, Object>>,
454}
455
456impl<'c> Iterator for DepthFirst<'c> {
457    type Item = &'c Object;
458
459    fn next(&mut self) -> Option<Self::Item> {
460        loop {
461            let level = self.levels.last_mut()?;
462            if let Some(object) = level.next() {
463                self.levels.push(object.inventory.iter());
464                return Some(object);
465            }
466            self.levels.pop();
467        }
468    }
469}
470
471/// A placed object or an inventory entry.
472#[derive(Clone, Debug, PartialEq)]
473pub struct Object {
474    /// The object's stable ID, unique in the world.
475    pub uid: Option<String>,
476    /// Chunk-local tile; `None` for inventory entries.
477    pub at: Option<[u16; 2]>,
478    pub proto: String,
479    pub layer: Option<String>,
480    pub sub_layer: i16,
481    pub z: i32,
482    pub fields: Vec<(String, FieldValue)>,
483    pub portal: Option<Portal>,
484    pub inventory: Vec<Object>,
485    /// Canonical JSON of the `custom` object.
486    pub custom: Option<String>,
487}
488
489/// A field value. Arrays and objects, the values of `json`, `list` and
490/// `object` fields, are kept as canonical JSON.
491#[derive(Clone, Debug, PartialEq)]
492pub enum FieldValue {
493    Null,
494    Bool(bool),
495    Integer(i64),
496    Number(f64),
497    String(String),
498    Json(String),
499}
500
501#[derive(Clone, Debug, Eq, PartialEq)]
502pub enum Portal {
503    Location {
504        plane: Option<String>,
505        layer: i32,
506        at: [i64; 2],
507    },
508    Anchor {
509        plane: Option<String>,
510        anchor: String,
511    },
512}
513
514#[derive(Clone, Debug, PartialEq)]
515pub struct Definition {
516    pub fields: Vec<(String, FieldValue)>,
517    /// Canonical JSON of the definition document.
518    pub canonical: String,
519}
520
521/// Loads a source tree with default bounds.
522///
523/// # Errors
524/// Returns every diagnostic found, sorted by path and position.
525pub fn load(root: &Path) -> Result<World, Vec<Diagnostic>> {
526    load_with(root, &LoadOptions::default())
527}
528
529/// Loads a source tree.
530///
531/// # Errors
532/// Returns every diagnostic found, sorted by path and position.
533pub fn load_with(root: &Path, options: &LoadOptions) -> Result<World, Vec<Diagnostic>> {
534    load_edited(root, options, &Overlay::new())
535}
536
537/// Edits applied over the files of a tree for [`load_edited`], keyed by
538/// root-relative `/`-separated path: `Some(text)` replaces or creates the
539/// file, and `None` removes it.
540pub type Overlay = BTreeMap<String, Option<String>>;
541
542/// Loads a source tree as it would be with `overlay` applied, without
543/// writing anything. Overlay files are read under the same limits and
544/// budget as files on disk, and a directory that holds one exists. One
545/// that the overlay leaves without a file holds none, and is ignored where
546/// the layout ignores such a directory (FORMAT.md section 2). An overlay of
547/// more entries than `maximum_entries` is refused before anything is read.
548///
549/// # Errors
550/// Returns every diagnostic found, sorted by path and position.
551pub fn load_edited(
552    root: &Path,
553    options: &LoadOptions,
554    overlay: &Overlay,
555) -> Result<World, Vec<Diagnostic>> {
556    load_edited_in(&SourceRoot::new(root), options, overlay)
557}
558
559/// Loads the tree of an opened root as [`load_edited`] does, through the
560/// handle rather than its path, so that what is validated is the directory
561/// the caller opened.
562///
563/// # Errors
564/// Returns every diagnostic found, sorted by path and position.
565pub fn load_edited_in(
566    root: &SourceRoot,
567    options: &LoadOptions,
568    overlay: &Overlay,
569) -> Result<World, Vec<Diagnostic>> {
570    // At least one diagnostic is kept, so a failed load always says why.
571    let options = &LoadOptions {
572        limits: within_format(options.limits),
573        maximum_diagnostics: options.maximum_diagnostics.max(1),
574        ..*options
575    };
576    // Each entry adds, replaces or removes a directory entry; more than a
577    // load may list cannot load, and is not looked at.
578    if overlay.len() > options.maximum_entries {
579        return Err(vec![Diagnostic::file(
580            ".",
581            Code::Budget,
582            format!(
583                "the edits hold more than {} entries",
584                options.maximum_entries
585            ),
586        )]);
587    }
588    // Every key must be a path a file could have, so no entry costs more
589    // than a path does.
590    if let Some(key) = overlay
591        .keys()
592        .find(|key| key.is_empty() || access::checked(key).is_err())
593    {
594        let shown = if key.len() <= 128 {
595            format!("{key:?}")
596        } else {
597            format!("a path of {} bytes", key.len())
598        };
599        return Err(vec![Diagnostic::file(
600            ".",
601            Code::Path,
602            format!("the edits name {shown}, which is not a path a file of the tree can have"),
603        )]);
604    }
605    let mut diagnostics = Diagnostics::new(options.maximum_diagnostics);
606    let mut tree = files::Tree::in_root(root.platform(), options, overlay, diagnostics.total());
607    let world = load_tree(&mut tree, &mut diagnostics);
608    diagnostics.append(tree.take_diagnostics());
609    match world {
610        Some(world) if diagnostics.is_empty() => Ok(World {
611            source_digest: tree.digest(),
612            ..world
613        }),
614        _ => Err(diagnostics.finish()),
615    }
616}
617
618/// `limits`, each lowered to the format's own limit where it is higher.
619fn within_format(limits: Limits) -> Limits {
620    let format = Limits::default();
621    Limits {
622        max_input_bytes: limits.max_input_bytes.min(format.max_input_bytes),
623        max_depth: limits.max_depth.min(format.max_depth),
624        max_values: limits.max_values.min(format.max_values),
625        max_array_items: limits.max_array_items.min(format.max_array_items),
626        max_object_keys: limits.max_object_keys.min(format.max_object_keys),
627        max_string_bytes: limits.max_string_bytes.min(format.max_string_bytes),
628        max_number_bytes: limits.max_number_bytes.min(format.max_number_bytes),
629        max_comments: limits.max_comments.min(format.max_comments),
630        max_comment_bytes: limits.max_comment_bytes.min(format.max_comment_bytes),
631        max_lines: limits.max_lines.min(format.max_lines),
632        max_line_bytes: limits.max_line_bytes.min(format.max_line_bytes),
633    }
634}
635
636fn load_tree(tree: &mut files::Tree, diagnostics: &mut Diagnostics) -> Option<World> {
637    let (manifest_path, plane_ids) = profile::read_manifest(tree, diagnostics)?;
638    let (mut profile, transitions_path) = profile::read_profile(tree, &manifest_path, diagnostics)?;
639    let mut references = schema::References::new(&profile);
640    let definitions = defs::read_definitions(tree, &profile, &mut references, diagnostics);
641    // A transitions document that fails has reported why, so the load
642    // fails with it.
643    if let Some(path) = transitions_path {
644        profile.transitions =
645            transitions::read_transitions(tree, &path, &profile, &references, diagnostics)
646                .map(Arc::new);
647    }
648    let mut planes = BTreeMap::new();
649    for plane in plane::read_planes(tree, &profile, &plane_ids, &mut references, diagnostics) {
650        planes.insert(plane.id.clone(), plane);
651    }
652    let object_uids = uid::index(&planes);
653    Some(World {
654        profile,
655        planes,
656        definitions,
657        object_uids,
658        source_digest: [0; 32],
659    })
660}
661
662#[cfg(test)]
663mod tests;