Skip to main content

tilewright_pack/read/
identities.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! Listing the identities a pack holds, for tools that compare IDs: its
5//! render layers, planes, zones, anchors, stairwells and definitions, the
6//! addresses of its chunks, and its object uids.
7
8use super::{
9    bounded_identifier, bounded_text, maximum_document_bytes,
10    rows::{
11        anchor_columns, anchor_row, chunk_coordinate, definition_columns, definition_row,
12        disagrees, identifier, integer, layer, plane_columns, plane_row, zone_columns, zone_row,
13    },
14    stored_document, stored_stairwell, utf8_column,
15};
16use crate::{Error, ErrorCode, MAXIMUM_OBJECT_INDEX, Pack};
17use rusqlite::Row;
18use tilewright_jsonc::Value;
19
20/// One identity of a pack, as [`Pack::visit_identities`] lists it.
21/// Documents are the stored canonical JSON: a plane's or definition's
22/// document, or a zone's or stairwell's entry.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum Identity<'a> {
25    /// A render layer of the profile and its position in `render_layers`.
26    RenderLayer {
27        name: &'a str,
28        position: usize,
29    },
30    Plane {
31        id: &'a str,
32        document: &'a str,
33    },
34    Zone {
35        plane: &'a str,
36        id: &'a str,
37        document: &'a str,
38    },
39    Anchor {
40        plane: &'a str,
41        id: &'a str,
42        layer: i32,
43        at: [i64; 2],
44    },
45    Stairwell {
46        plane: &'a str,
47        id: &'a str,
48        document: &'a str,
49    },
50    Definition {
51        kind: &'a str,
52        id: &'a str,
53        document: &'a str,
54    },
55    /// The address of a chunk: its plane, layer and chunk coordinates.
56    Chunk {
57        plane: &'a str,
58        layer: i32,
59        cx: i64,
60        cy: i64,
61    },
62    /// An object uid and where its object is: the plane, layer and chunk
63    /// coordinates of its chunk, and its index in the chunk's depth-first
64    /// order.
65    Object {
66        uid: &'a str,
67        plane: &'a str,
68        layer: i32,
69        cx: i64,
70        cy: i64,
71        index: usize,
72    },
73}
74
75impl Pack {
76    /// Calls `each` with every row of `sql`, whose `?1`, if any, is the
77    /// largest stored document.
78    fn each_row(
79        &self,
80        sql: &str,
81        each: &mut dyn FnMut(&Row<'_>) -> Result<(), Error>,
82    ) -> Result<(), Error> {
83        let mut statement = self.connection.prepare(sql)?;
84        let mut rows = if statement.parameter_count() == 0 {
85            statement.query([])?
86        } else {
87            statement.query([maximum_document_bytes()])?
88        };
89        while let Some(row) = rows.next()? {
90            each(row)?;
91        }
92        Ok(())
93    }
94
95    /// Verifies the whole pack, as [`Pack::verify`] does, and then calls
96    /// `visit` with every render layer of the profile, in the profile's
97    /// order, then with every plane, zone, stairwell, definition and
98    /// anchor, then with the address of every chunk, and then with every
99    /// object uid: table by table in that order, each table in primary-key
100    /// order. Every identity listed
101    /// is therefore one a valid pack holds. One stored document is held at
102    /// a time.
103    ///
104    /// # Errors
105    /// Returns the first problem verification finds, an error if SQLite
106    /// fails or a row is invalid, or the error `visit` returns.
107    pub fn visit_identities(
108        &self,
109        visit: &mut dyn FnMut(Identity<'_>) -> Result<(), Error>,
110    ) -> Result<(), Error> {
111        self.verify()?;
112        self.visit_rows(visit)
113    }
114
115    /// The listing of [`Pack::visit_identities`], without verifying the
116    /// pack first. Each value is read within the bounds a single read uses,
117    /// and checked against what its row or document repeats, so that a
118    /// file that changed after verification still cannot yield an
119    /// unbounded or inconsistent identity: the stored profile is checked as
120    /// verification checks it, keys are identifiers, documents are
121    /// canonical JSON of objects that agree with their rows, stairwells
122    /// have the source format's shape, layers and positions are integers
123    /// within the format's range, chunk coordinates keep every tile of a
124    /// format 1 chunk within it, and object indexes are at most
125    /// [`MAXIMUM_OBJECT_INDEX`]. Relations between a row and other rows or
126    /// chunk records, such as an anchor's plane existing or a uid's row
127    /// naming an object with that uid, are what the verification before
128    /// the listing checks, and they are not checked again.
129    fn visit_rows(
130        &self,
131        visit: &mut dyn FnMut(Identity<'_>) -> Result<(), Error>,
132    ) -> Result<(), Error> {
133        let document = bounded_text("document");
134        let key = bounded_identifier;
135
136        self.visit_render_layers(visit)?;
137
138        self.each_row(
139            &format!("SELECT {} FROM planes ORDER BY id", plane_columns()),
140            &mut |row| {
141                let (id, stored) = plane_row(row)?;
142                visit(Identity::Plane {
143                    id: &id,
144                    document: stored.source(),
145                })
146            },
147        )?;
148        self.each_row(
149            &format!("SELECT {} FROM zones ORDER BY plane, id", zone_columns()),
150            &mut |row| {
151                let (plane, id, stored) = zone_row(row)?;
152                visit(Identity::Zone {
153                    plane: &plane,
154                    id: &id,
155                    document: stored.source(),
156                })
157            },
158        )?;
159        self.each_row(
160            &format!(
161                "SELECT {}, {}, {document} FROM stairwells ORDER BY plane, id",
162                key("plane"),
163                key("id")
164            ),
165            &mut |row| {
166                let (plane, id) = (
167                    identifier(row, 0, "stairwell", ErrorCode::Stairwell)?,
168                    identifier(row, 1, "stairwell", ErrorCode::Stairwell)?,
169                );
170                let stairwell = stored_stairwell(&stored_document(
171                    utf8_column(row, 2, ErrorCode::Stairwell)?,
172                    "a stairwell document",
173                    ErrorCode::Stairwell,
174                )?)?;
175                if stairwell.id != id {
176                    return Err(disagrees(
177                        &format!("stairwell {plane}/{id}"),
178                        ErrorCode::Stairwell,
179                    ));
180                }
181                visit(Identity::Stairwell {
182                    plane: &plane,
183                    id: &id,
184                    document: &stairwell.canonical,
185                })
186            },
187        )?;
188        self.each_row(
189            &format!(
190                "SELECT {} FROM definitions ORDER BY type, id",
191                definition_columns()
192            ),
193            &mut |row| {
194                let (kind, id, stored) = definition_row(row)?;
195                visit(Identity::Definition {
196                    kind: &kind,
197                    id: &id,
198                    document: stored.source(),
199                })
200            },
201        )?;
202        self.visit_positions(visit)
203    }
204
205    /// The render layers of [`Pack::visit_rows`], in the profile's order.
206    fn visit_render_layers(
207        &self,
208        visit: &mut dyn FnMut(Identity<'_>) -> Result<(), Error>,
209    ) -> Result<(), Error> {
210        // The whole profile is checked, so its render layers are 1 to
211        // 65,534 unique identifiers.
212        self.profile_layers()?;
213        let profile = stored_document(
214            self.meta("profile")?,
215            "the stored profile",
216            ErrorCode::Profile,
217        )?;
218        let layers = profile
219            .root()
220            .get("render_layers")
221            .and_then(Value::as_array)
222            .ok_or_else(|| {
223                Error::Rejected(
224                    ErrorCode::Profile,
225                    "the stored profile lists no render layers".into(),
226                )
227            })?;
228        for (position, layer) in layers.iter().enumerate() {
229            let name = layer.as_str().ok_or_else(|| {
230                Error::Rejected(ErrorCode::Profile, "a render layer is not a string".into())
231            })?;
232            visit(Identity::RenderLayer { name, position })?;
233        }
234        Ok(())
235    }
236
237    /// The anchors, chunk addresses and object uids of [`Pack::visit_rows`].
238    fn visit_positions(
239        &self,
240        visit: &mut dyn FnMut(Identity<'_>) -> Result<(), Error>,
241    ) -> Result<(), Error> {
242        let key = bounded_identifier;
243        self.each_row(
244            &format!(
245                "SELECT {} FROM anchors ORDER BY plane, id",
246                anchor_columns()
247            ),
248            &mut |row| {
249                let anchor = anchor_row(row)?;
250                visit(Identity::Anchor {
251                    plane: &anchor.plane,
252                    id: &anchor.id,
253                    layer: anchor.layer,
254                    at: [anchor.x, anchor.y],
255                })
256            },
257        )?;
258        self.each_row(
259            &format!(
260                "SELECT {}, {}, {}, {} FROM chunks ORDER BY plane, layer, cx, cy",
261                key("plane"),
262                integer("layer"),
263                integer("cx"),
264                integer("cy")
265            ),
266            &mut |row| {
267                visit(Identity::Chunk {
268                    plane: &identifier(row, 0, "chunk", ErrorCode::Chunk)?,
269                    layer: layer(row, 1, "chunk", ErrorCode::Chunk)?,
270                    cx: chunk_coordinate(row, 2, ErrorCode::Chunk)?,
271                    cy: chunk_coordinate(row, 3, ErrorCode::Chunk)?,
272                })
273            },
274        )?;
275        self.each_row(
276            &format!(
277                "SELECT {}, {}, {}, {}, {}, {} FROM object_uids ORDER BY uid",
278                key("uid"),
279                key("plane"),
280                integer("layer"),
281                integer("cx"),
282                integer("cy"),
283                integer("object")
284            ),
285            &mut |row| {
286                let code = ErrorCode::ObjectUid;
287                let index: Option<i64> = row.get(5)?;
288                let index = index
289                    .and_then(|index| usize::try_from(index).ok())
290                    .filter(|index| *index <= MAXIMUM_OBJECT_INDEX)
291                    .ok_or_else(|| {
292                        Error::Rejected(
293                            code,
294                            format!(
295                                "an object index is not an integer from 0 to {MAXIMUM_OBJECT_INDEX}"
296                            ),
297                        )
298                    })?;
299                visit(Identity::Object {
300                    uid: &identifier(row, 0, "uid", code)?,
301                    plane: &identifier(row, 1, "uid", code)?,
302                    layer: layer(row, 2, "uid", code)?,
303                    cx: chunk_coordinate(row, 3, code)?,
304                    cy: chunk_coordinate(row, 4, code)?,
305                    index,
306                })
307            },
308        )
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::Identity;
315    use crate::{Error, Pack, write_pack};
316    use rusqlite::Connection;
317    use std::path::{Path, PathBuf};
318
319    fn world() -> tilewright_source::World {
320        let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/basic");
321        tilewright_source::load(&fixture).expect("the fixture is valid")
322    }
323
324    /// A pack of the fixture, changed by `sql` unless it is empty.
325    fn pack(sql: &str) -> (tempfile::TempDir, PathBuf) {
326        let directory = tempfile::tempdir().unwrap();
327        let written = write_pack(&world(), directory.path()).unwrap();
328        let copy = directory.path().join("copy.twpack");
329        std::fs::copy(&written.path, &copy).unwrap();
330        if !sql.is_empty() {
331            Connection::open(&copy)
332                .unwrap()
333                .execute_batch(&format!("{sql}; VACUUM;"))
334                .unwrap();
335        }
336        (directory, copy)
337    }
338
339    /// Every identity [`Pack::visit_rows`] lists, without verifying the
340    /// pack first.
341    fn visit(path: &Path) -> Result<Vec<String>, Error> {
342        let mut listed = Vec::new();
343        Pack::open(path)?.visit_rows(&mut |identity| {
344            listed.push(match identity {
345                Identity::RenderLayer { name, position } => {
346                    format!("render_layer {name} {position}")
347                }
348                Identity::Plane { id, document } => format!("plane {id} {document}"),
349                Identity::Zone {
350                    plane,
351                    id,
352                    document,
353                } => format!("zone {plane}/{id} {document}"),
354                Identity::Anchor {
355                    plane,
356                    id,
357                    layer,
358                    at,
359                } => format!("anchor {plane}/{id} {layer} {at:?}"),
360                Identity::Stairwell {
361                    plane,
362                    id,
363                    document,
364                } => format!("stairwell {plane}/{id} {document}"),
365                Identity::Definition { kind, id, document } => {
366                    format!("definition {kind}/{id} {document}")
367                }
368                Identity::Chunk {
369                    plane,
370                    layer,
371                    cx,
372                    cy,
373                } => format!("chunk {plane} {layer} {cx} {cy}"),
374                Identity::Object {
375                    uid,
376                    plane,
377                    layer,
378                    cx,
379                    cy,
380                    index,
381                } => format!("object {uid} {plane} {layer} {cx} {cy} {index}"),
382            });
383            Ok(())
384        })?;
385        Ok(listed)
386    }
387
388    #[test]
389    fn identities_list_every_row_in_order_with_its_source_document() {
390        let world = world();
391        let mut expected = Vec::new();
392        for (position, name) in world.profile().render_layers.iter().enumerate() {
393            expected.push(format!("render_layer {name} {position}"));
394        }
395        for (id, plane) in world.planes() {
396            expected.push(format!("plane {id} {}", plane.canonical));
397        }
398        for (id, plane) in world.planes() {
399            for (zone, entry) in &plane.zones {
400                expected.push(format!("zone {id}/{zone} {}", entry.canonical));
401            }
402        }
403        for (id, plane) in world.planes() {
404            for (stairwell, entry) in &plane.stairwells {
405                expected.push(format!("stairwell {id}/{stairwell} {}", entry.canonical));
406            }
407        }
408        for ((kind, id), definition) in world.definitions() {
409            expected.push(format!("definition {kind}/{id} {}", definition.canonical));
410        }
411        for (id, plane) in world.planes() {
412            for (anchor, entry) in &plane.anchors {
413                expected.push(format!(
414                    "anchor {id}/{anchor} {} {:?}",
415                    entry.layer, entry.at
416                ));
417            }
418        }
419        for (id, plane) in world.planes() {
420            for (layer, cx, cy) in plane.chunks.keys() {
421                expected.push(format!("chunk {id} {layer} {cx} {cy}"));
422            }
423        }
424        let mut objects = Vec::new();
425        for (id, plane) in world.planes() {
426            for ((layer, cx, cy), chunk) in &plane.chunks {
427                for (index, object) in chunk.depth_first().enumerate() {
428                    if let Some(uid) = &object.uid {
429                        objects.push(format!("object {uid} {id} {layer} {cx} {cy} {index}"));
430                    }
431                }
432            }
433        }
434        objects.sort();
435        assert_eq!(objects.len(), 5);
436        expected.extend(objects);
437        let (_directory, path) = pack("");
438        let listed = visit(&path).unwrap();
439        assert_eq!(listed, expected);
440        assert_eq!(
441            listed
442                .iter()
443                .filter(|line| line.starts_with("chunk "))
444                .count(),
445            5
446        );
447
448        // A failing visit stops the listing and returns its error.
449        let mut seen = 0;
450        let error = Pack::open(&path)
451            .unwrap()
452            .visit_identities(&mut |_| {
453                seen += 1;
454                Err(Error::Invalid("stop".into()))
455            })
456            .unwrap_err();
457        assert_eq!((seen, error.to_string()), (1, "stop".to_owned()));
458
459        // The verified listing is the same.
460        let mut verified = 0;
461        Pack::open(&path)
462            .unwrap()
463            .visit_identities(&mut |_| {
464                verified += 1;
465                Ok(())
466            })
467            .unwrap();
468        assert_eq!(verified, expected.len());
469    }
470
471    #[test]
472    fn identities_are_listed_only_from_packs_that_verify() {
473        // Each change passes the row checks but not verification, which
474        // relates rows to each other.
475        for sql in [
476            "UPDATE stairwell_steps SET proto = 'stairs_up' WHERE stairwell = 'cave.chute' AND step = 0",
477            "INSERT INTO stairwell_steps VALUES ('overworld', 5, 1, 1, 'cave.chute', 7, 'coin')",
478            "UPDATE planes SET document = replace(document, '\"wilderness\"', '\"nowhere\"') WHERE id = 'overworld'",
479            "UPDATE zones SET plane = 'ghost' WHERE id = 'village'",
480            "UPDATE zones SET parent = 'village', document = replace(document, '\"name\"', '\"parent\":\"village\",\"name\"') WHERE id = 'wilderness'",
481            "UPDATE anchors SET plane = 'ghost' WHERE id = 'village.well'",
482            "UPDATE chunks SET plane = 'ghost' WHERE plane = 'vault'",
483            "UPDATE stairwells SET plane = 'ghost' WHERE id = 'cave.chute'",
484            "UPDATE object_uids SET object = 5 WHERE uid = 'village.chest'",
485            "DELETE FROM object_uids WHERE uid = 'vault.chest'",
486        ] {
487            let (_directory, path) = pack(sql);
488            assert!(visit(&path).is_ok(), "{sql}: the row checks accept it");
489            let mut seen = 0;
490            let result = Pack::open(&path).unwrap().visit_identities(&mut |_| {
491                seen += 1;
492                Ok(())
493            });
494            assert!(result.is_err(), "{sql}");
495            assert_eq!(seen, 0, "{sql}");
496        }
497    }
498
499    /// Changes to a pack, each with what the error for it says.
500    const MALFORMED: &[(&str, &str)] = &[
501        (
502            "UPDATE planes SET document = replace(document, '\"vault\"', '\"other\"') WHERE id = 'vault'",
503            "disagrees",
504        ),
505        (
506            "UPDATE zones SET document = '{\"id\":\"elsewhere\"}' WHERE id = 'village'",
507            "disagrees",
508        ),
509        (
510            "UPDATE zones SET document = ' {}' WHERE id = 'village'",
511            "canonical",
512        ),
513        (
514            "UPDATE zones SET id = 'Village' WHERE id = 'village'",
515            "identifier",
516        ),
517        (
518            "UPDATE zones SET parent = 'wilderness' WHERE id = 'village'",
519            "disagrees",
520        ),
521        (
522            "UPDATE zones SET parent = NULL WHERE id = 'village'",
523            "disagrees",
524        ),
525        (
526            "UPDATE zones SET parent = 'x' || char(10) WHERE id = 'village'",
527            "identifier",
528        ),
529        (
530            "UPDATE planes SET kind = 'shared' WHERE id = 'vault'",
531            "disagrees",
532        ),
533        (
534            "UPDATE planes SET kind = 'odd', document = replace(document, '\"instanced\"', '\"odd\"') WHERE id = 'vault'",
535            "invalid kind",
536        ),
537        (
538            "UPDATE planes SET chunk_size = 16 WHERE id = 'vault'",
539            "chunk size",
540        ),
541        (
542            "UPDATE planes SET document = replace(document, 'plane/1', 'plane/2') WHERE id = 'vault'",
543            "disagrees",
544        ),
545        (
546            "UPDATE anchors SET layer = 4294967296 WHERE id = 'village.well'",
547            "32-bit",
548        ),
549        (
550            "UPDATE anchors SET x = 'one' WHERE id = 'village.well'",
551            "position",
552        ),
553        (
554            "UPDATE anchors SET y = 9007199254740992 WHERE id = 'village.well'",
555            "position",
556        ),
557        (
558            "UPDATE stairwells SET document = replace(document, '\"down\"', '\"sideways\"') WHERE id = 'cave.chute'",
559            "shape",
560        ),
561        (
562            "UPDATE stairwells SET id = 'cave.slide' WHERE id = 'cave.chute'",
563            "disagrees",
564        ),
565        (
566            "UPDATE definitions SET type = 'loot' WHERE type = 'recipe'",
567            "disagrees",
568        ),
569        (
570            "UPDATE definitions SET document = zeroblob(10) WHERE id = 'coin'",
571            "canonical",
572        ),
573        ("UPDATE chunks SET layer = 'x' WHERE layer = 1", "32-bit"),
574        (
575            "UPDATE meta SET value = '{\"render_layers\":[]}' WHERE key = 'profile'",
576            "profile",
577        ),
578        (
579            "UPDATE meta SET value = replace(value, '\"effect\"]', '\"effect\",\"floor\"]') WHERE key = 'profile'",
580            "render_layers",
581        ),
582        (
583            "UPDATE meta SET value = '2.0.0' WHERE key = 'profile_version'",
584            "profile_version",
585        ),
586        (
587            "UPDATE chunks SET cx = 281474976710656 WHERE plane = 'vault'",
588            "chunk coordinate",
589        ),
590        (
591            "UPDATE chunks SET cy = -281474976710656 WHERE plane = 'vault'",
592            "chunk coordinate",
593        ),
594        (
595            "UPDATE chunks SET plane = x'00' WHERE plane = 'vault'",
596            "identifier",
597        ),
598        (
599            "UPDATE object_uids SET uid = 'Vault.chest' WHERE uid = 'vault.chest'",
600            "uid key",
601        ),
602        (
603            "UPDATE object_uids SET plane = 'Vault' WHERE uid = 'vault.chest'",
604            "uid key",
605        ),
606        (
607            "UPDATE object_uids SET layer = 2147483648 WHERE uid = 'vault.chest'",
608            "32-bit",
609        ),
610        (
611            "UPDATE object_uids SET cx = 281474976710656 WHERE uid = 'vault.chest'",
612            "chunk coordinate",
613        ),
614        (
615            "UPDATE object_uids SET object = -1 WHERE uid = 'vault.chest'",
616            "object index",
617        ),
618        (
619            "UPDATE object_uids SET object = 16777216 WHERE uid = 'vault.chest'",
620            "object index",
621        ),
622    ];
623
624    #[test]
625    fn identities_reject_rows_of_the_wrong_shape() {
626        for (sql, problem) in MALFORMED {
627            let (_directory, path) = pack(sql);
628            let error = visit(&path).expect_err(sql).to_string();
629            assert!(error.contains(problem), "{sql}: {error}");
630        }
631        // The outermost chunks whose tiles stay within range are listed.
632        let (_directory, path) = pack(
633            "UPDATE chunks SET cx = 281474976710655, cy = -281474976710655 WHERE plane = 'vault'",
634        );
635        assert!(
636            visit(&path)
637                .unwrap()
638                .contains(&"chunk vault 0 281474976710655 -281474976710655".to_owned())
639        );
640    }
641}