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