1use 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
77impl 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#[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 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#[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 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#[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 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 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#[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 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}