Skip to main content

tilewright_pack/
lib.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! Tilewright content packs (`docs/spec/PACK.md`): one SQLite file compiled
5//! deterministically from a validated world, named by its SHA-256 digest.
6//!
7//! ```no_run
8//! # use std::path::Path;
9//! let world = tilewright_source::load(Path::new("content")).expect("valid source");
10//! let written = tilewright_pack::write_pack(&world, Path::new("dist"))?;
11//! let pack = tilewright_pack::Pack::open(&written.path)?;
12//! let summary = pack.verify()?;
13//! println!("{} chunks", summary.chunks);
14//! # Ok::<(), tilewright_pack::Error>(())
15//! ```
16
17mod chunk;
18mod compile;
19mod read;
20
21pub use chunk::{
22    CHUNK_ENCODING, ChunkData, DecodeError, FieldRecord, MAXIMUM_CHUNK_BYTES, MAXIMUM_DEPTH,
23    MAXIMUM_RECORD_BYTES, ObjectRecord, PortalRecord, RECORD_VERSION, ZSTD_LEVEL, decode_chunk,
24    decode_record, encode_chunk, encode_record,
25};
26pub use compile::{COMPILER, Written, compile, write_pack};
27pub use read::{
28    Anchor, DEFAULT_MAXIMUM_DECODED_BYTES, DEFAULT_MAXIMUM_PACK_BYTES, Definition, Identity,
29    MAXIMUM_OBJECT_INDEX, Pack, Page, Plane, StairwellTile, Summary, UidLocation, VerifyLimits,
30    Zone,
31};
32
33use std::fmt;
34
35/// The `meta.format` value.
36pub const FORMAT: &str = "tilewright.pack/1";
37
38/// SQLite `application_id` of a pack: `"TWP1"`.
39pub const APPLICATION_ID: i32 = 0x5457_5031;
40
41/// SQLite `user_version` of a pack of this format.
42pub const USER_VERSION: i32 = 2;
43
44/// Every `meta` key, in byte order. A pack has exactly these.
45pub const META_KEYS: [&str; 7] = [
46    "chunk_encoding",
47    "compiler",
48    "format",
49    "profile",
50    "profile_id",
51    "profile_version",
52    "source_digest",
53];
54
55/// The pack tables, by name, exactly as created.
56pub const SCHEMA_TABLES: [(&str, &str); 10] = [
57    (
58        "meta",
59        "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) WITHOUT ROWID",
60    ),
61    (
62        "strings",
63        "CREATE TABLE strings (id INTEGER PRIMARY KEY, value TEXT NOT NULL UNIQUE)",
64    ),
65    (
66        "planes",
67        "CREATE TABLE planes (id TEXT PRIMARY KEY, kind TEXT NOT NULL, chunk_size INTEGER NOT NULL, document TEXT NOT NULL) WITHOUT ROWID",
68    ),
69    (
70        "zones",
71        "CREATE TABLE zones (plane TEXT NOT NULL, id TEXT NOT NULL, parent TEXT, document TEXT NOT NULL, PRIMARY KEY (plane, id)) WITHOUT ROWID",
72    ),
73    (
74        "anchors",
75        "CREATE TABLE anchors (plane TEXT NOT NULL, id TEXT NOT NULL, layer INTEGER NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, PRIMARY KEY (plane, id)) WITHOUT ROWID",
76    ),
77    (
78        "stairwells",
79        "CREATE TABLE stairwells (plane TEXT NOT NULL, id TEXT NOT NULL, document TEXT NOT NULL, PRIMARY KEY (plane, id)) WITHOUT ROWID",
80    ),
81    (
82        "stairwell_steps",
83        "CREATE TABLE stairwell_steps (plane TEXT NOT NULL, layer INTEGER NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, stairwell TEXT NOT NULL, step INTEGER NOT NULL, proto TEXT NOT NULL, PRIMARY KEY (plane, layer, x, y)) WITHOUT ROWID",
84    ),
85    (
86        "chunks",
87        "CREATE TABLE chunks (plane TEXT NOT NULL, layer INTEGER NOT NULL, cx INTEGER NOT NULL, cy INTEGER NOT NULL, encoding TEXT NOT NULL, data BLOB NOT NULL, PRIMARY KEY (plane, layer, cx, cy)) WITHOUT ROWID",
88    ),
89    (
90        "definitions",
91        "CREATE TABLE definitions (type TEXT NOT NULL, id TEXT NOT NULL, document TEXT NOT NULL, PRIMARY KEY (type, id)) WITHOUT ROWID",
92    ),
93    (
94        "object_uids",
95        "CREATE TABLE object_uids (uid TEXT PRIMARY KEY, plane TEXT NOT NULL, layer INTEGER NOT NULL, cx INTEGER NOT NULL, cy INTEGER NOT NULL, object INTEGER NOT NULL) WITHOUT ROWID",
96    ),
97];
98
99/// Whether `text` matches `^[a-z0-9][a-z0-9_.-]{0,127}$`, the source
100/// format's identifier pattern.
101pub(crate) fn is_identifier(text: &str) -> bool {
102    let bytes = text.as_bytes();
103    (1..=128).contains(&bytes.len())
104        && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
105        && bytes
106            .iter()
107            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_.-".contains(byte))
108}
109
110/// Whether `text` is a domain-qualified namespace for `custom` data, such as
111/// `example.org` or `example.org/tool`, as the source format requires.
112pub(crate) fn is_namespace(text: &str) -> bool {
113    let (domain, rest) = text.split_once('/').unwrap_or((text, ""));
114    domain.contains('.')
115        && is_identifier(domain)
116        && !domain.starts_with('.')
117        && !domain.ends_with('.')
118        && (rest.is_empty() || is_identifier(rest))
119}
120
121/// Whether `text` is a field name: `^[a-z][a-z0-9_]{0,63}$`.
122pub(crate) fn is_field_name(text: &str) -> bool {
123    let bytes = text.as_bytes();
124    (1..=64).contains(&bytes.len())
125        && bytes[0].is_ascii_lowercase()
126        && bytes
127            .iter()
128            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
129}
130
131/// Limits for parsing canonical JSON stored in a pack. Canonical JSON is
132/// one line, so the physical line limit is the input limit; every other
133/// limit is the source format's, which the source document already met.
134pub(crate) fn canonical_limits() -> tilewright_jsonc::Limits {
135    let limits = tilewright_jsonc::Limits::default();
136    tilewright_jsonc::Limits {
137        max_line_bytes: limits.max_input_bytes,
138        ..limits
139    }
140}
141
142/// The schema as one SQL batch.
143pub(crate) static SCHEMA: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
144    SCHEMA_TABLES
145        .iter()
146        .map(|(_, sql)| format!("{sql};"))
147        .collect::<Vec<_>>()
148        .join("\n")
149});
150
151/// Stable codes for why a pack failed to open, read or verify, one per rule
152/// of `docs/spec/PACK.md` section 3. The Go reader reports the same codes,
153/// and the conformance fixtures in `fixtures/packs` pin them.
154#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
155#[non_exhaustive]
156pub enum ErrorCode {
157    /// The file could not be read.
158    Io,
159    /// SQLite failed, for example on a file that is not a database.
160    Sqlite,
161    /// The path is not a regular file.
162    NotAFile,
163    /// The file is longer than the pack size limit.
164    TooLarge,
165    /// The file is not exactly its pages.
166    FileLength,
167    /// The schema is not exactly the format's.
168    Schema,
169    /// The application ID, user version or page size is not the format's,
170    /// or the SQLite header's file format versions are not 1.
171    Identity,
172    /// The pack has free pages.
173    FreePages,
174    /// `meta` does not hold exactly the format's keys and values.
175    Meta,
176    /// The stored profile is invalid or disagrees with `meta`.
177    Profile,
178    /// A string is not UTF-8 text of at most 1 MiB without NUL.
179    String,
180    /// String IDs are not `1..=count` in byte order of their values.
181    StringIds,
182    /// A plane row or document is invalid.
183    Plane,
184    /// A zone row or document is invalid, or zone parents form a cycle.
185    Zone,
186    /// An anchor row is invalid.
187    Anchor,
188    /// A stairwell row or document is invalid or disagrees with its steps.
189    Stairwell,
190    /// A `stairwell_steps` row is invalid or listed by no stairwell.
191    StairwellStep,
192    /// A definition row or document is invalid.
193    Definition,
194    /// A chunk row's key is invalid, or its record disagrees with its plane
195    /// or the profile.
196    Chunk,
197    /// A chunk's `encoding` is not `twc1+zstd`.
198    ChunkEncoding,
199    /// A chunk's `data` is not a blob within the encoding's bound.
200    ChunkData,
201    /// A chunk's data is not a valid `twc1+zstd` record.
202    ChunkRecord,
203    /// A chunk record names a target that does not exist or is out of range.
204    Reference,
205    /// An `object_uids` row is invalid, or the table does not index exactly
206    /// the uids of the chunk records.
207    ObjectUid,
208    /// SQLite's integrity check failed.
209    Integrity,
210    /// The file's SHA-256 does not match its name, or the SHA-256 of a pack
211    /// opened from bytes is not the digest its caller expects.
212    Digest,
213    /// The chunks decode to more than the verification budget, or a single
214    /// read holds more than its budget: a chunk's objects and fields, or,
215    /// in the browser reader, a stairwell's steps.
216    DecodeBudget,
217    /// The compiler cannot encode a world.
218    Invalid,
219}
220
221impl ErrorCode {
222    /// Every code, in declaration order.
223    pub const ALL: [Self; 28] = [
224        Self::Io,
225        Self::Sqlite,
226        Self::NotAFile,
227        Self::TooLarge,
228        Self::FileLength,
229        Self::Schema,
230        Self::Identity,
231        Self::FreePages,
232        Self::Meta,
233        Self::Profile,
234        Self::String,
235        Self::StringIds,
236        Self::Plane,
237        Self::Zone,
238        Self::Anchor,
239        Self::Stairwell,
240        Self::StairwellStep,
241        Self::Definition,
242        Self::Chunk,
243        Self::ChunkEncoding,
244        Self::ChunkData,
245        Self::ChunkRecord,
246        Self::Reference,
247        Self::ObjectUid,
248        Self::Integrity,
249        Self::Digest,
250        Self::DecodeBudget,
251        Self::Invalid,
252    ];
253
254    /// The stable string form, such as `pack.free_pages`.
255    #[must_use]
256    pub const fn as_str(self) -> &'static str {
257        match self {
258            Self::Io => "pack.io",
259            Self::Sqlite => "pack.sqlite",
260            Self::NotAFile => "pack.not_a_file",
261            Self::TooLarge => "pack.too_large",
262            Self::FileLength => "pack.file_length",
263            Self::Schema => "pack.schema",
264            Self::Identity => "pack.identity",
265            Self::FreePages => "pack.free_pages",
266            Self::Meta => "pack.meta",
267            Self::Profile => "pack.profile",
268            Self::String => "pack.string",
269            Self::StringIds => "pack.string_ids",
270            Self::Plane => "pack.plane",
271            Self::Zone => "pack.zone",
272            Self::Anchor => "pack.anchor",
273            Self::Stairwell => "pack.stairwell",
274            Self::StairwellStep => "pack.stairwell_step",
275            Self::Definition => "pack.definition",
276            Self::Chunk => "pack.chunk",
277            Self::ChunkEncoding => "pack.chunk_encoding",
278            Self::ChunkData => "pack.chunk_data",
279            Self::ChunkRecord => "pack.chunk_record",
280            Self::Reference => "pack.reference",
281            Self::ObjectUid => "pack.object_uid",
282            Self::Integrity => "pack.integrity",
283            Self::Digest => "pack.digest",
284            Self::DecodeBudget => "pack.decode_budget",
285            Self::Invalid => "pack.invalid",
286        }
287    }
288}
289
290impl fmt::Display for ErrorCode {
291    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
292        formatter.write_str(self.as_str())
293    }
294}
295
296#[derive(Debug)]
297pub enum Error {
298    Io(std::io::Error),
299    Sqlite(rusqlite::Error),
300    Chunk(DecodeError),
301    /// A pack the reader rejects, with the code of the rule it breaks.
302    Rejected(ErrorCode, String),
303    /// A world the compiler cannot encode.
304    Invalid(String),
305}
306
307impl Error {
308    /// The stable code of this error.
309    #[must_use]
310    pub const fn code(&self) -> ErrorCode {
311        match self {
312            Self::Io(_) => ErrorCode::Io,
313            Self::Sqlite(_) => ErrorCode::Sqlite,
314            Self::Chunk(_) => ErrorCode::ChunkRecord,
315            Self::Rejected(code, _) => *code,
316            Self::Invalid(_) => ErrorCode::Invalid,
317        }
318    }
319}
320
321impl fmt::Display for Error {
322    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323        match self {
324            Self::Io(error) => write!(formatter, "I/O error: {error}"),
325            Self::Sqlite(error) => write!(formatter, "SQLite error: {error}"),
326            Self::Chunk(error) => error.fmt(formatter),
327            Self::Rejected(_, message) | Self::Invalid(message) => formatter.write_str(message),
328        }
329    }
330}
331
332impl std::error::Error for Error {}
333
334impl From<std::io::Error> for Error {
335    fn from(error: std::io::Error) -> Self {
336        Self::Io(error)
337    }
338}
339
340impl From<rusqlite::Error> for Error {
341    fn from(error: rusqlite::Error) -> Self {
342        Self::Sqlite(error)
343    }
344}
345
346#[cfg(test)]
347mod tests;