luminol_filesystem/
project.rs

1// Copyright (C) 2024 Melody Madeline Lyons
2//
3// This file is part of Luminol.
4//
5// Luminol is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Luminol is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Luminol.  If not, see <http://www.gnu.org/licenses/>.
17
18use color_eyre::eyre::WrapErr;
19#[cfg(target_arch = "wasm32")]
20use itertools::Itertools;
21
22use crate::FileSystem as _;
23use crate::{archiver, host, list, path_cache};
24use crate::{DirEntry, Error, Metadata, OpenFlags, Result};
25
26#[derive(Default)]
27pub enum FileSystem {
28    #[default]
29    Unloaded,
30    HostLoaded(host::FileSystem),
31    Loaded {
32        filesystem: path_cache::FileSystem<list::FileSystem>,
33        host_filesystem: host::FileSystem,
34        project_path: camino::Utf8PathBuf,
35    },
36}
37
38pub enum File {
39    Host(<host::FileSystem as crate::FileSystem>::File),
40    Loaded(<path_cache::FileSystem<list::FileSystem> as crate::FileSystem>::File),
41}
42
43#[must_use = "contains potential warnings generated while loading a project"]
44pub struct LoadResult {
45    pub missing_rtps: Vec<String>,
46}
47
48impl FileSystem {
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    pub fn project_path(&self) -> Option<camino::Utf8PathBuf> {
54        match self {
55            FileSystem::Unloaded => None,
56            FileSystem::HostLoaded(h) => Some(h.root_path().to_path_buf()),
57            FileSystem::Loaded { project_path, .. } => Some(project_path.clone()),
58        }
59    }
60
61    pub fn project_loaded(&self) -> bool {
62        !matches!(self, FileSystem::Unloaded)
63    }
64
65    pub fn unload_project(&mut self) {
66        *self = FileSystem::Unloaded;
67    }
68
69    pub fn rebuild_path_cache(&mut self) {
70        let FileSystem::Loaded { filesystem, .. } = self else {
71            return;
72        };
73        filesystem.rebuild();
74    }
75}
76
77// Not platform specific
78impl FileSystem {
79    fn detect_rm_ver(&self) -> Option<luminol_config::RMVer> {
80        if self.exists("Data/Actors.rxdata").ok()? {
81            return Some(luminol_config::RMVer::XP);
82        }
83
84        if self.exists("Data/Actors.rvdata").ok()? {
85            return Some(luminol_config::RMVer::VX);
86        }
87
88        if self.exists("Data/Actors.rvdata2").ok()? {
89            return Some(luminol_config::RMVer::Ace);
90        }
91
92        for path in self.read_dir("").ok()? {
93            let path = path.path();
94            if path.extension() == Some("rgssad") {
95                return Some(luminol_config::RMVer::XP);
96            }
97
98            if path.extension() == Some("rgss2a") {
99                return Some(luminol_config::RMVer::VX);
100            }
101
102            if path.extension() == Some("rgss3a") {
103                return Some(luminol_config::RMVer::Ace);
104            }
105        }
106
107        None
108    }
109
110    fn load_project_config(&self) -> Result<luminol_config::project::Config> {
111        let c = "While loading project configuration";
112        self.create_dir(".luminol").wrap_err(c)?;
113
114        let game_ini = match self
115            .read_to_string("Game.ini")
116            .ok()
117            .and_then(|i| ini::Ini::load_from_str_noescape(&i).ok())
118        {
119            Some(i) => i,
120            None => {
121                let mut ini = ini::Ini::new();
122                ini.with_section(Some("Game"))
123                    .set("Library", "RGSS104E.dll")
124                    .set("Scripts", "Data/Scripts.rxdata")
125                    .set("Title", "")
126                    .set("RTP1", "")
127                    .set("RTP2", "")
128                    .set("RTP3", "");
129
130                let mut file = self.open_file(
131                    "Game.ini",
132                    OpenFlags::Write | OpenFlags::Create | OpenFlags::Truncate,
133                )?;
134                ini.write_to(&mut file)?;
135
136                ini
137            }
138        };
139
140        let pretty_config = ron::ser::PrettyConfig::new()
141            .struct_names(true)
142            .enumerate_arrays(true);
143
144        let project = match self
145            .read_to_string(".luminol/config")
146            .ok()
147            .and_then(|s| ron::from_str::<luminol_config::project::Project>(&s).ok())
148        {
149            Some(config) if config.persistence_id != 0 => config,
150            Some(mut config) => {
151                while config.persistence_id == 0 {
152                    config.persistence_id = rand::random();
153                }
154                self.write(
155                    ".luminol/config",
156                    ron::ser::to_string_pretty(&config, pretty_config.clone()).wrap_err(c)?,
157                )
158                .wrap_err(c)?;
159                config
160            }
161            None => {
162                let Some(editor_ver) = self.detect_rm_ver() else {
163                    return Err(Error::UnableToDetectRMVer).wrap_err(c);
164                };
165                let project_name = game_ini
166                    .general_section()
167                    .get("Title")
168                    .unwrap_or("Untitled Project")
169                    .to_string();
170                let config = luminol_config::project::Project {
171                    editor_ver,
172                    project_name,
173                    ..Default::default()
174                };
175                self.write(
176                    ".luminol/config",
177                    ron::ser::to_string_pretty(&config, pretty_config.clone()).wrap_err(c)?,
178                )
179                .wrap_err(c)?;
180                config
181            }
182        };
183
184        let command_db = match self
185            .read_to_string(".luminol/commands")
186            .ok()
187            .and_then(|s| ron::from_str(&s).ok())
188        {
189            Some(c) => c,
190            None => {
191                let command_db = luminol_config::command_db::CommandDB::new(project.editor_ver);
192                self.write(
193                    ".luminol/commands",
194                    ron::ser::to_string_pretty(&command_db, pretty_config.clone()).wrap_err(c)?,
195                )
196                .wrap_err(c)?;
197                command_db
198            }
199        };
200
201        Ok(luminol_config::project::Config {
202            project,
203            command_db,
204            game_ini,
205        })
206    }
207
208    pub fn debug_ui(&self, ui: &mut egui::Ui) {
209        ui.set_width(ui.available_width());
210
211        match self {
212            FileSystem::Unloaded => {
213                ui.label("Unloaded");
214            }
215            FileSystem::HostLoaded(fs) => {
216                ui.label("Host Filesystem Loaded");
217                ui.horizontal(|ui| {
218                    ui.label("Project path: ");
219                    ui.label(fs.root_path().as_str());
220                });
221            }
222            FileSystem::Loaded { filesystem, .. } => {
223                ui.label("Loaded");
224                filesystem.debug_ui(ui);
225            }
226        }
227    }
228
229    pub fn load_project(
230        &mut self,
231        host: host::FileSystem,
232        project_config: &mut Option<luminol_config::project::Config>,
233        global_config: &mut luminol_config::global::Config,
234    ) -> Result<LoadResult> {
235        let c = "While loading project data";
236
237        *self = FileSystem::HostLoaded(host);
238        let config = self.load_project_config().wrap_err(c)?;
239
240        let Self::HostLoaded(host) = std::mem::take(self) else {
241            return Err(std::io::Error::new(
242                std::io::ErrorKind::PermissionDenied,
243                "Unable to fetch host filesystem",
244            )
245            .into());
246        };
247
248        let result = self
249            .load_partially_loaded_project(host, &config, global_config)
250            .wrap_err(c)?;
251
252        *project_config = Some(config);
253
254        Ok(result)
255    }
256
257    pub fn host(&self) -> Option<host::FileSystem> {
258        match self {
259            FileSystem::Unloaded => None,
260            FileSystem::HostLoaded(host) => Some(host.clone()),
261            FileSystem::Loaded {
262                host_filesystem, ..
263            } => Some(host_filesystem.clone()),
264        }
265    }
266
267    pub fn desensitize(&self, path: impl AsRef<camino::Utf8Path>) -> Result<camino::Utf8PathBuf> {
268        match self {
269            FileSystem::Unloaded | FileSystem::HostLoaded(_) => Err(Error::NotExist.into()),
270            FileSystem::Loaded { filesystem, .. } => filesystem.desensitize(path),
271        }
272    }
273}
274
275// Specific to windows
276#[cfg(windows)]
277impl FileSystem {
278    fn find_rtp_paths(
279        filesystem: &host::FileSystem,
280        config: &luminol_config::project::Config,
281        global_config: &luminol_config::global::Config,
282    ) -> (Vec<camino::Utf8PathBuf>, Vec<String>) {
283        let Some(section) = config.game_ini.section(Some("Game")) else {
284            return (vec![], vec![]);
285        };
286        let mut paths = vec![];
287        let mut seen_rtps = vec![];
288        let mut missing_rtps = vec![];
289        // FIXME: handle vx ace?
290        for rtp in ["RTP1", "RTP2", "RTP3"] {
291            if let Some(rtp) = section.get(rtp) {
292                if seen_rtps.contains(&rtp) || rtp.is_empty() {
293                    continue;
294                }
295                seen_rtps.push(rtp);
296
297                let hklm = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE);
298                if let Ok(value) = hklm
299                    .open_subkey("SOFTWARE\\WOW6432Node\\Enterbrain\\RGSS\\RTP")
300                    .and_then(|key| key.get_value::<String, _>(rtp))
301                {
302                    let path = camino::Utf8PathBuf::from(value);
303                    if path.exists() {
304                        paths.push(path);
305                        continue;
306                    }
307                }
308
309                if let Ok(value) = hklm
310                    .open_subkey("SOFTWARE\\WOW6432Node\\Enterbrain\\RPGXP")
311                    .and_then(|key| key.get_value::<String, _>("ApplicationPath"))
312                {
313                    let path = camino::Utf8PathBuf::from(value).join("rtp");
314                    if path.exists() {
315                        paths.push(path);
316                        continue;
317                    }
318                }
319
320                if let Ok(value) = hklm
321                    .open_subkey(
322                        "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Steam App 235900",
323                    )
324                    .and_then(|key| key.get_value::<String, _>("InstallLocation"))
325                {
326                    let path = camino::Utf8PathBuf::from(value).join("rtp");
327                    if path.exists() {
328                        paths.push(path);
329                        continue;
330                    }
331                }
332
333                let path = filesystem.root_path().join("RTP").join(rtp);
334                if let Ok(exists) = filesystem.exists(&path) {
335                    if exists {
336                        paths.push(path);
337                        continue;
338                    }
339                }
340
341                if let Some(path) = global_config.rtp_paths.get(rtp) {
342                    let path = camino::Utf8PathBuf::from(path);
343                    if path.exists() {
344                        paths.push(path);
345                        continue;
346                    }
347                }
348
349                missing_rtps.push(rtp.to_string());
350            }
351        }
352        (paths, missing_rtps)
353    }
354}
355
356// Specific to anything BUT windows
357#[cfg(not(any(windows, target_arch = "wasm32")))]
358impl FileSystem {
359    fn find_rtp_paths(
360        filesystem: &host::FileSystem,
361        config: &luminol_config::project::Config,
362        global_config: &luminol_config::global::Config,
363    ) -> (Vec<camino::Utf8PathBuf>, Vec<String>) {
364        let Some(section) = config.game_ini.section(Some("Game")) else {
365            return (vec![], vec![]);
366        };
367        let mut paths = vec![];
368        let mut seen_rtps = vec![];
369        let mut missing_rtps = vec![];
370        // FIXME: handle vx ace?
371        for rtp in ["RTP1", "RTP2", "RTP3"] {
372            if let Some(rtp) = section.get(rtp) {
373                if seen_rtps.contains(&rtp) || rtp.is_empty() {
374                    continue;
375                }
376                seen_rtps.push(rtp);
377
378                if let Some(path) = global_config.rtp_paths.get(rtp) {
379                    let path = camino::Utf8PathBuf::from(path);
380                    if path.exists() {
381                        paths.push(path);
382                        continue;
383                    }
384                }
385
386                let path = filesystem.root_path().join("RTP").join(rtp);
387                if let Ok(exists) = filesystem.exists(&path) {
388                    if exists {
389                        paths.push(path);
390                        continue;
391                    }
392                }
393
394                missing_rtps.push(rtp.to_string());
395            }
396        }
397        (paths, missing_rtps)
398    }
399}
400
401// Specific to native
402#[cfg(not(target_arch = "wasm32"))]
403impl FileSystem {
404    pub fn load_project_from_path(
405        &mut self,
406        project_config: &mut Option<luminol_config::project::Config>,
407        global_config: &mut luminol_config::global::Config,
408        project_path: impl AsRef<camino::Utf8Path>,
409    ) -> Result<LoadResult> {
410        let host = host::FileSystem::new(project_path);
411        self.load_project(host, project_config, global_config)
412    }
413
414    pub fn load_partially_loaded_project(
415        &mut self,
416        host: host::FileSystem,
417        project_config: &luminol_config::project::Config,
418        global_config: &mut luminol_config::global::Config,
419    ) -> Result<LoadResult> {
420        let host_clone = host.clone();
421        let project_path = host.root_path().to_path_buf();
422
423        let mut list = list::FileSystem::new();
424
425        let archive = host
426            .read_dir("")?
427            .into_iter()
428            .find(|entry| {
429                entry.metadata.is_file
430                    && matches!(entry.path.extension(), Some("rgssad" | "rgss2a" | "rgss3a"))
431            })
432            .map(|entry| host.open_file(entry.path, OpenFlags::Read | OpenFlags::Write))
433            .transpose()?
434            .map(archiver::FileSystem::new)
435            .transpose()?;
436
437        // FIXME: handle missing rtps
438        let (found_rtps, missing_rtps) = Self::find_rtp_paths(&host, project_config, global_config);
439
440        list.push(host);
441
442        for path in found_rtps {
443            list.push(host::FileSystem::new(path))
444        }
445        if let Some(archive) = archive {
446            list.push(archive);
447        }
448
449        let path_cache = path_cache::FileSystem::new(list)?;
450
451        *self = FileSystem::Loaded {
452            filesystem: path_cache,
453            host_filesystem: host_clone,
454            project_path: project_path.to_path_buf(),
455        };
456
457        // FIXME: handle
458        // if let Err(e) = state!().data_cache.load() {
459        //     *self = FileSystem::Unloaded;
460        //     return Err(e);
461        // }
462
463        let mut projects: std::collections::VecDeque<_> = global_config
464            .recent_projects
465            .iter()
466            .filter(|p| p.as_str() != project_path)
467            .cloned()
468            .collect();
469        projects.push_front(project_path.into_string());
470        global_config.recent_projects = projects;
471
472        Ok(LoadResult { missing_rtps })
473    }
474}
475
476// Specific to web
477#[cfg(target_arch = "wasm32")]
478impl FileSystem {
479    fn find_rtp_paths(
480        filesystem: &host::FileSystem,
481        config: &luminol_config::project::Config,
482    ) -> (Vec<camino::Utf8PathBuf>, Vec<String>) {
483        let Some(section) = config.game_ini.section(Some("Game")) else {
484            return (vec![], vec![]);
485        };
486        let mut paths = vec![];
487        let mut seen_rtps = vec![];
488        let mut missing_rtps = vec![];
489        // FIXME: handle vx ace?
490        for rtp in ["RTP1", "RTP2", "RTP3"] {
491            if let Some(rtp) = section.get(rtp) {
492                if seen_rtps.contains(&rtp) || rtp.is_empty() {
493                    continue;
494                }
495                seen_rtps.push(rtp);
496
497                let path = camino::Utf8PathBuf::from("RTP").join(rtp);
498                if let Ok(exists) = filesystem.exists(&path) {
499                    if exists {
500                        paths.push(path);
501                        continue;
502                    }
503                }
504
505                missing_rtps.push(rtp.to_string());
506            }
507        }
508        (paths, missing_rtps)
509    }
510
511    #[cfg(target_arch = "wasm32")]
512    pub fn load_partially_loaded_project(
513        &mut self,
514        host: host::FileSystem,
515        project_config: &luminol_config::project::Config,
516        global_config: &mut luminol_config::global::Config,
517    ) -> Result<LoadResult> {
518        let entries = host.read_dir("")?;
519        if !entries.iter().any(|e| {
520            if let Some(extension) = e.path.extension() {
521                e.metadata.is_file
522                    && (extension == "rxproj"
523                        || extension == "rvproj"
524                        || extension == "rvproj2"
525                        || extension == "lumproj")
526            } else {
527                false
528            }
529        }) {
530            return Err(Error::InvalidProjectFolder.into());
531        };
532
533        let root_path = host.root_path().to_path_buf();
534
535        let mut list = list::FileSystem::new();
536
537        let (found_rtps, missing_rtps) = Self::find_rtp_paths(&host, project_config);
538        let rtp_filesystems: Vec<_> = found_rtps
539            .into_iter()
540            .map(|rtp| host.subdir(rtp))
541            .try_collect()?;
542
543        let archive = host
544            .read_dir("")?
545            .into_iter()
546            .find(|entry| {
547                entry.metadata.is_file
548                    && matches!(entry.path.extension(), Some("rgssad" | "rgss2a" | "rgss3a"))
549            })
550            .map(|entry| host.open_file(entry.path, OpenFlags::Read | OpenFlags::Write))
551            .transpose()?
552            .map(archiver::FileSystem::new)
553            .transpose()?;
554
555        list.push(host.clone());
556        for filesystem in rtp_filesystems {
557            list.push(filesystem)
558        }
559        if let Some(archive) = archive {
560            list.push(archive);
561        }
562
563        let path_cache = path_cache::FileSystem::new(list)?;
564
565        *self = Self::Loaded {
566            filesystem: path_cache,
567            host_filesystem: host.clone(),
568            project_path: root_path.clone(),
569        };
570
571        if let Ok(idb_key) = host.save_to_idb() {
572            let mut projects: std::collections::VecDeque<_> = global_config
573                .recent_projects
574                .iter()
575                .filter(|(_, k)| k.as_str() != idb_key)
576                .cloned()
577                .collect();
578            projects.push_front((root_path.to_string(), idb_key.to_string()));
579            global_config.recent_projects = projects;
580        }
581
582        Ok(LoadResult { missing_rtps })
583    }
584}
585
586impl std::io::Write for File {
587    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
588        match self {
589            File::Host(f) => f.write(buf),
590            File::Loaded(f) => f.write(buf),
591        }
592    }
593
594    fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result<usize> {
595        match self {
596            File::Host(f) => f.write_vectored(bufs),
597            File::Loaded(f) => f.write_vectored(bufs),
598        }
599    }
600
601    fn flush(&mut self) -> std::io::Result<()> {
602        match self {
603            File::Host(f) => f.flush(),
604            File::Loaded(f) => f.flush(),
605        }
606    }
607}
608
609impl std::io::Read for File {
610    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
611        match self {
612            File::Host(f) => f.read(buf),
613            File::Loaded(f) => f.read(buf),
614        }
615    }
616
617    fn read_vectored(&mut self, bufs: &mut [std::io::IoSliceMut<'_>]) -> std::io::Result<usize> {
618        match self {
619            File::Host(f) => f.read_vectored(bufs),
620            File::Loaded(f) => f.read_vectored(bufs),
621        }
622    }
623
624    fn read_exact(&mut self, buf: &mut [u8]) -> std::io::Result<()> {
625        match self {
626            File::Host(f) => f.read_exact(buf),
627            File::Loaded(f) => f.read_exact(buf),
628        }
629    }
630}
631
632impl std::io::Seek for File {
633    fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
634        match self {
635            File::Host(f) => f.seek(pos),
636            File::Loaded(f) => f.seek(pos),
637        }
638    }
639
640    fn stream_position(&mut self) -> std::io::Result<u64> {
641        match self {
642            File::Host(f) => f.stream_position(),
643            File::Loaded(f) => f.stream_position(),
644        }
645    }
646}
647
648impl crate::File for File {
649    fn metadata(&self) -> std::io::Result<Metadata> {
650        match self {
651            File::Host(h) => crate::File::metadata(h),
652            File::Loaded(l) => l.metadata(),
653        }
654    }
655
656    fn set_len(&self, new_size: u64) -> std::io::Result<()> {
657        match self {
658            File::Host(f) => f.set_len(new_size),
659            File::Loaded(f) => f.set_len(new_size),
660        }
661    }
662}
663
664impl crate::FileSystem for FileSystem {
665    type File = File;
666
667    fn open_file(
668        &self,
669        path: impl AsRef<camino::Utf8Path>,
670        flags: OpenFlags,
671    ) -> Result<Self::File> {
672        match self {
673            FileSystem::Unloaded => Err(Error::NotLoaded.into()),
674            FileSystem::HostLoaded(f) => f.open_file(path, flags).map(File::Host),
675            FileSystem::Loaded { filesystem: f, .. } => f.open_file(path, flags).map(File::Loaded),
676        }
677    }
678
679    fn metadata(&self, path: impl AsRef<camino::Utf8Path>) -> Result<Metadata> {
680        match self {
681            FileSystem::Unloaded => Err(Error::NotLoaded.into()),
682            FileSystem::HostLoaded(f) => f.metadata(path),
683            FileSystem::Loaded { filesystem: f, .. } => f.metadata(path),
684        }
685    }
686
687    fn rename(
688        &self,
689        from: impl AsRef<camino::Utf8Path>,
690        to: impl AsRef<camino::Utf8Path>,
691    ) -> Result<()> {
692        match self {
693            FileSystem::Unloaded => Err(Error::NotLoaded.into()),
694            FileSystem::HostLoaded(f) => f.rename(from, to),
695            FileSystem::Loaded { filesystem, .. } => filesystem.rename(from, to),
696        }
697    }
698
699    fn exists(&self, path: impl AsRef<camino::Utf8Path>) -> Result<bool> {
700        match self {
701            FileSystem::Unloaded => Err(Error::NotLoaded.into()),
702            FileSystem::HostLoaded(f) => f.exists(path),
703            FileSystem::Loaded { filesystem, .. } => filesystem.exists(path),
704        }
705    }
706
707    fn create_dir(&self, path: impl AsRef<camino::Utf8Path>) -> Result<()> {
708        match self {
709            FileSystem::Unloaded => Err(Error::NotLoaded.into()),
710            FileSystem::HostLoaded(f) => f.create_dir(path),
711            FileSystem::Loaded { filesystem, .. } => filesystem.create_dir(path),
712        }
713    }
714
715    fn remove_dir(&self, path: impl AsRef<camino::Utf8Path>) -> Result<()> {
716        match self {
717            FileSystem::Unloaded => Err(Error::NotLoaded.into()),
718            FileSystem::HostLoaded(f) => f.remove_dir(path),
719            FileSystem::Loaded { filesystem, .. } => filesystem.remove_dir(path),
720        }
721    }
722
723    fn remove_file(&self, path: impl AsRef<camino::Utf8Path>) -> Result<()> {
724        match self {
725            FileSystem::Unloaded => Err(Error::NotLoaded.into()),
726            FileSystem::HostLoaded(f) => f.remove_file(path),
727            FileSystem::Loaded { filesystem, .. } => filesystem.remove_file(path),
728        }
729    }
730
731    fn read_dir(&self, path: impl AsRef<camino::Utf8Path>) -> Result<Vec<DirEntry>> {
732        match self {
733            FileSystem::Unloaded => Err(Error::NotLoaded.into()),
734            FileSystem::HostLoaded(f) => f.read_dir(path),
735            FileSystem::Loaded { filesystem, .. } => filesystem.read_dir(path),
736        }
737    }
738}