concinnity_host/store/paths/tree.rs
1// The state tree as a value: a content root, the two roots that may be split
2// away from it, and the layout hanging off all three.
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 WORLDS_DIR: &str = "worlds";
16const SAVES_DIR: &str = "saves";
17const PREVIEW_SAVES_DIR: &str = "preview-saves";
18const SETTINGS_FILE: &str = "settings";
19const CRASHES_DIR: &str = "crashes";
20const EDITOR_SESSION_FILE: &str = "editor";
21const CACHE_DIR: &str = "cache";
22const RUNTIME_CACHE_SEGMENT: &str = "0";
23const BUILD_CACHE_SEGMENT: &str = "1";
24
25/// Where a project's state lives, and what hangs off it.
26///
27/// Built by whatever runs the process -- the dev CLI from its project
28/// directory, a shipped application from the directory beside its executable,
29/// an embedder from whatever its own layout implies -- and passed down. Library
30/// code is handed one; it never resolves a root for itself.
31///
32/// Three roots, because the three fall apart on real installs:
33///
34/// - the **content** root holds what a build produces and reads (`data/`,
35/// `assets/`, `worlds/`),
36/// - the **writable** root holds what the running application writes
37/// (`saves/`, `settings`, `crashes/`), split away when the content root is a
38/// read-only install such as Program Files,
39/// - the **cache** root holds the regenerable segments, split away when the
40/// caches should outlive (or be shared across) the content beside them.
41///
42/// An unsplit tree resolves all three at the content root, which is the
43/// single-folder layout a portable install and a dev checkout both use.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct StateTree {
46 content: PathBuf,
47 writable: Option<PathBuf>,
48 cache: Option<PathBuf>,
49}
50
51impl StateTree {
52 /// A tree with every root at `content`.
53 pub fn at<P: Into<PathBuf>>(content: P) -> Self {
54 Self {
55 content: content.into(),
56 writable: None,
57 cache: None,
58 }
59 }
60
61 /// Move the runtime-writable state to `dir`, leaving the content where it
62 /// is: a read-only install writes its saves and settings per-user.
63 #[must_use]
64 pub fn with_writable<P: Into<PathBuf>>(mut self, dir: P) -> Self {
65 self.writable = Some(dir.into());
66 self
67 }
68
69 /// Move both cache segments to `dir`. Anchors the regenerable artifacts
70 /// away from the content and the writable state, so a warm cache can sit
71 /// behind a content root that is read-only, or freshly built, or both.
72 #[must_use]
73 pub fn with_cache<P: Into<PathBuf>>(mut self, dir: P) -> Self {
74 self.cache = Some(dir.into());
75 self
76 }
77
78 /// The root holding what a build produces and reads.
79 pub fn content_root(&self) -> &Path {
80 &self.content
81 }
82
83 /// The root holding what the running application writes.
84 pub fn writable_root(&self) -> &Path {
85 self.writable.as_deref().unwrap_or(&self.content)
86 }
87
88 /// The state root's `assets/` directory.
89 pub fn assets_dir(&self) -> PathBuf {
90 self.content.join(ASSETS_DIR)
91 }
92
93 /// The state root's `data/` directory.
94 pub fn data_dir(&self) -> PathBuf {
95 self.content.join(DATA_DIR)
96 }
97
98 /// The state root's `worlds/` directory.
99 pub fn worlds_dir(&self) -> PathBuf {
100 self.content.join(WORLDS_DIR)
101 }
102
103 /// Directory holding the runtime save files. Created on first write by the
104 /// running application, never by a build.
105 pub fn saves_dir(&self) -> PathBuf {
106 self.writable_root().join(SAVES_DIR)
107 }
108
109 /// Sandboxed sibling of [`saves_dir`](Self::saves_dir) for preview
110 /// sessions: the save UI keeps working against this directory, the real
111 /// saves are never touched, and the sandbox is wiped at each session start.
112 pub fn preview_saves_dir(&self) -> PathBuf {
113 self.writable_root().join(PREVIEW_SAVES_DIR)
114 }
115
116 /// The mutable settings file, written by the in-engine settings menu and
117 /// never by a build.
118 pub fn settings_path(&self) -> PathBuf {
119 self.writable_root().join(SETTINGS_FILE)
120 }
121
122 /// Directory holding crash reports. Created on first write; capped by the
123 /// writer's retention pruning.
124 pub fn crashes_dir(&self) -> PathBuf {
125 self.writable_root().join(CRASHES_DIR)
126 }
127
128 /// The editor's session store: the per-project state an editor run carries
129 /// between launches, which is state rather than cache.
130 pub fn editor_session_path(&self) -> PathBuf {
131 self.writable_root().join(EDITOR_SESSION_FILE)
132 }
133
134 /// The segment a running application writes: one container holding every
135 /// regenerable artifact it produces for its own later launches, indexed by
136 /// producer and key.
137 ///
138 /// Deletable at any time; whatever is missing is recomputed. The
139 /// application writes this file and no other, so a concurrent build writing
140 /// a segment of its own never shares a file with it.
141 pub fn runtime_cache_path(&self) -> PathBuf {
142 self.cache_root_for_runtime()
143 .join(CACHE_DIR)
144 .join(RUNTIME_CACHE_SEGMENT)
145 }
146
147 /// The runtime segment a bundle ships, read-only. `cn export` warms it with
148 /// the shader binaries a first launch would otherwise compile; because those
149 /// artifacts are backend IR (DXBC / SPIR-V) rather than machine code, one
150 /// warmed at package time is valid on any machine.
151 ///
152 /// Always resolves against the content root, so it stays readable on a
153 /// read-only install. That is also the only thing separating it from
154 /// [`runtime_cache_path`](Self::runtime_cache_path): a bundle the player
155 /// can write to has one segment serving both roles.
156 pub fn bundled_runtime_cache_path(&self) -> PathBuf {
157 self.content.join(CACHE_DIR).join(RUNTIME_CACHE_SEGMENT)
158 }
159
160 /// The segment a build writes: one container holding every payload,
161 /// expansion, and baked thumbnail a cook produced, indexed by producer and
162 /// key.
163 ///
164 /// Deletable at any time; whatever is missing is recompiled.
165 pub fn build_cache_path(&self) -> PathBuf {
166 self.cache_root_for_build()
167 .join(CACHE_DIR)
168 .join(BUILD_CACHE_SEGMENT)
169 }
170
171 // Without a cache root the runtime segment follows what the application
172 // writes, which is what keeps it writable on a read-only install.
173 fn cache_root_for_runtime(&self) -> &Path {
174 self.cache
175 .as_deref()
176 .unwrap_or_else(|| self.writable_root())
177 }
178
179 // Without a cache root the build segment follows the content: a build
180 // writes the `data/` beside it, so a tree it cannot write is a tree it
181 // cannot cook into either.
182 fn cache_root_for_build(&self) -> &Path {
183 self.cache.as_deref().unwrap_or(&self.content)
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 // An unsplit tree: one folder, everything under it. The portable install
192 // and the dev checkout.
193 #[test]
194 fn one_root_resolves_the_whole_layout() {
195 let tree = StateTree::at("/flat");
196 let root = Path::new("/flat");
197
198 assert_eq!(tree.content_root(), root);
199 assert_eq!(tree.writable_root(), root);
200 assert_eq!(tree.assets_dir(), root.join("assets"));
201 assert_eq!(tree.data_dir(), root.join("data"));
202 assert_eq!(tree.worlds_dir(), root.join("worlds"));
203 assert_eq!(tree.saves_dir(), root.join("saves"));
204 assert_eq!(tree.preview_saves_dir(), root.join("preview-saves"));
205 assert_eq!(tree.settings_path(), root.join("settings"));
206 assert_eq!(tree.crashes_dir(), root.join("crashes"));
207 assert_eq!(tree.editor_session_path(), root.join("editor"));
208 assert_eq!(tree.runtime_cache_path(), root.join("cache").join("0"));
209 assert_eq!(tree.build_cache_path(), root.join("cache").join("1"));
210 // One file in both runtime roles, which is what makes the bundled tier
211 // vacuous for a bundle the player can write to.
212 assert_eq!(tree.bundled_runtime_cache_path(), tree.runtime_cache_path());
213 }
214
215 // A read-only install: only what the application writes moves. The content
216 // (and the segment a build writes into it) stays put.
217 #[test]
218 fn a_writable_root_moves_only_what_the_application_writes() {
219 let content = Path::new("/opt/MyGame");
220 let writable = Path::new("/home/u/.local/share/MyGame");
221 let tree = StateTree::at(content).with_writable(writable);
222
223 assert_eq!(tree.content_root(), content);
224 assert_eq!(tree.writable_root(), writable);
225 assert_eq!(tree.saves_dir(), writable.join("saves"));
226 assert_eq!(tree.preview_saves_dir(), writable.join("preview-saves"));
227 assert_eq!(tree.settings_path(), writable.join("settings"));
228 assert_eq!(tree.crashes_dir(), writable.join("crashes"));
229 assert_eq!(tree.editor_session_path(), writable.join("editor"));
230 assert_eq!(tree.runtime_cache_path(), writable.join("cache").join("0"));
231
232 assert_eq!(tree.data_dir(), content.join("data"));
233 assert_eq!(tree.assets_dir(), content.join("assets"));
234 assert_eq!(tree.worlds_dir(), content.join("worlds"));
235 assert_eq!(tree.build_cache_path(), content.join("cache").join("1"));
236 // The shipped segment stays with the content, which is what keeps a
237 // read-only install's warmed artifacts readable.
238 assert_eq!(
239 tree.bundled_runtime_cache_path(),
240 content.join("cache").join("0")
241 );
242 }
243
244 // A cache root moves both regenerable segments and nothing else: the point
245 // of the split is a warm cache behind content that is fresh, read-only, or
246 // both.
247 #[test]
248 fn a_cache_root_moves_both_segments_and_nothing_else() {
249 let content = Path::new("/build/content");
250 let cache = Path::new("/var/cache/mygame");
251 let tree = StateTree::at(content).with_cache(cache);
252
253 assert_eq!(tree.runtime_cache_path(), cache.join("cache").join("0"));
254 assert_eq!(tree.build_cache_path(), cache.join("cache").join("1"));
255 // Still the shipped tier's own definition: beside the content.
256 assert_eq!(
257 tree.bundled_runtime_cache_path(),
258 content.join("cache").join("0")
259 );
260 assert_eq!(tree.data_dir(), content.join("data"));
261 assert_eq!(tree.saves_dir(), content.join("saves"));
262 }
263
264 // All three split: the case one knob could never express, and the reason
265 // the cache is a root of its own.
266 #[test]
267 fn all_three_roots_split_independently() {
268 let tree = StateTree::at("/opt/app")
269 .with_writable("/home/u/app")
270 .with_cache("/var/cache/app");
271
272 assert_eq!(tree.data_dir(), Path::new("/opt/app/data"));
273 assert_eq!(tree.saves_dir(), Path::new("/home/u/app/saves"));
274 assert_eq!(
275 tree.runtime_cache_path(),
276 Path::new("/var/cache/app/cache/0")
277 );
278 assert_eq!(tree.build_cache_path(), Path::new("/var/cache/app/cache/1"));
279 }
280
281 // The builders are independent: setting one leaves the others resolving at
282 // their own defaults.
283 #[test]
284 fn builders_do_not_disturb_each_other() {
285 let base = StateTree::at("/root");
286 assert_eq!(
287 base.clone().with_cache("/c").saves_dir(),
288 base.saves_dir(),
289 "a cache root leaves the writable state alone"
290 );
291 assert_eq!(
292 base.clone().with_writable("/w").build_cache_path(),
293 base.build_cache_path(),
294 "a writable root leaves the build segment with the content"
295 );
296 }
297}