From d8907de6c85cb5af51d13fa1992befae667e5dd1 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Sat, 30 Nov 2024 20:30:41 -0500 Subject: [PATCH 01/13] batch migration Signed-off-by: Joseph Mattiello --- .../PVLibrary/Database/PVGameLibrary.swift | 69 +++++++++++-------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift index c9c4f95543..72a6dd9b03 100644 --- a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift +++ b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift @@ -217,44 +217,46 @@ public final class ROMLocationMigrator { ILOG("Found \(contents.count) items to process in \(sourceDir.lastPathComponent)") - try await withThrowingTaskGroup(of: Void.self) { group in - for itemURL in contents { - group.addTask { - let isDirectory = try itemURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false - let relativePath = itemURL.lastPathComponent - let destinationURL = destDir.appendingPathComponent(relativePath) - - if isDirectory { - // Create destination directory if it doesn't exist - if !self.fileManager.fileExists(atPath: destinationURL.path) { - try self.fileManager.createDirectory(at: destinationURL, - withIntermediateDirectories: true, - attributes: nil) - } - // Recursively migrate contents of subdirectory - try await self.migrateDirectory(from: itemURL, to: destinationURL) + // Process in smaller batches to reduce UI impact + let batchSize = 10 + for batch in contents.chunked(into: batchSize) { + try await withThrowingTaskGroup(of: Void.self) { group in + for itemURL in batch { + group.addTask { + let isDirectory = try itemURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false + let relativePath = itemURL.lastPathComponent + let destinationURL = destDir.appendingPathComponent(relativePath) - // Try to remove empty source directory - if try self.fileManager.contentsOfDirectory(atPath: itemURL.path).isEmpty { - try await self.fileManager.removeItem(at: itemURL) - ILOG("Removed empty directory: \(itemURL.lastPathComponent)") - } - } else { - // Handle file migration - if self.fileManager.fileExists(atPath: destinationURL.path) { - ILOG("Skipping \(relativePath) as it already exists in destination") + if isDirectory { + if !self.fileManager.fileExists(atPath: destinationURL.path) { + try self.fileManager.createDirectory(at: destinationURL, + withIntermediateDirectories: true, + attributes: nil) + } + try await self.migrateDirectory(from: itemURL, to: destinationURL) + + if try self.fileManager.contentsOfDirectory(atPath: itemURL.path).isEmpty { + try? await self.fileManager.removeItem(at: itemURL) + ILOG("Removed empty directory: \(itemURL.lastPathComponent)") + } } else { - try self.fileManager.moveItem(at: itemURL, to: destinationURL) - ILOG("Successfully migrated: \(relativePath)") + if self.fileManager.fileExists(atPath: destinationURL.path) { + ILOG("Skipping \(relativePath) as it already exists in destination") + } else { + try self.fileManager.moveItem(at: itemURL, to: destinationURL) + ILOG("Successfully migrated: \(relativePath)") + } } } } + try await group.waitForAll() } - try await group.waitForAll() + // Add a small delay between batches to let UI breathe + try await Task.sleep(nanoseconds: 10_000_000) // 10ms delay } - // Try to remove source directory if empty + // Cleanup empty source directory do { let remainingItems = try fileManager.contentsOfDirectory( at: sourceDir, @@ -271,3 +273,12 @@ public final class ROMLocationMigrator { } } } + +// Add this extension to support chunking +private extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} From 1d713d90551b783fd570ca28a8a3e4f8dc75075d Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Sat, 30 Nov 2024 20:31:15 -0500 Subject: [PATCH 02/13] package update Signed-off-by: Joseph Mattiello --- PVUI/Package.resolved | 56 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/PVUI/Package.resolved b/PVUI/Package.resolved index 691cc4fa06..99a6ef6970 100644 --- a/PVUI/Package.resolved +++ b/PVUI/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "c76af2cf0ed3103331d746b738bc8973bf534246a714d82da5869cb0caca17ee", + "originHash" : "2b15c8052d6aba466a44cc8ac80fb1f92529b726039a7a349f7cd98a1ca76bcb", "pins" : [ + { + "identity" : "activityindicatorview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/ActivityIndicatorView.git", + "state" : { + "revision" : "9970fd0bb7a05dad0b6566ae1f56937716686b24", + "version" : "1.1.1" + } + }, + { + "identity" : "animatedgradient", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/AnimatedGradient.git", + "state" : { + "revision" : "70f5d34bc553483936e86eca3984b6e12302ead2", + "version" : "1.0.0" + } + }, { "identity" : "bitbytedata", "kind" : "remoteSourceControl", @@ -28,6 +46,15 @@ "version" : "1.1.1" } }, + { + "identity" : "daterangepicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MrAsterisco/DateRangePicker", + "state" : { + "revision" : "9fd27bed5a951e7a48e2211465fdfc7e9bf2cbe7", + "version" : "1.0.2" + } + }, { "identity" : "defaults", "kind" : "remoteSourceControl", @@ -37,6 +64,15 @@ "revision" : "a89f799930c92a85aa04b72131849d46c0833ab3" } }, + { + "identity" : "floatingbutton", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/FloatingButton.git", + "state" : { + "revision" : "cf77c2f124df1423d90a9a1985e9b9ccfa4b9b3e", + "version" : "1.3.0" + } + }, { "identity" : "freemiumkit", "kind" : "remoteSourceControl", @@ -73,6 +109,15 @@ "version" : "1.2.0" } }, + { + "identity" : "opendateinterval", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MrAsterisco/OpenDateInterval", + "state" : { + "revision" : "acf88f98adae4ef2f7040d3fcc23685b094b219b", + "version" : "1.0.0" + } + }, { "identity" : "packagebuildinfo", "kind" : "remoteSourceControl", @@ -244,6 +289,15 @@ "version" : "0.2.3" } }, + { + "identity" : "swipecellsui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DominikButz/SwipeCellSUI.git", + "state" : { + "revision" : "d1b121a9b30789b603d7fa7559889925f20fe47e", + "version" : "2.1.4" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", From 30ca3354f226d728a064d9041149beaf19568a24 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Sat, 30 Nov 2024 20:33:34 -0500 Subject: [PATCH 03/13] optimize migration code Signed-off-by: Joseph Mattiello --- .../PVLibrary/Database/PVGameLibrary.swift | 112 +++++++++++------- 1 file changed, 69 insertions(+), 43 deletions(-) diff --git a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift index 72a6dd9b03..9273ece0b5 100644 --- a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift +++ b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift @@ -208,68 +208,94 @@ public final class ROMLocationMigrator { private func migrateDirectory(from sourceDir: URL, to destDir: URL) async throws { ILOG("Migrating directory: \(sourceDir.lastPathComponent)") - // Get all items in source directory - let contents = try fileManager.contentsOfDirectory( + // Get all items in source directory with minimal property loading + let resourceKeys: Set = [.isDirectoryKey] + let enumerator = FileManager.default.enumerator( at: sourceDir, - includingPropertiesForKeys: [.isDirectoryKey], + includingPropertiesForKeys: Array(resourceKeys), options: [.skipsHiddenFiles] ) - ILOG("Found \(contents.count) items to process in \(sourceDir.lastPathComponent)") - - // Process in smaller batches to reduce UI impact - let batchSize = 10 - for batch in contents.chunked(into: batchSize) { - try await withThrowingTaskGroup(of: Void.self) { group in - for itemURL in batch { - group.addTask { - let isDirectory = try itemURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false - let relativePath = itemURL.lastPathComponent - let destinationURL = destDir.appendingPathComponent(relativePath) - - if isDirectory { - if !self.fileManager.fileExists(atPath: destinationURL.path) { - try self.fileManager.createDirectory(at: destinationURL, - withIntermediateDirectories: true, - attributes: nil) - } - try await self.migrateDirectory(from: itemURL, to: destinationURL) - - if try self.fileManager.contentsOfDirectory(atPath: itemURL.path).isEmpty { - try? await self.fileManager.removeItem(at: itemURL) - ILOG("Removed empty directory: \(itemURL.lastPathComponent)") - } - } else { - if self.fileManager.fileExists(atPath: destinationURL.path) { - ILOG("Skipping \(relativePath) as it already exists in destination") - } else { - try self.fileManager.moveItem(at: itemURL, to: destinationURL) - ILOG("Successfully migrated: \(relativePath)") - } + guard let enumerator = enumerator else { + ELOG("Failed to create enumerator for \(sourceDir.path)") + return + } + + // Process items in chunks to avoid memory pressure + let chunkSize = 20 + var itemsToProcess: [URL] = [] + + while let url = enumerator.nextObject() as? URL { + itemsToProcess.append(url) + + if itemsToProcess.count >= chunkSize { + try await processChunk(itemsToProcess, sourceDir: sourceDir, destDir: destDir) + itemsToProcess.removeAll(keepingCapacity: true) + + // Brief pause between chunks to let the system breathe + try await Task.sleep(nanoseconds: 50_000_000) // 50ms + } + } + + // Process remaining items + if !itemsToProcess.isEmpty { + try await processChunk(itemsToProcess, sourceDir: sourceDir, destDir: destDir) + } + + // Cleanup empty source directory at the end + try await cleanupSourceDirectory(sourceDir) + } + + private func processChunk(_ urls: [URL], sourceDir: URL, destDir: URL) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + for itemURL in urls { + group.addTask { + let relativePath = itemURL.lastPathComponent + let destinationURL = destDir.appendingPathComponent(relativePath) + + // Quick check for existing files first + if FileManager.default.fileExists(atPath: destinationURL.path) { + ILOG("Skipping \(relativePath) as it already exists") + return + } + + let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) + + if resourceValues.isDirectory ?? false { + // Handle directory + if !FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.createDirectory( + at: destinationURL, + withIntermediateDirectories: true, + attributes: nil + ) } + try await self.migrateDirectory(from: itemURL, to: destinationURL) + } else { + // Handle file + try FileManager.default.moveItem(at: itemURL, to: destinationURL) + ILOG("Migrated: \(relativePath)") } } - try await group.waitForAll() } - - // Add a small delay between batches to let UI breathe - try await Task.sleep(nanoseconds: 10_000_000) // 10ms delay + try await group.waitForAll() } + } - // Cleanup empty source directory + private func cleanupSourceDirectory(_ sourceDir: URL) async throws { do { - let remainingItems = try fileManager.contentsOfDirectory( + let contents = try FileManager.default.contentsOfDirectory( at: sourceDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) - if remainingItems.isEmpty { - try await fileManager.removeItem(at: sourceDir) + if contents.isEmpty { + try await FileManager.default.removeItem(at: sourceDir) ILOG("Removed empty directory: \(sourceDir.lastPathComponent)") } } catch { - ELOG("Error cleaning up directory \(sourceDir.lastPathComponent): \(error.localizedDescription)") + ELOG("Error cleaning up \(sourceDir.lastPathComponent): \(error.localizedDescription)") } } } From c29f6bddd59158e095c46607c87002f08a609af5 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Sat, 30 Nov 2024 20:59:36 -0500 Subject: [PATCH 04/13] optimize migration code Signed-off-by: Joseph Mattiello --- .../PVLibrary/Database/PVGameLibrary.swift | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift index 9273ece0b5..76586052ee 100644 --- a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift +++ b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift @@ -198,6 +198,9 @@ public final class ROMLocationMigrator { } try await migrateDirectory(from: oldPath, to: newPath) + + // Clean up empty directories after migration is complete + try await cleanupSourceDirectory(oldPath) } else { ILOG("No old \(oldPath.lastPathComponent) directory found, skipping migration") } @@ -241,9 +244,6 @@ public final class ROMLocationMigrator { if !itemsToProcess.isEmpty { try await processChunk(itemsToProcess, sourceDir: sourceDir, destDir: destDir) } - - // Cleanup empty source directory at the end - try await cleanupSourceDirectory(sourceDir) } private func processChunk(_ urls: [URL], sourceDir: URL, destDir: URL) async throws { @@ -286,13 +286,36 @@ public final class ROMLocationMigrator { do { let contents = try FileManager.default.contentsOfDirectory( at: sourceDir, - includingPropertiesForKeys: nil, + includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) - if contents.isEmpty { - try await FileManager.default.removeItem(at: sourceDir) - ILOG("Removed empty directory: \(sourceDir.lastPathComponent)") + // First check if there are any files (non-directories) + let hasFiles = contents.contains { url in + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + return !isDirectory + } + + if !hasFiles { + // Process subdirectories recursively + for url in contents { + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + if isDirectory { + try await cleanupSourceDirectory(url) + } + } + + // Check again after processing subdirectories + let remainingContents = try FileManager.default.contentsOfDirectory( + at: sourceDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + + if remainingContents.isEmpty { + try await FileManager.default.removeItem(at: sourceDir) + ILOG("Removed empty directory: \(sourceDir.lastPathComponent)") + } } } catch { ELOG("Error cleaning up \(sourceDir.lastPathComponent): \(error.localizedDescription)") From a535f1da973afc32cf83250a85da34955fcc5d08 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Sat, 30 Nov 2024 20:59:56 -0500 Subject: [PATCH 05/13] optimize realm code in homecontinuesection Signed-off-by: Joseph Mattiello --- .../PVSwiftUI/Home/HomeContinueSection.swift | 208 +++++------------- 1 file changed, 61 insertions(+), 147 deletions(-) diff --git a/PVUI/Sources/PVSwiftUI/Home/HomeContinueSection.swift b/PVUI/Sources/PVSwiftUI/Home/HomeContinueSection.swift index 1ef547dde1..ef3e623d2e 100644 --- a/PVUI/Sources/PVSwiftUI/Home/HomeContinueSection.swift +++ b/PVUI/Sources/PVSwiftUI/Home/HomeContinueSection.swift @@ -69,10 +69,11 @@ struct HomeContinueSection: SwiftUI.View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass + /// Filtered save states based on console identifier @ObservedResults( PVSaveState.self, sortDescriptor: SortDescriptor(keyPath: #keyPath(PVSaveState.date), ascending: false) - ) var allSaveStates + ) private var filteredSaveStates weak var rootDelegate: PVRootDelegate? let defaultHeight: CGFloat = 260 @@ -83,6 +84,31 @@ struct HomeContinueSection: SwiftUI.View { @StateObject private var viewModel = ContinuesSectionViewModel() + init(rootDelegate: PVRootDelegate?, consoleIdentifier: String?, parentFocusedSection: Binding, parentFocusedItem: Binding) { + self.rootDelegate = rootDelegate + self.consoleIdentifier = consoleIdentifier + self._parentFocusedSection = parentFocusedSection + self._parentFocusedItem = parentFocusedItem + + // Create the filter predicate based on console identifier + let baseFilter = NSPredicate(format: "game != nil") + if let consoleId = consoleIdentifier { + let consoleFilter = NSPredicate(format: "game.systemIdentifier == %@", consoleId) + let combinedFilter = NSCompoundPredicate(andPredicateWithSubpredicates: [baseFilter, consoleFilter]) + _filteredSaveStates = ObservedResults( + PVSaveState.self, + filter: combinedFilter, + sortDescriptor: SortDescriptor(keyPath: #keyPath(PVSaveState.date), ascending: false) + ) + } else { + _filteredSaveStates = ObservedResults( + PVSaveState.self, + filter: baseFilter, + sortDescriptor: SortDescriptor(keyPath: #keyPath(PVSaveState.date), ascending: false) + ) + } + } + var isLandscapePhone: Bool { #if os(iOS) return UIDevice.current.userInterfaceIdiom == .phone && @@ -100,26 +126,15 @@ struct HomeContinueSection: SwiftUI.View { isLandscapePhone ? 2 : 1 } - var filteredSaveStates: [PVSaveState] { - let validSaveStates = allSaveStates.filter { !$0.isInvalidated } - - if let consoleIdentifier = consoleIdentifier { - return validSaveStates.filter { - $0.game != nil && - !$0.game.isInvalidated && - $0.game.systemIdentifier == consoleIdentifier - } - } else { - return validSaveStates.filter { $0.game != nil && !$0.game.isInvalidated } - } + /// Number of pages based on number of save states and items per page + private var pageCount: Int { + let itemsPerPage = viewModel.itemsPerPage + return max(1, Int(ceil(Double(filteredSaveStates.count) / Double(itemsPerPage)))) } - var gridColumns: [GridItem] { - if isLandscapePhone { - [GridItem(.flexible()), GridItem(.flexible())] - } else { - [GridItem(.flexible())] - } + /// Grid columns configuration + private var gridColumns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: 16), count: columns) } // Add properties for navigation @@ -130,7 +145,7 @@ struct HomeContinueSection: SwiftUI.View { var body: some SwiftUI.View { TabView(selection: $viewModel.currentPage) { - if filteredSaveStates.count > 0 { + if !filteredSaveStates.isEmpty { ForEach(0.. let isLandscapePhone: Bool let gridColumns: [GridItem] let adjustedHeight: CGFloat @@ -309,131 +315,39 @@ private struct SaveStatesGridView: View { @Binding var parentFocusedSection: HomeSectionType? @Binding var parentFocusedItem: String? - @ObservedObject var viewModel: ContinuesSectionViewModel - var body: some View { - LazyVGrid(columns: gridColumns, spacing: 8) { - if isLandscapePhone { - LandscapeGridContent( - pageIndex: pageIndex, - filteredSaveStates: filteredSaveStates, - adjustedHeight: adjustedHeight, - hideSystemLabel: hideSystemLabel, - rootDelegate: rootDelegate, - parentFocusedSection: $parentFocusedSection, - parentFocusedItem: $parentFocusedItem - ) - } else { - PortraitGridContent( - pageIndex: pageIndex, - filteredSaveStates: filteredSaveStates, - adjustedHeight: adjustedHeight, - hideSystemLabel: hideSystemLabel, - rootDelegate: rootDelegate, - parentFocusedSection: $parentFocusedSection, - parentFocusedItem: $parentFocusedItem - ) - } - } + private var saveStatesForPage: [PVSaveState] { + let startIndex = pageIndex * viewModel.itemsPerPage + let endIndex = min(startIndex + viewModel.itemsPerPage, filteredSaveStates.count) + return Array(filteredSaveStates[startIndex.. Date: Sun, 1 Dec 2024 16:29:29 -0500 Subject: [PATCH 06/13] refactor save state recovery and deletion to RomDatabase Signed-off-by: Joseph Mattiello --- .../Realm Database/RomDatabase+Saves.swift | 174 ++++++++++++++++++ .../PVLibrary/Errors/SaveStateError.swift | 28 +++ .../Settings/PVSettingsViewModel.swift | 2 +- .../PVEmulatorViewController+Saves.swift | 88 +-------- .../xcshareddata/swiftpm/Package.resolved | 4 +- Provenance/Main UI/PVAppDelegate.swift | 2 + 6 files changed, 209 insertions(+), 89 deletions(-) create mode 100644 PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift create mode 100644 PVLibrary/Sources/PVLibrary/Errors/SaveStateError.swift diff --git a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift new file mode 100644 index 0000000000..e83b771243 --- /dev/null +++ b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift @@ -0,0 +1,174 @@ +// +// RomDatabase+Saves.swift +// PVLibrary +// +// Created by Joseph Mattiello on 11/30/24. +// + +import PVCoreBridge + +/// Save state purging and recoovery +public extension RomDatabase { + + /// Recover save states from the save state directory + public func recoverSaveStates(forGame game: PVGame, core: EmulatorCoreIOInterface) throws { + let saveStatePath: URL = PVEmulatorConfiguration.saveStatePath(forGame: game) + + do { + let fileManager = FileManager.default + let directoryContents = try fileManager.contentsOfDirectory( + at: saveStatePath, + includingPropertiesForKeys:[.contentModificationDateKey] + ).filter { $0.lastPathComponent.hasSuffix(".svs") } + .sorted(by: { + let date0 = try $0.promisedItemResourceValues(forKeys:[.contentModificationDateKey]).contentModificationDate! + let date1 = try $1.promisedItemResourceValues(forKeys:[.contentModificationDateKey]).contentModificationDate! + return date0.compare(date1) == .orderedAscending + }) + let realm = RomDatabase.sharedInstance.realm + var saves:[String:Int]=[:] + for saveState in game.saveStates { + saves[saveState.file.url.lastPathComponent.lowercased()] = 1; + } + for url in directoryContents { + let file = url.lastPathComponent.lowercased() + if (fileManager.fileExists(atPath: url.path) && + file.contains("svs") && + !file.contains("json") && + !file.contains("jpg") && + saves.index(forKey: file) == nil) { + do { + guard let core = realm.object(ofType: PVCore.self, forPrimaryKey: core.coreIdentifier) else { + throw SaveStateError.noCoreFound(core.coreIdentifier ?? "null") + return + } + let imgFile = PVImageFile(withURL: URL(fileURLWithPath: url.path.replacingOccurrences(of: "svs", with: "jpg"))) + let saveFile = PVFile(withURL: url) + let newState = PVSaveState(withGame: game, core: core, file: saveFile, image: imgFile, isAutosave: false) + try realm.write { + realm.add(newState) + } + } catch { + ELOG(error.localizedDescription) + } + } + } + } catch { + ELOG("\(error.localizedDescription)") + throw error + } + } + + public func updateSaveStates(forGame game: PVGame) throws { + do { + // Clear saves from database that don't have files + for save in game.saveStates { + if !FileManager.default.fileExists(atPath: save.file.url.path) { + try? PVSaveState.delete(save) + } + } + // Clear auto-saves from database that don't have files + for save in game.autoSaves { + if !FileManager.default.fileExists(atPath: save.file.url.path) { + try? PVSaveState.delete(save) + } + } + } catch { + ELOG("\(error.localizedDescription)") + throw error + } + } +} + +/* Example saves json + + ```json + { + "isAutosave": false, + "game": { + "systemShortName": "GBA", + "crc": "", + "lastPlayed": 752119302.780123, + "title": "Kingdom Hearts: Chain of Memories", + "file": { + "md5": "f7b81e3f3bb3b02d973fc6f145ad4416", + "local": true, + "size": 33554432, + "online": true, + "fileName": "Kingdom Hearts - Chain of Memories (U) (1).gba" + }, + "publishDate": "Dec7, 2004", + "developer": "Jupiter Corporation", + "releaseID": "51672", + "isFavorite": false, + "md5": "F7B81E3F3BB3B02D973FC6F145AD4416", + "boxBackArtworkURL": "https:\/\/gamefaqs.gamespot.com\/a\/box\/4\/9\/4\/57494_back.jpg", + "gameDescription": "KINGDOM HEARTSĀ® CHAIN OF MEMORIES delivers an entirely new adventure and sets the stage for KINGDOM HEARTS II. Sora, Donald and Goofy travel through many vast and colorful worlds in search of their missing companions.", + "systemIdentifier": "com.provenance.gba", + "genres": "Role-Playing,Action RPG", + "referenceURL": "http:\/\/www.gamefaqs.com\/gba\/919011-kingdom-hearts-chain-of-memories", + "id": "6A5DAE83-EF74-4E80-93FB-590B2E840076", + "regionName": "USA", + "regionID": 21, + "playCount": 1 + }, + "file": { + "local": true, + "md5": "b80f99dd5590603e00e16abf3afea35c", + "fileName": "F7B81E3F3BB3B02D973FC6F145AD4416.752119311.126136.svs", + "online": true, + "size": 118861 + }, + "date": 752119311.185537, + "image": { + "url": "file:\/\/\/var\/mobile\/Containers\/Data\/Application\/759855AC-1A54-4BFF-B089-9CF7212EEBF8\/Documents\/Save%20States\/Kingdom%20Hearts%20-%20Chain%20of%20Memories%20(U)%20(1)\/F7B81E3F3BB3B02D973FC6F145AD4416.752119311.126136.jpg" + }, + "id": "000C90A2-7F66-46E6-88B5-889283E290FE", + "core": { + "systems": [{ + "releaseYear": 2001, + "bits": 32, + "usesCDs": false, + "portableSystem": true, + "manufacturer": "Nintendo", + "options": 2, + "name": "Game Boy Advance", + "supported": true, + "openvgDatabaseID": 20, + "identifier": "com.provenance.gba", + "headerByteSize": 0, + "requiresBIOS": false, + "BIOSes": [{ + "optional": true, + "expectedSize": 16384, + "descriptionText": "Game Boy Advance BIOS", + "status": { + "state": { + "rawValue": 0 + }, + "required": false, + "available": false + }, + "expectedFilename": "GBA.BIOS", + "expectedMD5": "A860E8C0B6D573D191E4EC7DB1B1E4F6", + "version": "", + "regions": 1048576 + }], + "extensions": ["gba", "agb", "bin", "zip"], + "shortName": "GBA", + "supportsRumble": false, + "screenType": "ColorLCD" + }], + "identifier": "com.provenance.core.visualboyadvance", + "disabled": false, + "project": { + "url": "https:\/\/sourceforge.net\/projects\/vba\/", + "version": "1.8.0", + "name": "VisualBoyAdvance" + }, + "principleClass": "PVVisualBoyAdvance.PVVisualBoyAdvanceCore" + } + } +``` + + */ diff --git a/PVLibrary/Sources/PVLibrary/Errors/SaveStateError.swift b/PVLibrary/Sources/PVLibrary/Errors/SaveStateError.swift new file mode 100644 index 0000000000..9a8d5f2a53 --- /dev/null +++ b/PVLibrary/Sources/PVLibrary/Errors/SaveStateError.swift @@ -0,0 +1,28 @@ +// +// SaveStateError.swift +// PVLibrary +// +// Created by Joseph Mattiello on 12/1/24. +// + +public enum SaveStateError: Error { + case coreSaveError(Error?) + case coreLoadError(Error?) + case saveStatesUnsupportedByCore + case ineligibleError + case noCoreFound(String) + case realmWriteError(Error) + case realmDeletionError(Error) + + var localizedDescription: String { + switch self { + case let .coreSaveError(coreError): return "Core failed to save: \(coreError?.localizedDescription ?? "No reason given.")" + case let .coreLoadError(coreError): return "Core failed to load: \(coreError?.localizedDescription ?? "No reason given.")" + case .saveStatesUnsupportedByCore: return "This core does not support save states." + case .ineligibleError: return "Save states are currently ineligible." + case let .noCoreFound(id): return "No core found to match id: \(id)" + case let .realmWriteError(realmError): return "Unable to write save state to realm: \(realmError.localizedDescription)" + case let .realmDeletionError(realmError): return "Unable to delete old auto-save from database: \(realmError.localizedDescription)" + } + } +} diff --git a/PVUI/Sources/PVSwiftUI/Settings/PVSettingsViewModel.swift b/PVUI/Sources/PVSwiftUI/Settings/PVSettingsViewModel.swift index 9034e5c83b..a10654a102 100644 --- a/PVUI/Sources/PVSwiftUI/Settings/PVSettingsViewModel.swift +++ b/PVUI/Sources/PVSwiftUI/Settings/PVSettingsViewModel.swift @@ -212,7 +212,7 @@ class PVSettingsViewModel: ObservableObject { title: "Re-Scan all ROM Directories?", message: """ Attempt scan all ROM Directories, - import all new ROMs found, and update existing ROMs + import all new ROMs found, and update existing ROMs, and recover save states. """, preferredStyle: .alert ) diff --git a/PVUI/Sources/PVUIBase/PVEmulatorVC/PVEmulatorViewController+Saves.swift b/PVUI/Sources/PVUIBase/PVEmulatorVC/PVEmulatorViewController+Saves.swift index 35b8f57ac4..8c20c763d4 100644 --- a/PVUI/Sources/PVUIBase/PVEmulatorVC/PVEmulatorViewController+Saves.swift +++ b/PVUI/Sources/PVUIBase/PVEmulatorVC/PVEmulatorViewController+Saves.swift @@ -18,28 +18,6 @@ import PVSettings import UIKit #endif -public enum SaveStateError: Error { - case coreSaveError(Error?) - case coreLoadError(Error?) - case saveStatesUnsupportedByCore - case ineligibleError - case noCoreFound(String) - case realmWriteError(Error) - case realmDeletionError(Error) - - var localizedDescription: String { - switch self { - case let .coreSaveError(coreError): return "Core failed to save: \(coreError?.localizedDescription ?? "No reason given.")" - case let .coreLoadError(coreError): return "Core failed to load: \(coreError?.localizedDescription ?? "No reason given.")" - case .saveStatesUnsupportedByCore: return "This core does not support save states." - case .ineligibleError: return "Save states are currently ineligible." - case let .noCoreFound(id): return "No core found to match id: \(id)" - case let .realmWriteError(realmError): return "Unable to write save state to realm: \(realmError.localizedDescription)" - case let .realmDeletionError(realmError): return "Unable to delete old auto-save from database: \(realmError.localizedDescription)" - } - } -} - public extension PVEmulatorViewController { var saveStatePath: URL { get { PVEmulatorConfiguration.saveStatePath(forGame: game) } } @@ -278,8 +256,8 @@ public extension PVEmulatorViewController { @objc func showSaveStateMenu() { Task.detached { [weak self] in guard let self = self else { return } - await updateSaveStates() - await recoverSaveStates() + await try RomDatabase.sharedInstance.updateSaveStates(forGame: game) + await try RomDatabase.sharedInstance.recoverSaveStates(forGame: game, core: core) } guard let saveStatesNavController = UIStoryboard(name: "SaveStates", bundle: BundleLoader.module).instantiateViewController(withIdentifier: "PVSaveStatesViewControllerNav") as? UINavigationController else { return @@ -363,66 +341,4 @@ public extension PVEmulatorViewController { } } } - - func recoverSaveStates() { - do { - let fileManager = FileManager.default - let directoryContents = try fileManager.contentsOfDirectory( - at: saveStatePath, - includingPropertiesForKeys:[.contentModificationDateKey] - ).filter { $0.lastPathComponent.hasSuffix(".svs") } - .sorted(by: { - let date0 = try $0.promisedItemResourceValues(forKeys:[.contentModificationDateKey]).contentModificationDate! - let date1 = try $1.promisedItemResourceValues(forKeys:[.contentModificationDateKey]).contentModificationDate! - return date0.compare(date1) == .orderedAscending - }) - let realm = RomDatabase.sharedInstance.realm - var saves:[String:Int]=[:] - for saveState in game.saveStates { - saves[saveState.file.url.lastPathComponent.lowercased()] = 1; - } - for url in directoryContents { - let file = url.lastPathComponent.lowercased() - if (fileManager.fileExists(atPath: url.path) && - file.contains("svs") && - !file.contains("json") && - !file.contains("jpg") && - saves.index(forKey: file) == nil) { - do { - guard let core = realm.object(ofType: PVCore.self, forPrimaryKey: core.coreIdentifier) else { - presentError("No core in database with id \(self.core.coreIdentifier ?? "null")", source: self.view) - return - } - let imgFile = PVImageFile(withURL: URL(fileURLWithPath: url.path.replacingOccurrences(of: "svs", with: "jpg"))) - let saveFile = PVFile(withURL: url) - let newState = PVSaveState(withGame: game, core: core, file: saveFile, image: imgFile, isAutosave: false) - try realm.write { - realm.add(newState) - } - } catch { - ELOG(error.localizedDescription) - } - } - } - } catch { - ELOG("\(error.localizedDescription)") - } - } - - func updateSaveStates() { - do { - for save in game.saveStates { - if !FileManager.default.fileExists(atPath: save.file.url.path) { - try PVSaveState.delete(save) - } - } - for save in game.autoSaves { - if !FileManager.default.fileExists(atPath: save.file.url.path) { - try PVSaveState.delete(save) - } - } - } catch { - ELOG("\(error.localizedDescription)") - } - } } diff --git a/Provenance.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Provenance.xcworkspace/xcshareddata/swiftpm/Package.resolved index f1027a84a2..ff6bf396e9 100644 --- a/Provenance.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Provenance.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -87,8 +87,8 @@ "repositoryURL": "https://github.com/FlineDev/FreemiumKit.git", "state": { "branch": null, - "revision": "ff45d8e3fc7af5255f97e059cc95524dfa320196", - "version": "1.15.0" + "revision": "20eafe6b3b91e494294d8a5693e9693acb8442b6", + "version": "1.15.1" } }, { diff --git a/Provenance/Main UI/PVAppDelegate.swift b/Provenance/Main UI/PVAppDelegate.swift index a20d5fa65c..d090e497e8 100644 --- a/Provenance/Main UI/PVAppDelegate.swift +++ b/Provenance/Main UI/PVAppDelegate.swift @@ -113,6 +113,7 @@ final class PVAppDelegate: UIResponder, GameLaunchingAppDelegate, UIApplicationD await updates.importROMDirectories() } } +// RomDatabase.sharedInstance.recoverSaveStates() } promise(.success(())) } @@ -127,6 +128,7 @@ final class PVAppDelegate: UIResponder, GameLaunchingAppDelegate, UIApplicationD Task { @MainActor in do { try RomDatabase.sharedInstance.deleteAllGames() +// RomDatabase.sharedInstance.updateSaveStates() if let _ = self.gameLibraryViewController { self.gameLibraryViewController?.checkROMs(false) } else { From 2f75b1395e3d6ca6745cd63f21010b2753dd938a Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Sun, 1 Dec 2024 18:06:35 -0500 Subject: [PATCH 07/13] first pass at savestate recovery Signed-off-by: Joseph Mattiello --- .../Realm Database/RomDatabase+Saves.swift | 132 ++++++++++++++++++ .../RealmPlatform/Entities/PVSaveState.swift | 5 +- Provenance/Main UI/PVAppDelegate.swift | 4 +- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift index e83b771243..ad23dde100 100644 --- a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift +++ b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift @@ -6,10 +6,102 @@ // import PVCoreBridge +import PVFileSystem /// Save state purging and recoovery public extension RomDatabase { + public func recoverAllSaveStates() { + // Get the base directory for saves + let saveStatesDirectory: URL = Paths.saveSavesPath + // iterate sub-dirs calling recoverSaveStates(forPath: path) + let fm = FileManager.default + let subdirectories = try! fm.contentsOfDirectory(at: saveStatesDirectory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) + for subdirectory in subdirectories { + recoverSaveStates(forPath: subdirectory) + } + } + + public func recoverSaveStates(forPath path: URL) { + let fileManager = FileManager.default + + // Get all .svs.json files in the directory + guard let jsonFiles = try? fileManager.contentsOfDirectory(at: path, includingPropertiesForKeys: nil) + .filter({ $0.pathExtension == "json" && $0.lastPathComponent.contains(".svs.") }) else { + ELOG("Failed to read directory contents at \(path)") + return + } + + for jsonURL in jsonFiles { + do { + // 1. Read and decode the JSON file + let jsonData = try Data(contentsOf: jsonURL) + let decoder = JSONDecoder() + let saveStateMetadata = try decoder.decode(SaveStateMetadata.self, from: jsonData) + + // 2. Check if this save state already exists in the database + if let existingSave = realm.object(ofType: PVSaveState.self, forPrimaryKey: saveStateMetadata.id) { + DLOG("Save state already exists: \(existingSave.id)") + continue + } + + // 3. Find the matching game using MD5 + guard let game = realm.object(ofType: PVGame.self, forPrimaryKey: saveStateMetadata.game.md5) else { + WLOG("No matching game found for save state: \(saveStateMetadata.id)") + continue + } + + // 4. Find the matching core + guard let core = realm.object(ofType: PVCore.self, forPrimaryKey: saveStateMetadata.core.identifier) else { + WLOG("No matching core found for save state: \(saveStateMetadata.id)") + continue + } + + // 5. Create PVFile for the save state + let saveFileURL = jsonURL.deletingPathExtension() // Remove .json extension + guard fileManager.fileExists(atPath: saveFileURL.path) else { + WLOG("Save state file not found: \(saveFileURL.path)") + continue + } + let saveFile = PVFile(withURL: saveFileURL) + + // 6. Create PVImageFile for the screenshot if it exists + var imageFile: PVImageFile? + if let imageURLString = saveStateMetadata.image?.url, + let imageURL = URL(string: imageURLString), + fileManager.fileExists(atPath: imageURL.path) { + imageFile = PVImageFile(withURL: imageURL) + } + + // 7. Create and save the new PVSaveState + try realm.write { + let newSaveState = PVSaveState( + withGame: game, + core: core, + file: saveFile, + date: Date(timeIntervalSince1970: saveStateMetadata.date) ?? Date(), + image: imageFile, + isAutosave: saveStateMetadata.isAutosave, + isPinned: saveStateMetadata.isPinned ?? false, + isFavorite: saveStateMetadata.isFavorite ?? false, + userDescription: saveStateMetadata.userDescription, + createdWithCoreVersion: saveStateMetadata.core.project.version + ) + + // Set additional properties from metadata + newSaveState.id = saveStateMetadata.id + newSaveState.date = Date(timeIntervalSinceReferenceDate: saveStateMetadata.date) + + realm.add(newSaveState) + ILOG("Recovered save state: \(newSaveState.id)") + } + + } catch { + ELOG("Failed to recover save state from \(jsonURL): \(error)") + } + } + } + /// Recover save states from the save state directory public func recoverSaveStates(forGame game: PVGame, core: EmulatorCoreIOInterface) throws { let saveStatePath: URL = PVEmulatorConfiguration.saveStatePath(forGame: game) @@ -172,3 +264,43 @@ public extension RomDatabase { ``` */ + +// MARK: - Save State Metadata Structs +private struct SaveStateMetadata: Codable { + let id: String + let isAutosave: Bool + let date: TimeInterval + let game: GameMetadata + let core: CoreMetadata + let file: FileMetadata + let image: ImageMetadata? + let isPinned: Bool? + let isFavorite: Bool? + let userDescription: String? +} + +private struct GameMetadata: Codable { + let md5: String + let systemIdentifier: String + let title: String +} + +private struct CoreMetadata: Codable { + let identifier: String + let project: ProjectMetadata +} + +private struct ProjectMetadata: Codable { + let version: String + let name: String +} + +private struct FileMetadata: Codable { + let md5: String + let fileName: String + let size: UInt64 +} + +private struct ImageMetadata: Codable { + let url: String +} diff --git a/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVSaveState.swift b/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVSaveState.swift index 2ea1144101..88daf66b1b 100644 --- a/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVSaveState.swift +++ b/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVSaveState.swift @@ -30,17 +30,18 @@ public final class PVSaveState: RealmSwift.Object, Identifiable, Filed, LocalFil @Persisted public var createdWithCoreVersion: String! - public convenience init(withGame game: PVGame, core: PVCore, file: PVFile, image: PVImageFile? = nil, isAutosave: Bool = false, isPinned: Bool = false, isFavorite: Bool = false, userDescription: String? = nil) { + public convenience init(withGame game: PVGame, core: PVCore, file: PVFile, date: Date = Date(), image: PVImageFile? = nil, isAutosave: Bool = false, isPinned: Bool = false, isFavorite: Bool = false, userDescription: String? = nil, createdWithCoreVersion: String? = nil) { self.init() self.game = game self.file = file self.image = image + self.date = date self.isAutosave = isAutosave self.isPinned = isPinned self.isFavorite = isFavorite self.userDescription = userDescription self.core = core - createdWithCoreVersion = core.projectVersion + self.createdWithCoreVersion = createdWithCoreVersion ?? core.projectVersion } public static func == (lhs: PVSaveState, rhs: PVSaveState) -> Bool { diff --git a/Provenance/Main UI/PVAppDelegate.swift b/Provenance/Main UI/PVAppDelegate.swift index d090e497e8..620c9d8626 100644 --- a/Provenance/Main UI/PVAppDelegate.swift +++ b/Provenance/Main UI/PVAppDelegate.swift @@ -113,7 +113,7 @@ final class PVAppDelegate: UIResponder, GameLaunchingAppDelegate, UIApplicationD await updates.importROMDirectories() } } -// RomDatabase.sharedInstance.recoverSaveStates() + RomDatabase.sharedInstance.recoverAllSaveStates() } promise(.success(())) } @@ -128,7 +128,6 @@ final class PVAppDelegate: UIResponder, GameLaunchingAppDelegate, UIApplicationD Task { @MainActor in do { try RomDatabase.sharedInstance.deleteAllGames() -// RomDatabase.sharedInstance.updateSaveStates() if let _ = self.gameLibraryViewController { self.gameLibraryViewController?.checkROMs(false) } else { @@ -136,6 +135,7 @@ final class PVAppDelegate: UIResponder, GameLaunchingAppDelegate, UIApplicationD await updates.importROMDirectories() } } + RomDatabase.sharedInstance.recoverAllSaveStates() promise(.success(())) } catch { ELOG("Failed to refresh all objects. \(error.localizedDescription)") From 7b27a22e45f121c0eaee60c6c0ecb4c73d49149b Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Sun, 1 Dec 2024 18:14:27 -0500 Subject: [PATCH 08/13] batch pvsavestate recovery database writes Signed-off-by: Joseph Mattiello --- .../Realm Database/RomDatabase+Saves.swift | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift index ad23dde100..8f2b6b6a0c 100644 --- a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift +++ b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift @@ -7,6 +7,7 @@ import PVCoreBridge import PVFileSystem +import PVLogging /// Save state purging and recoovery public extension RomDatabase { @@ -32,6 +33,9 @@ public extension RomDatabase { return } + // Collect save states to be added + var saveStatesToAdd: [PVSaveState] = [] + for jsonURL in jsonFiles { do { // 1. Read and decode the JSON file @@ -73,32 +77,43 @@ public extension RomDatabase { imageFile = PVImageFile(withURL: imageURL) } - // 7. Create and save the new PVSaveState - try realm.write { - let newSaveState = PVSaveState( - withGame: game, - core: core, - file: saveFile, - date: Date(timeIntervalSince1970: saveStateMetadata.date) ?? Date(), - image: imageFile, - isAutosave: saveStateMetadata.isAutosave, - isPinned: saveStateMetadata.isPinned ?? false, - isFavorite: saveStateMetadata.isFavorite ?? false, - userDescription: saveStateMetadata.userDescription, - createdWithCoreVersion: saveStateMetadata.core.project.version - ) + // 7. Create new PVSaveState + let newSaveState = PVSaveState( + withGame: game, + core: core, + file: saveFile, + date: Date(timeIntervalSinceReferenceDate: saveStateMetadata.date), + image: imageFile, + isAutosave: saveStateMetadata.isAutosave, + isPinned: false, + isFavorite: false, + userDescription: nil, + createdWithCoreVersion: saveStateMetadata.core.project.version + ) - // Set additional properties from metadata - newSaveState.id = saveStateMetadata.id - newSaveState.date = Date(timeIntervalSinceReferenceDate: saveStateMetadata.date) + // Set the original ID + newSaveState.id = saveStateMetadata.id - realm.add(newSaveState) - ILOG("Recovered save state: \(newSaveState.id)") - } + saveStatesToAdd.append(newSaveState) + ILOG("Prepared save state for recovery: \(newSaveState.id)") + + } catch { + ELOG("Failed to prepare save state from \(jsonURL): \(error)") + } + } + // Batch write all save states to Realm + if !saveStatesToAdd.isEmpty { + do { + try realm.write { + realm.add(saveStatesToAdd) + ILOG("Successfully recovered \(saveStatesToAdd.count) save states") + } } catch { - ELOG("Failed to recover save state from \(jsonURL): \(error)") + ELOG("Failed to batch write save states to Realm: \(error)") } + } else { + DLOG("No save states to recover in \(path)") } } @@ -274,9 +289,6 @@ private struct SaveStateMetadata: Codable { let core: CoreMetadata let file: FileMetadata let image: ImageMetadata? - let isPinned: Bool? - let isFavorite: Bool? - let userDescription: String? } private struct GameMetadata: Codable { From 0bb78b237424ab9da44dde8300a8af57d2b3d35d Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Sun, 1 Dec 2024 18:23:03 -0500 Subject: [PATCH 09/13] update todos Signed-off-by: Joseph Mattiello --- TODO.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TODO.md b/TODO.md index 4e0c702c39..4f4d07fab6 100644 --- a/TODO.md +++ b/TODO.md @@ -3,12 +3,15 @@ _My personal TODO notes_ ## Show stoppers +- [ ] Recover of games with bad paths +- [ ] Recover of save states not loading images - [ ] ugly retroarch ui in app, a bit unresponsive - [ ] Gearcoloco bottom buttons are wrong values - [ ] Pokemini audio fucked up - [ ] import queue, clicking an item should import it - [ ] Atari 2600 not using our controller for retroarch - [ ] Hud still looping +- [ ] save state manager is showing saves for all roms - [ ] importer doesn't auto start on import or selection of conflicts - [X] PCFX retroarch controls fucked, right is held down, actions don't do shit (using bultin controls) - [X] N64 retroarch don't load (no disabled) From 117fc9fbc4064b622b832d7b28afa0d82da73887 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Sun, 1 Dec 2024 18:27:54 -0500 Subject: [PATCH 10/13] savestate recover more metadata Signed-off-by: Joseph Mattiello --- .../Database/Realm Database/RomDatabase+Saves.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift index 8f2b6b6a0c..4176d5a0ae 100644 --- a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift +++ b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift @@ -85,9 +85,9 @@ public extension RomDatabase { date: Date(timeIntervalSinceReferenceDate: saveStateMetadata.date), image: imageFile, isAutosave: saveStateMetadata.isAutosave, - isPinned: false, - isFavorite: false, - userDescription: nil, + isPinned: saveStateMetadata.isPinned ?? false, + isFavorite: saveStateMetadata.isFavorite ?? false, + userDescription: saveStateMetadata.userDescription, createdWithCoreVersion: saveStateMetadata.core.project.version ) From 5282830df7f3a2c309f1815ac8199aee3f6eb948 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Mon, 2 Dec 2024 00:31:03 -0500 Subject: [PATCH 11/13] better save state serialization/deserialization, logging Signed-off-by: Joseph Mattiello --- PVLibrary/Sources/PVFileSystem/Paths.swift | 40 +++++++++++----- .../PVLibrary/Database/PVGameLibrary.swift | 24 +++++++++- .../Realm Database/RomDatabase+Saves.swift | 46 +++++++++++++++++-- .../Sources/PVPrimitives/SaveState.swift | 17 ++++++- 4 files changed, 107 insertions(+), 20 deletions(-) diff --git a/PVLibrary/Sources/PVFileSystem/Paths.swift b/PVLibrary/Sources/PVFileSystem/Paths.swift index e8cb2ecc06..7d730dd754 100644 --- a/PVLibrary/Sources/PVFileSystem/Paths.swift +++ b/PVLibrary/Sources/PVFileSystem/Paths.swift @@ -23,9 +23,8 @@ public extension URL { #if os(tvOS) return cachesPath #else - if USE_APP_GROUPS, - let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: PVAppGroupId) { - return groupURL.appendingPathComponent("Documents/") + if USE_APP_GROUPS { + return documentsPathAppGroup ?? documentsPathLocal } else { return documentsPathLocal } @@ -34,21 +33,38 @@ public extension URL { static let cachesPath: URL = { #if os(tvOS) - let paths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true) - return URL(fileURLWithPath: paths.first!, isDirectory: true) + return cachesPathLocal #else - - if USE_APP_GROUPS, let groupCaches = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: PVAppGroupId) { - return groupCaches.appending(component: "Caches/") - + if USE_APP_GROUPS { + return cachesPathAppGroup ?? cachesPathLocal } else { - let paths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true) - return URL(fileURLWithPath: paths.first!, isDirectory: true) - + return cachesPathLocal } #endif }() + static let cachesPathLocal: URL = { + let paths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true) + + return URL(fileURLWithPath: paths.first!, isDirectory: true) + }() + + static let cachesPathAppGroup: URL? = { + guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: PVAppGroupId) else { + return nil + } + + return groupURL.appendingPathComponent("Caches/") + }() + + static let documentsPathAppGroup: URL? = { + guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: PVAppGroupId) else { + return nil + } + + return groupURL.appendingPathComponent("Documents/") + }() + static let documentsPathLocal: URL = { let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true) return URL(fileURLWithPath: paths.first!, isDirectory: true) diff --git a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift index 76586052ee..fd17e788bf 100644 --- a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift +++ b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift @@ -182,15 +182,37 @@ public final class ROMLocationMigrator { ] } } + + public func printFolderContents() async { + for (oldPath, newPath) in oldPaths { + try? await printFolderContents(oldPath) + try? await printFolderContents(newPath) + } + } + + /// Debug print contents of folder and it's subfolders + public func printFolderContents(_ folder: URL) async throws { + let contents = try await fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil, options: []) + let paths = contents.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }).sorted(by: { $0.path < $1.path }).map { $0.relativePath } + DLOG("Contents of path: \(folder.standardizedFileURL): \(paths.joined(separator: ", "))") + } /// Migrates files from old location to new location if necessary public func migrateIfNeeded() async throws { ILOG("Checking if file migration is needed...") + #if DEBUG + await try? printFolderContents() + #endif for (oldPath, newPath) in oldPaths { if fileManager.fileExists(atPath: oldPath.path) { ILOG("Found old directory to migrate: \(oldPath.lastPathComponent)") - + // Contents of old directory + #if DEBUG + let contents = try await fileManager.contentsOfDirectory(at: oldPath, includingPropertiesForKeys: nil, options: []) + let paths = contents.map { $0.relativePath }.joined(separator: ", ") + DLOG("PATHS: \(paths)") + #endif if !fileManager.fileExists(atPath: newPath.path) { try fileManager.createDirectory(at: newPath, withIntermediateDirectories: true, diff --git a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift index 4176d5a0ae..cc779f186d 100644 --- a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift +++ b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift @@ -46,6 +46,25 @@ public extension RomDatabase { // 2. Check if this save state already exists in the database if let existingSave = realm.object(ofType: PVSaveState.self, forPrimaryKey: saveStateMetadata.id) { DLOG("Save state already exists: \(existingSave.id)") + + // Check if the existing save state's image needs to be updated + if let existingImage = existingSave.image, + !fileManager.fileExists(atPath: existingImage.url.path) { + + // Look for the image in the same directory as the save state + let localImageURL = jsonURL.deletingPathExtension().deletingPathExtension() + .appendingPathExtension("jpg") + + if fileManager.fileExists(atPath: localImageURL.path) { + DLOG("Found image file at alternate location for existing save: \(localImageURL.path)") + try? realm.write { + existingSave.image = PVImageFile(withURL: localImageURL) + ILOG("Updated image path for existing save state: \(existingSave.id)") + } + } else { + WLOG("No valid image found for existing save state: \(existingSave.id)") + } + } continue } @@ -72,9 +91,23 @@ public extension RomDatabase { // 6. Create PVImageFile for the screenshot if it exists var imageFile: PVImageFile? if let imageURLString = saveStateMetadata.image?.url, - let imageURL = URL(string: imageURLString), - fileManager.fileExists(atPath: imageURL.path) { - imageFile = PVImageFile(withURL: imageURL) + let originalImageURL = URL(string: imageURLString) { + + // First try the original path from JSON + if fileManager.fileExists(atPath: originalImageURL.path) { + imageFile = PVImageFile(withURL: originalImageURL) + } else { + // If original path fails, look for the image in the same directory as the save state + let localImageURL = jsonURL.deletingPathExtension().deletingPathExtension() + .appendingPathExtension("jpg") + + if fileManager.fileExists(atPath: localImageURL.path) { + DLOG("Found image file at alternate location: \(localImageURL.path)") + imageFile = PVImageFile(withURL: localImageURL) + } else { + WLOG("Image file not found at original path (\(originalImageURL.path)) or local path (\(localImageURL.path))") + } + } } // 7. Create new PVSaveState @@ -84,7 +117,7 @@ public extension RomDatabase { file: saveFile, date: Date(timeIntervalSinceReferenceDate: saveStateMetadata.date), image: imageFile, - isAutosave: saveStateMetadata.isAutosave, + isAutosave: saveStateMetadata.isAutosave ?? false, isPinned: saveStateMetadata.isPinned ?? false, isFavorite: saveStateMetadata.isFavorite ?? false, userDescription: saveStateMetadata.userDescription, @@ -283,12 +316,15 @@ public extension RomDatabase { // MARK: - Save State Metadata Structs private struct SaveStateMetadata: Codable { let id: String - let isAutosave: Bool + let isAutosave: Bool? + let isPinned: Bool? + let isFavorite: Bool? let date: TimeInterval let game: GameMetadata let core: CoreMetadata let file: FileMetadata let image: ImageMetadata? + let userDescription: String? } private struct GameMetadata: Codable { diff --git a/PVPrimitives/Sources/PVPrimitives/SaveState.swift b/PVPrimitives/Sources/PVPrimitives/SaveState.swift index 41d29f7653..9e8053e088 100644 --- a/PVPrimitives/Sources/PVPrimitives/SaveState.swift +++ b/PVPrimitives/Sources/PVPrimitives/SaveState.swift @@ -17,6 +17,9 @@ public protocol SaveStateInfoProvider { var lastOpened: Date? { get } var image: LocalFile? { get } var isAutosave: Bool { get } + var isPinned: Bool { get } + var isFavorite: Bool { get } + var userDescription: String? { get } } public struct SaveState: SaveStateInfoProvider, Codable, Sendable, Identifiable { @@ -28,8 +31,11 @@ public struct SaveState: SaveStateInfoProvider, Codable, Sendable, Identifiable public let lastOpened: Date? public let image: LocalFile? public let isAutosave: Bool - - public init(id: String, game: Game, core: Core, file: FileInfo, date: Date, lastOpened: Date?, image: LocalFile?, isAutosave: Bool) { + public let isPinned: Bool + public let isFavorite: Bool + public let userDescription: String? + + public init(id: String, game: Game, core: Core, file: FileInfo, date: Date, lastOpened: Date?, image: LocalFile?, isAutosave: Bool, isPinned: Bool = false, isFavorite: Bool = false, userDescription: String? = nil) { self.id = id self.game = game self.core = core @@ -38,6 +44,9 @@ public struct SaveState: SaveStateInfoProvider, Codable, Sendable, Identifiable self.lastOpened = lastOpened self.image = image self.isAutosave = isAutosave + self.isPinned = isPinned + self.isFavorite = isFavorite + self.userDescription = userDescription } public init(from decoder: any Decoder) throws { @@ -50,6 +59,10 @@ public struct SaveState: SaveStateInfoProvider, Codable, Sendable, Identifiable self.lastOpened = try container.decodeIfPresent(Date.self, forKey: .lastOpened) self.image = try container.decodeIfPresent(LocalFile.self, forKey: .image) self.isAutosave = try container.decode(Bool.self, forKey: .isAutosave) + self.isPinned = try container.decode(Bool.self, forKey: .isPinned) + self.isFavorite = try container.decode(Bool.self, forKey: .isFavorite) + self.userDescription = try container.decodeIfPresent(String.self, forKey: .userDescription) + } } From 3933c97ce0598a8d773a70dc9efbf916af827070 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Mon, 2 Dec 2024 00:31:20 -0500 Subject: [PATCH 12/13] retroarch.cfg video_font_enable = "false" Signed-off-by: Joseph Mattiello --- CoresRetro/RetroArch/Resources/retroarch.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CoresRetro/RetroArch/Resources/retroarch.cfg b/CoresRetro/RetroArch/Resources/retroarch.cfg index fbf87167e2..f765b29808 100644 --- a/CoresRetro/RetroArch/Resources/retroarch.cfg +++ b/CoresRetro/RetroArch/Resources/retroarch.cfg @@ -3117,7 +3117,7 @@ video_disable_composition = "false" video_driver = "metal" video_filter = "" video_filter_dir = "default" -video_font_enable = "true" +video_font_enable = "false" video_font_path = "" video_font_size = "32.000000" video_force_aspect = "true" From 9b75174a63a1a257523768b190d4045101649fee Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Mon, 2 Dec 2024 02:02:16 -0500 Subject: [PATCH 13/13] save state images reimport working Signed-off-by: Joseph Mattiello --- .../Realm Database/RomDatabase+Saves.swift | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift index cc779f186d..08ca6c17eb 100644 --- a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift +++ b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase+Saves.swift @@ -32,6 +32,18 @@ public extension RomDatabase { ELOG("Failed to read directory contents at \(path)") return } + +#if DEBUG +// // For testing, remove all PVSaveState imges +// let allSaves = RomDatabase.sharedInstance.all(PVSaveState.self) +// if !allSaves.isEmpty { +// for save in allSaves { +// try? realm.write { +// save.image = nil +// } +// } +// } +#endif // Collect save states to be added var saveStatesToAdd: [PVSaveState] = [] @@ -48,18 +60,19 @@ public extension RomDatabase { DLOG("Save state already exists: \(existingSave.id)") // Check if the existing save state's image needs to be updated - if let existingImage = existingSave.image, - !fileManager.fileExists(atPath: existingImage.url.path) { + if existingSave.image == nil || !fileManager.fileExists(atPath: existingSave.image!.url.path) { // Look for the image in the same directory as the save state let localImageURL = jsonURL.deletingPathExtension().deletingPathExtension() .appendingPathExtension("jpg") if fileManager.fileExists(atPath: localImageURL.path) { + let imgFile = PVImageFile(withURL: localImageURL.standardizedFileURL) + DLOG("Found image file at alternate location for existing save: \(localImageURL.path)") try? realm.write { - existingSave.image = PVImageFile(withURL: localImageURL) - ILOG("Updated image path for existing save state: \(existingSave.id)") + existingSave.image = imgFile + ILOG("Updated image path for existing save state: \(existingSave.id) to \(imgFile.url.path(percentEncoded: false))") } } else { WLOG("No valid image found for existing save state: \(existingSave.id)")