Skip to main content

concinnity_host/store/paths/
tree.rs

1// The state tree as a value: a content root, the three roots that may be split
2// away from it, and the layout hanging off all four.
3//
4// Every directory and file name the tree is made of is spelled once, here. A
5// caller asks for `saves_dir()` or `build_cache_path()`; that `cache/1` is a
6// build's segment is this module's knowledge and nobody else's.
7
8use std::path::{Path, PathBuf};
9
10// The tree's layout, spelled once. Private: a caller asks the tree for a path
11// rather than for a segment's name, so nothing outside this module has to know
12// that a build's cache is `cache/1`.
13const ASSETS_DIR: &str = "assets";
14const DATA_DIR: &str = "data";
15const WORLD_LOCK_FILE: &str = "world-lock.json";
16const WORLDS_DIR: &str = "worlds";
17const SAVES_DIR: &str = "saves";
18const PREVIEW_SAVES_DIR: &str = "preview-saves";
19const SETTINGS_FILE: &str = "settings";
20const CRASHES_DIR: &str = "crashes";
21const EDITOR_SESSION_FILE: &str = "editor";
22const CACHE_DIR: &str = "cache";
23const RUNTIME_CACHE_SEGMENT: &str = "0";
24const BUILD_CACHE_SEGMENT: &str = "1";
25
26/// Where a project's state lives, and what hangs off it.
27///
28/// Built by whatever runs the process -- the dev CLI from its project
29/// directory, a shipped application from the directory beside its executable,
30/// an embedder from whatever its own layout implies -- and passed down. Library
31/// code is handed one; it never resolves a root for itself.
32///
33/// Four roots, because the four fall apart on real installs:
34///
35/// - the **content** root holds what a person authors (`assets/`, `worlds/`),
36/// - the **build** root holds what a build produces (`data/`, the world lock),
37///   split away when the authored content should stay visible and the output
38///   should not,
39/// - the **writable** root holds what the running application writes
40///   (`saves/`, `settings`, `crashes/`), split away when the content root is a
41///   read-only install such as Program Files,
42/// - the **cache** root holds the regenerable segments, split away when the
43///   caches should outlive (or be shared across) the content beside them.
44///
45/// An unsplit tree resolves all four at the content root, which is the
46/// single-folder layout a portable install uses.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct StateTree {
49    content: PathBuf,
50    build: Option<PathBuf>,
51    writable: Option<PathBuf>,
52    cache: Option<PathBuf>,
53}
54
55impl StateTree {
56    /// A tree with every root at `content`.
57    pub fn at<P: Into<PathBuf>>(content: P) -> Self {
58        Self {
59            content: content.into(),
60            build: None,
61            writable: None,
62            cache: None,
63        }
64    }
65
66    /// Move what a build produces to `dir`, leaving the authored content where
67    /// it is: a dev project keeps `assets/` and `worlds/` in sight and its
68    /// blobs, lock, and caches out of the way.
69    #[must_use]
70    pub fn with_build<P: Into<PathBuf>>(mut self, dir: P) -> Self {
71        self.build = Some(dir.into());
72        self
73    }
74
75    /// Move the runtime-writable state to `dir`, leaving the content where it
76    /// is: a read-only install writes its saves and settings per-user.
77    #[must_use]
78    pub fn with_writable<P: Into<PathBuf>>(mut self, dir: P) -> Self {
79        self.writable = Some(dir.into());
80        self
81    }
82
83    /// Move both cache segments to `dir`. Anchors the regenerable artifacts
84    /// away from the content and the writable state, so a warm cache can sit
85    /// behind a content root that is read-only, or freshly built, or both.
86    #[must_use]
87    pub fn with_cache<P: Into<PathBuf>>(mut self, dir: P) -> Self {
88        self.cache = Some(dir.into());
89        self
90    }
91
92    /// The root holding what a person authors.
93    pub fn content_root(&self) -> &Path {
94        &self.content
95    }
96
97    /// The root holding what a build produces.
98    pub fn build_root(&self) -> &Path {
99        self.build.as_deref().unwrap_or(&self.content)
100    }
101
102    /// The root holding what the running application writes.
103    pub fn writable_root(&self) -> &Path {
104        self.writable.as_deref().unwrap_or(&self.content)
105    }
106
107    /// The content root's `assets/` directory, holding authored source files.
108    pub fn assets_dir(&self) -> PathBuf {
109        self.content.join(ASSETS_DIR)
110    }
111
112    /// The content root's `worlds/` directory, holding authored worlds.
113    pub fn worlds_dir(&self) -> PathBuf {
114        self.content.join(WORLDS_DIR)
115    }
116
117    /// The build root's `data/` directory, holding the blobs a build writes
118    /// and a run reads.
119    pub fn data_dir(&self) -> PathBuf {
120        self.build_root().join(DATA_DIR)
121    }
122
123    /// The record a build writes beside its blobs: what went into them, and
124    /// what the build injected that no authored line asked for.
125    pub fn world_lock_path(&self) -> PathBuf {
126        self.build_root().join(WORLD_LOCK_FILE)
127    }
128
129    /// Directory holding the runtime save files. Created on first write by the
130    /// running application, never by a build.
131    pub fn saves_dir(&self) -> PathBuf {
132        self.writable_root().join(SAVES_DIR)
133    }
134
135    /// Sandboxed sibling of [`saves_dir`](Self::saves_dir) for preview
136    /// sessions: the save UI keeps working against this directory, the real
137    /// saves are never touched, and the sandbox is wiped at each session start.
138    pub fn preview_saves_dir(&self) -> PathBuf {
139        self.writable_root().join(PREVIEW_SAVES_DIR)
140    }
141
142    /// The mutable settings file, written by the in-engine settings menu and
143    /// never by a build.
144    pub fn settings_path(&self) -> PathBuf {
145        self.writable_root().join(SETTINGS_FILE)
146    }
147
148    /// Directory holding crash reports. Created on first write; capped by the
149    /// writer's retention pruning.
150    pub fn crashes_dir(&self) -> PathBuf {
151        self.writable_root().join(CRASHES_DIR)
152    }
153
154    /// The editor's session store: the per-project state an editor run carries
155    /// between launches, which is state rather than cache.
156    pub fn editor_session_path(&self) -> PathBuf {
157        self.writable_root().join(EDITOR_SESSION_FILE)
158    }
159
160    /// The segment a running application writes: one container holding every
161    /// regenerable artifact it produces for its own later launches, indexed by
162    /// producer and key.
163    ///
164    /// Deletable at any time; whatever is missing is recomputed. The
165    /// application writes this file and no other, so a concurrent build writing
166    /// a segment of its own never shares a file with it.
167    pub fn runtime_cache_path(&self) -> PathBuf {
168        self.cache_root_for_runtime()
169            .join(CACHE_DIR)
170            .join(RUNTIME_CACHE_SEGMENT)
171    }
172
173    /// The runtime segment a bundle ships, read-only. `cn export` warms it with
174    /// the shader binaries a first launch would otherwise compile; because those
175    /// artifacts are backend IR (DXBC / SPIR-V) rather than machine code, one
176    /// warmed at package time is valid on any machine.
177    ///
178    /// Always resolves against the build root, so it ships with the blobs it
179    /// was warmed for and stays readable on a read-only install. That is also
180    /// the only thing separating it from
181    /// [`runtime_cache_path`](Self::runtime_cache_path): a bundle the player
182    /// can write to has one segment serving both roles.
183    pub fn bundled_runtime_cache_path(&self) -> PathBuf {
184        self.build_root()
185            .join(CACHE_DIR)
186            .join(RUNTIME_CACHE_SEGMENT)
187    }
188
189    /// The segment a build writes: one container holding every payload,
190    /// expansion, and baked thumbnail a cook produced, indexed by producer and
191    /// key.
192    ///
193    /// Deletable at any time; whatever is missing is recompiled.
194    pub fn build_cache_path(&self) -> PathBuf {
195        self.cache_root_for_build()
196            .join(CACHE_DIR)
197            .join(BUILD_CACHE_SEGMENT)
198    }
199
200    // Without a cache root the runtime segment follows what the application
201    // writes, which is what keeps it writable on a read-only install.
202    fn cache_root_for_runtime(&self) -> &Path {
203        self.cache
204            .as_deref()
205            .unwrap_or_else(|| self.writable_root())
206    }
207
208    // Without a cache root the build segment follows the build root: a build
209    // writes the `data/` beside it, so a tree it cannot write is a tree it
210    // cannot cook into either.
211    fn cache_root_for_build(&self) -> &Path {
212        self.cache.as_deref().unwrap_or_else(|| self.build_root())
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    // An unsplit tree: one folder, everything under it. The portable install
221    // and the dev checkout.
222    #[test]
223    fn one_root_resolves_the_whole_layout() {
224        let tree = StateTree::at("/flat");
225        let root = Path::new("/flat");
226
227        assert_eq!(tree.content_root(), root);
228        assert_eq!(tree.build_root(), root);
229        assert_eq!(tree.writable_root(), root);
230        assert_eq!(tree.assets_dir(), root.join("assets"));
231        assert_eq!(tree.data_dir(), root.join("data"));
232        assert_eq!(tree.world_lock_path(), root.join("world-lock.json"));
233        assert_eq!(tree.worlds_dir(), root.join("worlds"));
234        assert_eq!(tree.saves_dir(), root.join("saves"));
235        assert_eq!(tree.preview_saves_dir(), root.join("preview-saves"));
236        assert_eq!(tree.settings_path(), root.join("settings"));
237        assert_eq!(tree.crashes_dir(), root.join("crashes"));
238        assert_eq!(tree.editor_session_path(), root.join("editor"));
239        assert_eq!(tree.runtime_cache_path(), root.join("cache").join("0"));
240        assert_eq!(tree.build_cache_path(), root.join("cache").join("1"));
241        // One file in both runtime roles, which is what makes the bundled tier
242        // vacuous for a bundle the player can write to.
243        assert_eq!(tree.bundled_runtime_cache_path(), tree.runtime_cache_path());
244    }
245
246    // A read-only install: only what the application writes moves. The content
247    // (and the segment a build writes into it) stays put.
248    #[test]
249    fn a_writable_root_moves_only_what_the_application_writes() {
250        let content = Path::new("/opt/MyGame");
251        let writable = Path::new("/home/u/.local/share/MyGame");
252        let tree = StateTree::at(content).with_writable(writable);
253
254        assert_eq!(tree.content_root(), content);
255        assert_eq!(tree.writable_root(), writable);
256        assert_eq!(tree.saves_dir(), writable.join("saves"));
257        assert_eq!(tree.preview_saves_dir(), writable.join("preview-saves"));
258        assert_eq!(tree.settings_path(), writable.join("settings"));
259        assert_eq!(tree.crashes_dir(), writable.join("crashes"));
260        assert_eq!(tree.editor_session_path(), writable.join("editor"));
261        assert_eq!(tree.runtime_cache_path(), writable.join("cache").join("0"));
262
263        assert_eq!(tree.data_dir(), content.join("data"));
264        assert_eq!(tree.world_lock_path(), content.join("world-lock.json"));
265        assert_eq!(tree.assets_dir(), content.join("assets"));
266        assert_eq!(tree.worlds_dir(), content.join("worlds"));
267        assert_eq!(tree.build_cache_path(), content.join("cache").join("1"));
268        // The shipped segment stays with the content, which is what keeps a
269        // read-only install's warmed artifacts readable.
270        assert_eq!(
271            tree.bundled_runtime_cache_path(),
272            content.join("cache").join("0")
273        );
274    }
275
276    // A cache root moves both regenerable segments and nothing else: the point
277    // of the split is a warm cache behind content that is fresh, read-only, or
278    // both.
279    #[test]
280    fn a_cache_root_moves_both_segments_and_nothing_else() {
281        let content = Path::new("/build/content");
282        let cache = Path::new("/var/cache/mygame");
283        let tree = StateTree::at(content).with_cache(cache);
284
285        assert_eq!(tree.runtime_cache_path(), cache.join("cache").join("0"));
286        assert_eq!(tree.build_cache_path(), cache.join("cache").join("1"));
287        // Still the shipped tier's own definition: beside the content.
288        assert_eq!(
289            tree.bundled_runtime_cache_path(),
290            content.join("cache").join("0")
291        );
292        assert_eq!(tree.data_dir(), content.join("data"));
293        assert_eq!(tree.saves_dir(), content.join("saves"));
294    }
295
296    // All four split: the case one knob could never express, and the reason
297    // each is a root of its own.
298    #[test]
299    fn all_four_roots_split_independently() {
300        let tree = StateTree::at("/opt/app")
301            .with_build("/opt/app/out")
302            .with_writable("/home/u/app")
303            .with_cache("/var/cache/app");
304
305        assert_eq!(tree.assets_dir(), Path::new("/opt/app/assets"));
306        assert_eq!(tree.data_dir(), Path::new("/opt/app/out/data"));
307        assert_eq!(tree.saves_dir(), Path::new("/home/u/app/saves"));
308        assert_eq!(
309            tree.runtime_cache_path(),
310            Path::new("/var/cache/app/cache/0")
311        );
312        assert_eq!(tree.build_cache_path(), Path::new("/var/cache/app/cache/1"));
313    }
314
315    // The dev layout: authored content in sight at the project root, every
316    // derived byte under one hidden directory beside it.
317    #[test]
318    fn a_build_root_hides_the_output_and_leaves_the_authored_content_visible() {
319        let project = Path::new("/proj");
320        let hidden = Path::new("/proj/.concinnity");
321        let tree = StateTree::at(project)
322            .with_writable(hidden)
323            .with_build(hidden);
324
325        assert_eq!(tree.assets_dir(), project.join("assets"));
326        assert_eq!(tree.worlds_dir(), project.join("worlds"));
327
328        assert_eq!(tree.data_dir(), hidden.join("data"));
329        assert_eq!(tree.world_lock_path(), hidden.join("world-lock.json"));
330        assert_eq!(tree.settings_path(), hidden.join("settings"));
331        assert_eq!(tree.saves_dir(), hidden.join("saves"));
332        assert_eq!(tree.editor_session_path(), hidden.join("editor"));
333        // Both segments follow without a cache root of their own, which is what
334        // keeps a `cache/` out of the project root.
335        assert_eq!(tree.runtime_cache_path(), hidden.join("cache").join("0"));
336        assert_eq!(tree.build_cache_path(), hidden.join("cache").join("1"));
337        assert_eq!(
338            tree.bundled_runtime_cache_path(),
339            hidden.join("cache").join("0")
340        );
341    }
342
343    // A cache root still outranks the build root for the segment a build
344    // writes: the two splits answer different questions.
345    #[test]
346    fn a_cache_root_outranks_the_build_root() {
347        let tree = StateTree::at("/proj")
348            .with_build("/proj/.concinnity")
349            .with_cache("/var/cache/app");
350
351        assert_eq!(tree.build_cache_path(), Path::new("/var/cache/app/cache/1"));
352        assert_eq!(tree.data_dir(), Path::new("/proj/.concinnity/data"));
353    }
354
355    // The builders are independent: setting one leaves the others resolving at
356    // their own defaults.
357    #[test]
358    fn builders_do_not_disturb_each_other() {
359        let base = StateTree::at("/root");
360        assert_eq!(
361            base.clone().with_cache("/c").saves_dir(),
362            base.saves_dir(),
363            "a cache root leaves the writable state alone"
364        );
365        assert_eq!(
366            base.clone().with_writable("/w").build_cache_path(),
367            base.build_cache_path(),
368            "a writable root leaves the build segment with the content"
369        );
370        assert_eq!(
371            base.clone().with_build("/b").assets_dir(),
372            base.assets_dir(),
373            "a build root leaves the authored content where it is"
374        );
375        assert_eq!(
376            base.clone().with_build("/b").saves_dir(),
377            base.saves_dir(),
378            "a build root leaves the writable state alone"
379        );
380    }
381}