diff --git a/PVLibrary/Sources/PVLibrary/Database/Drivers/RealmDatabaseDriver.swift b/PVLibrary/Sources/PVLibrary/Database/Drivers/RealmDatabaseDriver.swift index 9cc23d4122..953942b5d8 100644 --- a/PVLibrary/Sources/PVLibrary/Database/Drivers/RealmDatabaseDriver.swift +++ b/PVLibrary/Sources/PVLibrary/Database/Drivers/RealmDatabaseDriver.swift @@ -23,10 +23,10 @@ public final class RealmDatabaseDriver: DatabaseDriver, GameLibraryRealm, GameLi public typealias SystemType = PVSystem public typealias Savetype = PVSaveState public typealias RecentGame = PVRecentGame - + /// private let database: RomDatabase - + /// public lazy var saveStates: SavesObservable = { Observable @@ -47,7 +47,7 @@ public final class RealmDatabaseDriver: DatabaseDriver, GameLibraryRealm, GameLi .collection(from: recentsResults) .mapMany { $0 } }() - + /// public lazy var mostPlayed: GamesObservable = { Observable @@ -65,34 +65,40 @@ public final class RealmDatabaseDriver: DatabaseDriver, GameLibraryRealm, GameLi .sorted(byKeyPath: #keyPath(Savetype.date), ascending: false) }() - + /// public lazy var favoritesResults: Results = { - database.all(GameType.self, where: #keyPath(GameType.isFavorite), value: true).sorted(byKeyPath: #keyPath(GameType.title), ascending: false) + database.all(GameType.self) + .filter("isFavorite == true AND isHiddenPatchVariant == false") + .sorted(byKeyPath: #keyPath(GameType.title), ascending: false) }() - + /// public lazy var recentsResults: Results = { - database.all(RecentGame.self).sorted(byKeyPath: #keyPath(RecentGame.lastPlayedDate), ascending: false) + database.all(RecentGame.self) + .filter("game == nil OR game.isHiddenPatchVariant == false") + .sorted(byKeyPath: #keyPath(RecentGame.lastPlayedDate), ascending: false) }() - + /// public lazy var mostPlayedResults: Results = { - database.all(GameType.self).sorted(byKeyPath: #keyPath(GameType.playCount), ascending: false) + database.all(GameType.self) + .filter("isHiddenPatchVariant == false") + .sorted(byKeyPath: #keyPath(GameType.playCount), ascending: false) }() - + /// public lazy var activeSystems: Results = { database.all(SystemType.self, filter: NSPredicate(format: "games.@count > 0")).sorted(byKeyPath: #keyPath(SystemType.name), ascending: true) }() - + /// Init a new RealmDatabaseDriver /// - Parameter database: The RomDatabase to query on. required public init(database: RomDatabase) { self.database = database } - + // MARK: - Query by Identifier /// Game for identifier @@ -101,7 +107,7 @@ public final class RealmDatabaseDriver: DatabaseDriver, GameLibraryRealm, GameLi public func game(identifier: String) -> GameType? { database.object(ofType: GameType.self, wherePrimaryKeyEquals: identifier) } - + /// GameType .isFavorite toggle /// - Returns: Completable for when finished public func toggleFavorite(for game: GameType) -> Completable { @@ -122,34 +128,39 @@ public final class RealmDatabaseDriver: DatabaseDriver, GameLibraryRealm, GameLi /// System Query methods /// extension RealmDatabaseDriver { - + /// System for identifier /// - Parameter identifier: ID String of the system /// - Returns: Optional matching SystemType public func system(identifier: String) -> SystemType? { database.object(ofType: SystemType.self, wherePrimaryKeyEquals: identifier) } - + /// Get all games for a System /// - Parameter systemIdentifier: ID String of the system to query /// - Returns: A query result with live updating as long as the reference is active public func gamesForSystem(systemIdentifier: String) -> Results { - database.all(GameType.self).filter(NSPredicate(format: "systemIdentifier == %@", argumentArray: [systemIdentifier])) + database.all(GameType.self).filter( + NSPredicate( + format: "systemIdentifier == %@ AND isHiddenPatchVariant == false", + argumentArray: [systemIdentifier] + ) + ) } /// Systems sorted by Options -> RxRealm.Observable - + public typealias SystemStruct = PVGameLibrary.System public typealias SystemObservable = Observable public typealias SystemArrayObservable = Observable<[SystemStruct]> - + /// Get all systems sorted by... /// - Parameter sortOptions: Sory options, .title, .importDate, .lastPlayed, .mostPlayed /// - Returns: An Observable of systems by the sort options that updates as ROMs are played or imported public func systems(sortedBy sortOptions: PVSettings.SortOptions) -> Observable<[PVGameLibrary.System]> { let betaIDs: [SystemIdentifier] = SystemIdentifier.betas let unsuppotedIDs: [SystemIdentifier] = SystemIdentifier.unsupported - + return Observable .collection(from: database.all(SystemType.self)) .flatMapLatest({ systems -> SystemArrayObservable in @@ -181,7 +192,7 @@ extension RealmDatabaseDriver { }) .map { systems in systems.sorted(by: sortOptions) } } - + } /// Searching methods @@ -201,21 +212,24 @@ extension RealmDatabaseDriver { /// - Parameter searchText: Text to search for /// - Returns: Live updating results of GameTypes public func searchResults(for searchText: String) -> Results { + let visible = NSPredicate(format: "isHiddenPatchVariant == false") // Search first by title, and a broader search if that one's empty if searchText.count == 0 { - return self.database.all(GameType.self).sorted(byKeyPath: #keyPath(GameType.title), ascending: true) + return self.database.all(GameType.self) + .filter(visible) + .sorted(byKeyPath: #keyPath(GameType.title), ascending: true) } else { - + /// Search by title contains let titlePredicate = NSPredicate( - format: "title CONTAINS[c] %@", + format: "isHiddenPatchVariant == false AND title CONTAINS[c] %@", argumentArray: [searchText]) /// Search LIKE in genre, region, description, developer or publisher let searchPredicate = NSPredicate( - format: "genres LIKE[c] %@ OR gameDescription CONTAINS[c] %@ OR regionName LIKE[c] %@ OR developer LIKE[c] %@ or publisher LIKE[c] %@", + format: "isHiddenPatchVariant == false AND (genres LIKE[c] %@ OR gameDescription CONTAINS[c] %@ OR regionName LIKE[c] %@ OR developer LIKE[c] %@ or publisher LIKE[c] %@)", argumentArray: [searchText, searchText, searchText, searchText, searchText]) - + let titleResults = self.database.all(GameType.self, filter: titlePredicate) let generalresults = { return self.database.all(GameType.self, filter: searchPredicate) } let results = !titleResults.isEmpty ? titleResults : generalresults() @@ -227,7 +241,7 @@ extension RealmDatabaseDriver { /// Clearing methods /// extension RealmDatabaseDriver { - + /// Clear the library /// - Returns: Completable for when finished public func clearLibrary() -> Completable { @@ -257,7 +271,7 @@ extension RealmDatabaseDriver { return Disposables.create() } } - + /// Refrehes the library /// - Returns: Completable for when finished public func refreshLibrary() -> Completable { diff --git a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift index 40532abf5c..80c645f899 100644 --- a/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift +++ b/PVLibrary/Sources/PVLibrary/Database/PVGameLibrary.swift @@ -72,7 +72,8 @@ extension RealmSwift.LinkingObjects where Element: PVGame { } sortDescriptors.append(SortDescriptor(keyPath: #keyPath(PVGame.title), ascending: true)) - return sorted(by: sortDescriptors) + // Hidden patch variants stay out of normal library lists until "Add as separate tile". + return filter("isHiddenPatchVariant == false").sorted(by: sortDescriptors) } } diff --git a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase.swift b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase.swift index ca83ba5780..389995252d 100644 --- a/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase.swift +++ b/PVLibrary/Sources/PVLibrary/Database/Realm Database/RomDatabase.swift @@ -20,7 +20,7 @@ import AsyncAlgorithms import PVSystems import PVMediaCache -public let schemaVersion: UInt64 = 28 +public let schemaVersion: UInt64 = 29 public enum RomDeletionError: Error { case relatedFiledDeletionError @@ -298,6 +298,35 @@ public final class RealmConfiguration { // PVControllerProfile.lightBarColorHex — optional String; Realm defaults new optional fields to nil. ILOG("Migration to version 28 complete. (Added PVControllerProfile.lightBarColorHex)") } + if oldSchemaVersion < 29 { + // Patch variant support: PVGame patch-variant fields + extended PVPatch metadata. + migration.enumerateObjects(ofType: PVGame.className()) { _, newObject in + newObject?["isPatchVariant"] = false + newObject?["baseGameMD5"] = nil + newObject?["patchID"] = nil + newObject?["isHiddenPatchVariant"] = false + } + migration.enumerateObjects(ofType: PVPatch.className()) { oldObject, newObject in + // Preserve existing game links — never orphan linked patches as pending. + if let linkedGame = oldObject?["game"] as? MigrationObject, + let md5 = linkedGame["md5Hash"] as? String, !md5.isEmpty { + newObject?["baseGameMD5"] = md5 + newObject?["matchState"] = "manual" + newObject?["matchConfidenceRaw"] = "exact" + newObject?["matchScore"] = 1.0 + } else { + newObject?["baseGameMD5"] = nil + newObject?["matchState"] = "pending" + newObject?["matchConfidenceRaw"] = "none" + newObject?["matchScore"] = 0.0 + } + newObject?["hasSourceCRC32"] = false + newObject?["sourceCRC32"] = 0 + newObject?["showAsLibraryTile"] = false + newObject?["isDefaultLaunch"] = (oldObject?["isEnabled"] as? Bool) ?? false + } + ILOG("Migration to version 29 complete. (Patch variants + PVPatch matching metadata)") + } } #if DEBUG @@ -1082,6 +1111,10 @@ public extension RomDatabase { // --- Cloud Sync Pre-Deletion Hook --- // Capture MD5 *before* attempting any local deletion. let md5 = game.md5Hash + let isPatchVariant = game.isPatchVariant + let patchID = game.patchID + // Prefer the real on-disk file for variants (PatchedROMs/…); never the ROMs/ path. + let variantFileURL = isPatchVariant ? game.file?.url : nil // Only post notification if the deletion was initiated by the user AND md5 is valid if source == .userInitiated { @@ -1097,7 +1130,6 @@ public extension RomDatabase { } // --- End Cloud Sync Hook --- - let romURL = PVEmulatorConfiguration.path(forGame: game) if deleteArtwork, !game.customArtworkURL.isEmpty { do { try PVMediaCache.deleteImage(forKey: game.customArtworkURL) @@ -1125,6 +1157,40 @@ public extension RomDatabase { } } } + + // Patch variants: never call path(forGame:) — that points at ROMs// + // and can erase the original ROM when stems collide. Delete the patched artifact only. + if isPatchVariant { +#if os(iOS) + deleteFromSpotlight(game: game) +#endif + defer { RomDatabase.reloadGamesCache() } + deletePatchRecords(linkedToVariantMD5: md5, patchID: patchID) + // Orphan cleanup: if no PVPatch owned the row, still remove the PVGame. + if let orphan = realm.object(ofType: PVGame.self, forPrimaryKey: md5), !orphan.isInvalidated { + do { + try writeTransaction { + orphan.saveStates.forEach { realm.delete($0) } + orphan.cheats.forEach { realm.delete($0) } + orphan.recentPlays.forEach { realm.delete($0) } + orphan.screenShots.forEach { realm.delete($0) } + realm.delete(orphan) + } + } catch { + WLOG("Failed to delete orphan patch variant \(md5): \(error.localizedDescription)") + } + } + if let variantFileURL, FileManager.default.fileExists(atPath: variantFileURL.path) { + // Only remove files under PatchedROMs / cache — never ROMs/. + let path = variantFileURL.path + if path.contains("/PatchedROMs/") || path.contains("/PVPatchedROMs/") { + try? FileManager.default.removeItem(at: variantFileURL) + } + } + return + } + + let romURL = PVEmulatorConfiguration.path(forGame: game) if let romURL = romURL, FileManager.default.fileExists(atPath: romURL.path) { do { try FileManager.default.removeItem(at: romURL) @@ -1143,6 +1209,8 @@ public extension RomDatabase { RomDatabase.reloadGamesCache() } do { + // Deleting a base game removes its managed variants/patches (never other ROMs). + deletePatchRecords(ownedByBaseGameMD5: md5) deleteRelatedFilesGame(game) game.saveStates.forEach { try? $0.delete() } game.cheats.forEach { try? $0.delete() } @@ -1158,6 +1226,56 @@ public extension RomDatabase { } } + /// Delete patch Realm records + variant games for a base ROM. Never deletes the base ROM file. + private func deletePatchRecords(ownedByBaseGameMD5 md5: String) { + let patches = Array(realm.objects(PVPatch.self).filter("baseGameMD5 == %@", md5)) + for patch in patches { + deleteSinglePatchRecord(patch) + } + } + + private func deletePatchRecords(linkedToVariantMD5 md5: String, patchID: String?) { + if let patchID, let patch = realm.object(ofType: PVPatch.self, forPrimaryKey: patchID) { + deleteSinglePatchRecord(patch) + return + } + if let patch = realm.objects(PVPatch.self).filter("variantGameMD5 == %@", md5).first { + deleteSinglePatchRecord(patch) + } + } + + private func deleteSinglePatchRecord(_ patch: PVPatch) { + let patchFileURL = patch.file?.url + let variant = patch.variantGame + // Capture before Realm delete — only remove managed variant artifacts, never base ROMs. + let shouldDeleteVariantGame = PatchRepository.shouldDeleteVariantGame(isPatchVariant: variant?.isPatchVariant) + let variantFileURL = shouldDeleteVariantGame ? variant?.file?.url : nil + do { + try writeTransaction { + if let variant, !variant.isInvalidated, shouldDeleteVariantGame { + variant.saveStates.forEach { realm.delete($0) } + variant.cheats.forEach { realm.delete($0) } + variant.recentPlays.forEach { realm.delete($0) } + realm.delete(variant) + } + if !patch.isInvalidated { + realm.delete(patch) + } + } + } catch { + WLOG("Failed to delete patch record \(patch.id): \(error.localizedDescription)") + } + if let patchFileURL, FileManager.default.fileExists(atPath: patchFileURL.path) { + try? FileManager.default.removeItem(at: patchFileURL) + } + if let variantFileURL, FileManager.default.fileExists(atPath: variantFileURL.path) { + let path = variantFileURL.path + if path.contains("/PatchedROMs/") || path.contains("/PVPatchedROMs/") { + try? FileManager.default.removeItem(at: variantFileURL) + } + } + } + // Deletes a save state and its associated files /// Deletes a save state and its associated files func delete(saveState: PVSaveState) throws { diff --git a/PVLibrary/Sources/PVLibrary/Importer/Services/GameImporter/GameImporter.swift b/PVLibrary/Sources/PVLibrary/Importer/Services/GameImporter/GameImporter.swift index 625545914b..3354c1d3ab 100644 --- a/PVLibrary/Sources/PVLibrary/Importer/Services/GameImporter/GameImporter.swift +++ b/PVLibrary/Sources/PVLibrary/Importer/Services/GameImporter/GameImporter.swift @@ -3170,34 +3170,17 @@ public final class GameImporter: GameImporting, ObservableObject { ILOG("Deleted original archive after extraction: \(archiveURL.lastPathComponent)") } - /// Imports a ROM patch file by creating a `PVPatch` Realm record for it. + /// Imports a ROM patch file via `PatchRepository` (match, variant, idempotent re-import). /// The file must already have been moved to its destination before calling this method. - /// Full patch application is handled by PatchImporter (TODO #2676). private func importPatchFile(_ item: ImportQueueItem) async throws { - // Use the destination URL set by moveImportItem, falling back to the original URL. let url = item.destinationUrl ?? item.url - guard let format = PatchFormat.detect(from: url) else { + guard PatchFormat.detect(from: url) != nil else { ELOG("Cannot determine patch format for \(url.lastPathComponent)") throw GameImporterError.unsupportedFile } - ILOG("Importing patch file \(url.lastPathComponent) as format: \(format.rawValue)") - - // Realm objects must be created and written on the same thread. - // Run on MainActor to use the main-thread Realm instance safely. - let title = url.deletingPathExtension().lastPathComponent - try await MainActor.run { - let pvFile = PVFile(withURL: url) - let patch = PVPatch( - file: pvFile, - game: nil, - date: Date(), - format: format, - title: title - ) - let database = RomDatabase.sharedInstance - try database.add(patch, update: true) - } - ILOG("Saved PVPatch record for \(url.lastPathComponent)") + ILOG("Importing patch file \(url.lastPathComponent)") + let patch = try await PatchRepository.shared.importPatch(at: url, preferredGame: nil, autoMatch: true) + ILOG("Imported patch \(patch.displayTitle) (matchState=\(patch.matchState.rawValue))") } private func performImport(for item: ImportQueueItem) async throws { @@ -3378,8 +3361,7 @@ public final class GameImporter: GameImporting, ObservableObject { return } - // Handle patch files — move to Patches dir, then store in Realm. - // Full patch application is deferred to PatchImporter (TODO #2676). + // Handle patch files — move to Patches dir, then import/match via PatchRepository. if item.fileType == .patch { ILOG("Processing as ROM patch file: \(fileName)") do { @@ -3530,6 +3512,8 @@ public final class GameImporter: GameImporting, ObservableObject { try await gameImporterDatabaseService.importGameIntoDatabase(queueItem: item) let dbDuration = Date().timeIntervalSince(dbStartTime) ILOG("Successfully imported game \(fileName) into database in \(String(format: "%.2f", dbDuration))s") + // A newly imported ROM may unlock previously unmatched patches. + await PatchRepository.shared.rematchPendingPatches() } } catch { ELOG("Failed to import \(item.url.lastPathComponent) into database: \(error.localizedDescription)") diff --git a/PVLibrary/Sources/PVLibrary/Patches/PatchRepository.swift b/PVLibrary/Sources/PVLibrary/Patches/PatchRepository.swift new file mode 100644 index 0000000000..82886927ef --- /dev/null +++ b/PVLibrary/Sources/PVLibrary/Patches/PatchRepository.swift @@ -0,0 +1,558 @@ +// +// PatchRepository.swift +// PVLibrary +// +// Created by Provenance Emu on 2026-10-10. +// Copyright © 2026 Provenance Emu. All rights reserved. +// + +import Foundation +import RealmSwift +import PVLogging +import PVPatching +import PVRealm +import PVFileSystem +import PVPrimitives + +/// Owns patch import, matching, variant lifecycle, and launch preparation. +@MainActor +public final class PatchRepository { + public static let shared = PatchRepository() + + private let inspector = PatchInspector() + private let applier = PatchApplier() + + private init() {} + + // MARK: - Queries + + public func patches(forGameMD5 md5: String) -> Results { + RomDatabase.sharedInstance.realm.objects(PVPatch.self) + .filter("baseGameMD5 == %@ OR game.md5Hash == %@", md5, md5) + .sorted(byKeyPath: "date", ascending: false) + } + + public func pendingPatches() -> Results { + RomDatabase.sharedInstance.realm.objects(PVPatch.self) + .filter("matchState == %@", PVPatchMatchState.pending.rawValue) + .sorted(byKeyPath: "date", ascending: false) + } + + public func visibleLibraryGames(excludingHiddenVariants from: Results) -> Results { + from.filter("isHiddenPatchVariant == false") + } + + public func defaultPatch(forGameMD5 md5: String) -> PVPatch? { + patches(forGameMD5: md5).first(where: { $0.isDefaultLaunch && !$0.isInvalidated }) + } + + // MARK: - Import + + /// Import a patch file already moved to `Paths.patchesPath`. + @discardableResult + public func importPatch( + at url: URL, + preferredGame: PVGame? = nil, + autoMatch: Bool = true + ) async throws -> PVPatch { + guard let format = PatchFormat.detect(from: url) else { + throw PatchError.unsupportedFormat(url.pathExtension) + } + + let inspection = try inspector.inspect(patchURL: url) + let database = RomDatabase.sharedInstance + let realm = database.realm + + let pvFile = PVFile(withURL: url) + let existingID = !pvFile.partialPath.isEmpty ? pvFile.partialPath : url.path + let patch: PVPatch + if let existing = realm.object(ofType: PVPatch.self, forPrimaryKey: existingID) { + patch = existing + } else { + patch = PVPatch( + file: pvFile, + game: preferredGame, + format: format, + title: inspection.title ?? url.deletingPathExtension().lastPathComponent, + author: inspection.author, + version: inspection.version, + patchDescription: inspection.patchDescription, + matchState: preferredGame == nil ? .pending : .manual + ) + try database.add(patch, update: true) + } + + try database.writeTransaction { + guard let live = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id) else { return } + live.formatRawValue = format.rawValue + live.title = inspection.title ?? live.title + live.author = inspection.author ?? live.author + live.version = inspection.version ?? live.version + live.patchDescription = inspection.patchDescription ?? live.patchDescription + live.patchSHA256 = inspection.patchSHA256 + if let crc = inspection.sourceCRC32 { + live.hasSourceCRC32 = true + live.sourceCRC32 = Int(Int32(bitPattern: crc)) + } + if live.outputStem == nil || live.outputStem?.isEmpty == true { + live.outputStem = PatchApplier.defaultOutputStem( + romURL: preferredGame?.file?.url ?? url, + patchURL: url + ) + } + if let preferredGame { + live.game = preferredGame.thaw() ?? preferredGame + live.baseGameMD5 = preferredGame.md5Hash + live.matchState = .manual + live.matchConfidenceRaw = PatchMatchConfidence.exact.rawValue + live.matchScore = 1 + } + } + + if preferredGame == nil, autoMatch { + _ = try await matchPatch(patch) + } else if let preferredGame { + _ = try await ensureVariant(for: patch, baseGame: preferredGame) + } + + return realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id) ?? patch + } + + /// Attempt to auto-match a pending patch to a library ROM. + @discardableResult + public func matchPatch(_ patch: PVPatch) async throws -> PVGame? { + let realm = RomDatabase.sharedInstance.realm + guard let livePatch = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id), + let patchURL = livePatch.file?.url else { + return nil + } + + let patchID = livePatch.id + let needsSourceCRC = livePatch.hasSourceCRC32 + let patchFilename = patchURL.lastPathComponent + + // Snapshot lightweight game metadata on the main actor, then do any + // disk CRC work off-main so large libraries can't stall the UI. + struct GameSnapshot: Sendable { + let md5: String + let title: String + let filename: String + let storedCRC: String + let filePath: String? + } + let snapshots: [GameSnapshot] = realm.objects(PVGame.self) + .filter("isPatchVariant == false AND contentless == false") + .compactMap { game in + guard let filename = game.file?.fileName else { return nil } + return GameSnapshot( + md5: game.md5Hash, + title: game.title, + filename: filename, + storedCRC: game.crc, + filePath: game.file?.url?.path + ) + } + + let inspection = try await Task.detached(priority: .userInitiated) { + try PatchInspector().inspect(patchURL: patchURL) + }.value + + let candidates: [PatchMatchCandidate] = await Task.detached(priority: .userInitiated) { + snapshots.map { snap in + var crc: UInt32? + if !snap.storedCRC.isEmpty, let value = UInt32(snap.storedCRC, radix: 16) { + crc = value + } else if needsSourceCRC, let path = snap.filePath, + let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) { + crc = patchCRC32(data) + } + return PatchMatchCandidate( + id: snap.md5, + title: snap.title, + md5: snap.md5, + crc32: crc, + filename: snap.filename, + confidence: .none, + score: 0 + ) + } + }.value + + let ranked = inspector.rank( + candidates: candidates, + inspection: inspection, + patchFilename: patchFilename + ) + guard let best = inspector.unambiguousMatch(from: ranked), + let game = realm.object(ofType: PVGame.self, forPrimaryKey: best.md5) else { + try RomDatabase.sharedInstance.writeTransaction { + guard let p = realm.object(ofType: PVPatch.self, forPrimaryKey: patchID) else { return } + p.matchState = .pending + if let top = ranked.first { + p.matchConfidenceRaw = top.confidence.rawValue + p.matchScore = top.score + } + } + return nil + } + + try RomDatabase.sharedInstance.writeTransaction { + guard let p = realm.object(ofType: PVPatch.self, forPrimaryKey: patchID) else { return } + p.game = game + p.baseGameMD5 = game.md5Hash + p.matchState = .matched + p.matchConfidenceRaw = best.confidence.rawValue + p.matchScore = best.score + p.title = p.title ?? inspection.title + p.author = p.author ?? inspection.author + p.version = p.version ?? inspection.version + p.patchDescription = p.patchDescription ?? inspection.patchDescription + } + + guard let refreshed = realm.object(ofType: PVPatch.self, forPrimaryKey: patchID) else { return game } + _ = try await ensureVariant(for: refreshed, baseGame: game) + return game + } + + /// Rematch every pending patch — call after a new ROM import. + public func rematchPendingPatches() async { + let pending = Array(pendingPatches().freeze()) + for patch in pending { + do { + _ = try await matchPatch(patch) + } catch { + WLOG("Failed to rematch patch \(patch.id): \(error.localizedDescription)") + } + } + } + + /// Manually link a patch to a base game. + public func link(_ patch: PVPatch, to game: PVGame) async throws { + let realm = RomDatabase.sharedInstance.realm + try RomDatabase.sharedInstance.writeTransaction { + guard let p = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id), + let g = game.thaw() ?? realm.object(ofType: PVGame.self, forPrimaryKey: game.md5Hash) else { return } + p.game = g + p.baseGameMD5 = g.md5Hash + p.matchState = .manual + p.matchConfidenceRaw = PatchMatchConfidence.exact.rawValue + p.matchScore = 1 + } + _ = try await ensureVariant(for: patch, baseGame: game) + } + + // MARK: - Variant lifecycle + + /// Ensure a hidden/visible variant `PVGame` exists for the patch. + @discardableResult + public func ensureVariant(for patch: PVPatch, baseGame: PVGame) async throws -> PVGame { + let realm = RomDatabase.sharedInstance.realm + if let existingMD5 = patch.variantGameMD5, + let existing = realm.object(ofType: PVGame.self, forPrimaryKey: existingMD5), + let existingURL = existing.file?.url, + FileManager.default.fileExists(atPath: existingURL.path) { + // Reuse only when the on-disk artifact (or standalone ROM) is still present. + return existing + } + + guard let patchURL = patch.file?.url, let romURL = baseGame.file?.url else { + throw PatchError.sourceROMNotFound(baseGame.file?.url ?? URL(fileURLWithPath: "/")) + } + + let stem = patch.outputStem ?? PatchApplier.defaultOutputStem(romURL: romURL, patchURL: patchURL) + let artifact = try await applier.apply(patchURL: patchURL, to: romURL, outputStem: stem) + let variantMD5 = artifact.patchedMD5 + let patchedCRC: String = { + if let data = try? Data(contentsOf: artifact.url, options: .mappedIfSafe) { + return String(format: "%08X", patchCRC32(data)) + } + return "" + }() + + var result: PVGame? + try RomDatabase.sharedInstance.writeTransaction { + let livePatch = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id) + let liveBase = baseGame.thaw() ?? realm.object(ofType: PVGame.self, forPrimaryKey: baseGame.md5Hash) + + if let existing = realm.object(ofType: PVGame.self, forPrimaryKey: variantMD5) { + livePatch?.variantGame = existing + livePatch?.variantGameMD5 = variantMD5 + livePatch?.sourceSHA256 = artifact.sourceSHA256 + livePatch?.outputStem = artifact.outputStem + if existing.isPatchVariant || existing.patchID == livePatch?.id { + // Our managed variant — refresh metadata / file pointer. + existing.isPatchVariant = true + existing.isHiddenPatchVariant = !(livePatch?.showAsLibraryTile ?? false) + existing.baseGameMD5 = liveBase?.md5Hash + existing.patchID = livePatch?.id + existing.file = Self.makeVariantFile(for: artifact.url) + existing.romPath = artifact.url.lastPathComponent + existing.isDownloaded = true + } + // Else: a standalone library ROM already has this content hash. + // Link the patch to it for launch, but do not absorb / re-flag it. + result = existing + return + } + + let variant = PVGame() + variant.md5Hash = variantMD5 + variant.title = "\(liveBase?.title ?? "Game") — \(livePatch?.displayTitle ?? "Patch")" + variant.systemIdentifier = liveBase?.systemIdentifier ?? "" + variant.system = liveBase?.system + variant.isPatchVariant = true + variant.isHiddenPatchVariant = !(livePatch?.showAsLibraryTile ?? false) + variant.baseGameMD5 = liveBase?.md5Hash + variant.patchID = livePatch?.id + variant.originalArtworkURL = liveBase?.originalArtworkURL ?? "" + variant.customArtworkURL = liveBase?.customArtworkURL ?? "" + variant.userPreferredCoreID = liveBase?.userPreferredCoreID + variant.isDownloaded = true + variant.file = Self.makeVariantFile(for: artifact.url) + variant.romPath = artifact.url.lastPathComponent + variant.crc = patchedCRC + + realm.add(variant, update: .modified) + livePatch?.variantGame = variant + livePatch?.variantGameMD5 = variantMD5 + livePatch?.sourceSHA256 = artifact.sourceSHA256 + livePatch?.outputStem = artifact.outputStem + livePatch?.baseGameMD5 = liveBase?.md5Hash + livePatch?.game = liveBase + result = variant + } + guard let result else { + throw PatchError.patchApplicationFailed("Failed to persist patch variant") + } + return result + } + + public func setShowAsLibraryTile(_ show: Bool, for patch: PVPatch) throws { + let realm = RomDatabase.sharedInstance.realm + try RomDatabase.sharedInstance.writeTransaction { + guard let p = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id) else { return } + p.showAsLibraryTile = show + if let variant = p.variantGame { + variant.isHiddenPatchVariant = !show + } + } + } + + public func setDefaultLaunch(_ enabled: Bool, for patch: PVPatch) throws { + let realm = RomDatabase.sharedInstance.realm + try RomDatabase.sharedInstance.writeTransaction { + guard let p = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id) else { return } + if enabled, let md5 = p.baseGameMD5 { + realm.objects(PVPatch.self) + .filter("baseGameMD5 == %@ AND isDefaultLaunch == true", md5) + .forEach { + $0.isDefaultLaunch = false + $0.isEnabled = false + } + } + p.isDefaultLaunch = enabled + p.isEnabled = enabled + } + } + + // MARK: - Launch preparation + + /// Prepare a patched variant for launch and return it. + public func prepareForLaunch( + patch: PVPatch, + progress: (@Sendable (PatchProgress) -> Void)? = nil + ) async throws -> PVGame { + let realm = RomDatabase.sharedInstance.realm + guard let livePatch = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id) else { + throw PatchError.patchFileNotFound(URL(fileURLWithPath: "/")) + } + guard let baseMD5 = livePatch.baseGameMD5 ?? livePatch.game?.md5Hash, + let baseGame = realm.object(ofType: PVGame.self, forPrimaryKey: baseMD5) else { + throw PatchError.sourceROMMismatch + } + guard let patchURL = livePatch.file?.url, let romURL = baseGame.file?.url else { + throw PatchError.sourceROMNotFound(baseGame.file?.url ?? URL(fileURLWithPath: "/")) + } + + let stem = livePatch.outputStem ?? PatchApplier.defaultOutputStem(romURL: romURL, patchURL: patchURL) + let artifact = try await applier.apply( + patchURL: patchURL, + to: romURL, + outputStem: stem, + progress: progress + ) + + var result: PVGame? + try RomDatabase.sharedInstance.writeTransaction { + let p = livePatch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: livePatch.id) + let base = baseGame.thaw() ?? realm.object(ofType: PVGame.self, forPrimaryKey: baseMD5) + let variantMD5 = artifact.patchedMD5 + + if let existing = realm.object(ofType: PVGame.self, forPrimaryKey: variantMD5) { + p?.variantGame = existing + p?.variantGameMD5 = variantMD5 + p?.sourceSHA256 = artifact.sourceSHA256 + p?.outputStem = artifact.outputStem + if existing.isPatchVariant || existing.patchID == p?.id { + existing.file = Self.makeVariantFile(for: artifact.url) + existing.romPath = artifact.url.lastPathComponent + existing.isDownloaded = true + existing.isPatchVariant = true + existing.isHiddenPatchVariant = !(p?.showAsLibraryTile ?? false) + existing.baseGameMD5 = baseMD5 + existing.patchID = p?.id + } + // Standalone ROM with matching content: launch as-is, don't absorb. + result = existing + return + } + + let variant = PVGame() + variant.md5Hash = variantMD5 + variant.title = "\(base?.title ?? "Game") — \(p?.displayTitle ?? "Patch")" + variant.systemIdentifier = base?.systemIdentifier ?? "" + variant.system = base?.system + variant.isPatchVariant = true + variant.isHiddenPatchVariant = !(p?.showAsLibraryTile ?? false) + variant.baseGameMD5 = baseMD5 + variant.patchID = p?.id + variant.originalArtworkURL = base?.originalArtworkURL ?? "" + variant.customArtworkURL = base?.customArtworkURL ?? "" + variant.userPreferredCoreID = base?.userPreferredCoreID + variant.file = Self.makeVariantFile(for: artifact.url) + variant.romPath = artifact.url.lastPathComponent + variant.isDownloaded = true + realm.add(variant, update: .modified) + + p?.variantGame = variant + p?.variantGameMD5 = variantMD5 + p?.sourceSHA256 = artifact.sourceSHA256 + p?.outputStem = artifact.outputStem + result = variant + } + guard let result else { + throw PatchError.patchApplicationFailed("Failed to prepare patch variant for launch") + } + return result + } + + /// Build a `PVFile` rooted at the platform documents/caches directory so + /// `FileLocationResolver` can find patched artifacts under `PatchedROMs/`. + private static func makeVariantFile(for artifactURL: URL) -> PVFile { + PVFile(withURL: artifactURL, relativeRoot: .platformDefault) + } + + /// Export a collision-safe patched ROM copy into Documents/PatchedROMs (or provided dir). + public func exportPatchedCopy( + patch: PVPatch, + to directory: URL? = nil, + progress: (@Sendable (PatchProgress) -> Void)? = nil + ) async throws -> URL { + let realm = RomDatabase.sharedInstance.realm + guard let livePatch = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id), + let patchURL = livePatch.file?.url, + let baseMD5 = livePatch.baseGameMD5 ?? livePatch.game?.md5Hash, + let baseGame = realm.object(ofType: PVGame.self, forPrimaryKey: baseMD5), + let romURL = baseGame.file?.url else { + throw PatchError.sourceROMMismatch + } + let dest = directory ?? URL.documentsPath + .appendingPathComponent("PatchedROMs", isDirectory: true) + let preferred = "\(livePatch.outputStem ?? PatchApplier.defaultOutputStem(romURL: romURL, patchURL: patchURL)).\(romURL.pathExtension)" + return try await applier.exportPatchedCopy( + patchURL: patchURL, + romURL: romURL, + to: dest, + preferredName: preferred, + progress: progress + ) + } + + public func reapply(_ patch: PVPatch, progress: (@Sendable (PatchProgress) -> Void)? = nil) async throws -> PVGame { + let realm = RomDatabase.sharedInstance.realm + guard let livePatch = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id), + let patchURL = livePatch.file?.url, + let baseMD5 = livePatch.baseGameMD5 ?? livePatch.game?.md5Hash, + let baseGame = realm.object(ofType: PVGame.self, forPrimaryKey: baseMD5), + let romURL = baseGame.file?.url else { + throw PatchError.sourceROMMismatch + } + try await applier.invalidateCache(patchURL: patchURL, romURL: romURL) + return try await prepareForLaunch(patch: livePatch, progress: progress) + } + + /// Whether deleting a patch should also remove the linked `PVGame`. + /// Standalone same-MD5 library ROMs may be linked for launch but must not be deleted. + public nonisolated static func shouldDeleteVariantGame(isPatchVariant: Bool?) -> Bool { + isPatchVariant == true + } + + /// Delete a patch and its managed variant/cache. Never deletes the original ROM + /// or a standalone library game that happens to share the patched content hash. + public func deletePatch(_ patch: PVPatch, deleteVariantSaves: Bool = true) throws { + let realm = RomDatabase.sharedInstance.realm + guard let live = patch.thaw() ?? realm.object(ofType: PVPatch.self, forPrimaryKey: patch.id) else { return } + + if let patchURL = live.file?.url, + let baseMD5 = live.baseGameMD5 ?? live.game?.md5Hash, + let base = realm.object(ofType: PVGame.self, forPrimaryKey: baseMD5), + let romURL = base.file?.url { + let applier = self.applier + Task { + try? await applier.invalidateCache(patchURL: patchURL, romURL: romURL) + } + } + + // Only remove PVGame rows we created as patch variants. A same-MD5 standalone + // ROM may be linked for launch — never absorb or delete it here. + let managedVariant = Self.shouldDeleteVariantGame(isPatchVariant: live.variantGame?.isPatchVariant) + ? live.variantGame + : nil + let variantFileURL = managedVariant?.file?.url + let patchFileURL = live.file?.url + + try RomDatabase.sharedInstance.writeTransaction { + if let managedVariant, !managedVariant.isInvalidated { + if deleteVariantSaves { + managedVariant.saveStates.forEach { realm.delete($0) } + managedVariant.cheats.forEach { realm.delete($0) } + managedVariant.recentPlays.forEach { realm.delete($0) } + } + realm.delete(managedVariant) + } + if !live.isInvalidated { + realm.delete(live) + } + } + + if let patchFileURL, FileManager.default.fileExists(atPath: patchFileURL.path) { + try? FileManager.default.removeItem(at: patchFileURL) + } + if let variantFileURL, FileManager.default.fileExists(atPath: variantFileURL.path) { + let path = variantFileURL.path + if path.contains("/PatchedROMs/") || path.contains("/PVPatchedROMs/") { + try? FileManager.default.removeItem(at: variantFileURL) + } + } + } + + /// Delete every patch / variant owned by a base game. Never deletes the base ROM itself. + public func deletePatches(forBaseGameMD5 md5: String) throws { + let owned = Array(patches(forGameMD5: md5).freeze()) + for patch in owned { + try deletePatch(patch) + } + } +} + +/// Local CRC32 for matching (mirrors PVPatching's internal `patchCRC32`). +private func patchCRC32(_ data: Data) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for byte in data { + crc ^= UInt32(byte) + for _ in 0..<8 { + crc = (crc >> 1) ^ (0xEDB8_8320 * (crc & 1)) + } + } + return ~crc +} diff --git a/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVGame.swift b/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVGame.swift index 91a3e9c91c..ec909ce99a 100644 --- a/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVGame.swift +++ b/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVGame.swift @@ -77,8 +77,23 @@ public final class PVGame: RealmSwift.Object, Identifiable, PVGameLibraryEntry { @Persisted public var contentless: Bool = false + /// True when this game is a patched variant of another ROM (isolated identity). + @Persisted(indexed: true) public var isPatchVariant: Bool = false + + /// MD5 of the original/base game when `isPatchVariant` is true. + @Persisted(indexed: true) public var baseGameMD5: String? + + /// Primary key of the owning `PVPatch` when this game is a patch variant. + @Persisted public var patchID: String? + + /// Hidden patch variants stay out of normal library queries until the user + /// chooses "Add as separate tile". + @Persisted(indexed: true) public var isHiddenPatchVariant: Bool = false + /* Links to other objects */ @Persisted(originProperty: "game") public var saveStates: LinkingObjects + @Persisted(originProperty: "game") public var patches: LinkingObjects + @Persisted(originProperty: "variantGame") public var variantOfPatches: LinkingObjects @Persisted(originProperty: "game") public var cheats: LinkingObjects @Persisted(originProperty: "game") public var recentPlays: LinkingObjects @Persisted public private(set) var screenShots: List diff --git a/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVPatch.swift b/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVPatch.swift index e4c579b113..3057062210 100644 --- a/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVPatch.swift +++ b/PVLibrary/Sources/PVRealm/RealmPlatform/Entities/PVPatch.swift @@ -12,6 +12,16 @@ import PVLogging import PVPrimitives import PVPatching +/// How a patch was associated with a base game. +public enum PVPatchMatchState: String, PersistableEnum, Sendable { + /// Imported, waiting for a base ROM or user choice. + case pending + /// Auto-matched with high confidence. + case matched + /// Explicitly linked by the user. + case manual +} + /// A Realm-persisted record representing a ROM patch file linked (optionally) to a game. @objcMembers public final class PVPatch: RealmSwift.Object, Identifiable, Filed, LocalFileProvider { @@ -20,8 +30,6 @@ public final class PVPatch: RealmSwift.Object, Identifiable, Filed, LocalFilePro /// Primary key for this patch record. /// Derived from `file.partialPath` (relative path) when available — idempotent across re-imports. - /// Falls back to the absolute URL path when `partialPath` is empty, and to a random UUID - /// only if neither is resolvable (non-idempotent; logged as a warning at import time). @Persisted(primaryKey: true) public var id: String = "" // MARK: - File & Game @@ -29,9 +37,18 @@ public final class PVPatch: RealmSwift.Object, Identifiable, Filed, LocalFilePro /// The on-disk patch file. @Persisted public var file: PVFile? - /// The ROM this patch targets. May be `nil` if the patch was imported before its ROM. + /// The ROM this patch targets. May be `nil` if the patch was imported before its ROM. @Persisted public var game: PVGame? + /// Stable MD5 of the base game (survives temporary unlink / re-import). + @Persisted(indexed: true) public var baseGameMD5: String? + + /// Managed variant game that launches the patched ROM with isolated saves/identity. + @Persisted public var variantGame: PVGame? + + /// MD5 of the patched ROM artifact (uppercase), once applied. + @Persisted public var variantGameMD5: String? + // MARK: - Metadata /// When the patch was imported. @@ -52,9 +69,40 @@ public final class PVPatch: RealmSwift.Object, Identifiable, Filed, LocalFilePro /// Long-form description of the patch's changes. @Persisted public var patchDescription: String? + /// Expected source ROM CRC32 embedded in the patch (BPS/UPS), if any. + @Persisted public var sourceCRC32: Int = 0 + + /// Whether `sourceCRC32` is meaningful (0 is a valid CRC). + @Persisted public var hasSourceCRC32: Bool = false + + /// SHA-256 of the patch file. + @Persisted public var patchSHA256: String? + + /// SHA-256 of the source ROM last used to build the cache artifact. + @Persisted public var sourceSHA256: String? + + /// Unique human-readable stem for cache / save directories. + @Persisted public var outputStem: String? + + /// Auto-match score (0...1+) from the last ranking pass. + @Persisted public var matchScore: Double = 0 + + /// Auto-match confidence raw value (`none`/`low`/`medium`/`high`/`exact`). + @Persisted public var matchConfidenceRaw: String = PatchMatchConfidence.none.rawValue + + /// Pending / matched / manual. + @Persisted public var matchState: PVPatchMatchState = .pending + // MARK: - State + /// Whether this patch is the default launch choice for its base game. + @Persisted public var isDefaultLaunch: Bool = false + + /// Whether a separate library tile should be shown for the variant. + @Persisted public var showAsLibraryTile: Bool = false + /// Whether the patch should be applied on the next launch. + /// Kept for compatibility; prefer `isDefaultLaunch`. @Persisted public var isEnabled: Bool = false // MARK: - Origin @@ -64,11 +112,24 @@ public final class PVPatch: RealmSwift.Object, Identifiable, Filed, LocalFilePro // MARK: - Computed - /// The strongly-typed patch format, derived from `formatRawValue`. public var format: PatchFormat? { PatchFormat(rawValue: formatRawValue) } + public var matchConfidence: PatchMatchConfidence { + get { PatchMatchConfidence(rawValue: matchConfidenceRaw) ?? .none } + set { matchConfidenceRaw = newValue.rawValue } + } + + public var displayTitle: String { + if let title, !title.isEmpty { return title } + return file?.fileName.deletingPathExtension ?? "Patch" + } + + public var sourceCRC32Value: UInt32? { + hasSourceCRC32 ? UInt32(bitPattern: Int32(truncatingIfNeeded: sourceCRC32)) : nil + } + // MARK: - Initialiser public convenience init( @@ -81,29 +142,24 @@ public final class PVPatch: RealmSwift.Object, Identifiable, Filed, LocalFilePro version: String? = nil, patchDescription: String? = nil, isEnabled: Bool = false, - sourceURLString: String? = nil + sourceURLString: String? = nil, + matchState: PVPatchMatchState = .pending ) { self.init() - // Derive a stable primary key from the file's relative path so that - // re-importing the same patch updates the record rather than creating a duplicate. - // A non-empty partialPath is ideal for idempotent imports; otherwise, we fall back to - // the file's absolute URL path (idempotent per stable destination path), and only use - // a random UUID if no path information is available. if file.partialPath.isEmpty { if let absolutePath = file.url?.path, !absolutePath.isEmpty { - WLOG("PVFile.partialPath is empty — using absolute URL path for patch id; imports remain idempotent as long as the destination path stays stable") + WLOG("PVFile.partialPath is empty — using absolute URL path for patch id") } else { - WLOG("PVFile.partialPath and file URL path are unavailable — patch import may not be idempotent; falling back to a random UUID key") + WLOG("PVFile.partialPath and file URL path unavailable — falling back to random UUID key") } } - // Use relative path for deterministic, collision-resistant key. - // Fall back to URL path (absolute but stable per destination) if partial path is unavailable. let stableKey = !file.partialPath.isEmpty ? file.partialPath : file.url?.path ?? UUID().uuidString self.id = stableKey self.file = file self.game = game + self.baseGameMD5 = game?.md5Hash self.date = date self.formatRawValue = format.rawValue self.title = title @@ -111,6 +167,15 @@ public final class PVPatch: RealmSwift.Object, Identifiable, Filed, LocalFilePro self.version = version self.patchDescription = patchDescription self.isEnabled = isEnabled + self.isDefaultLaunch = isEnabled self.sourceURLString = sourceURLString + self.matchState = game == nil ? matchState : (matchState == .pending ? .manual : matchState) + self.outputStem = file.fileName.deletingPathExtension + } +} + +private extension String { + var deletingPathExtension: String { + (self as NSString).deletingPathExtension } } diff --git a/PVLibrary/Tests/PVLibraryTests/PVPatchModelTests.swift b/PVLibrary/Tests/PVLibraryTests/PVPatchModelTests.swift new file mode 100644 index 0000000000..72c5ae03d2 --- /dev/null +++ b/PVLibrary/Tests/PVLibraryTests/PVPatchModelTests.swift @@ -0,0 +1,91 @@ +// +// PVPatchModelTests.swift +// PVLibraryTests +// + +import XCTest +import RealmSwift +import PVPatching +@testable import PVLibrary +import PVRealm + +final class PVPatchModelTests: XCTestCase { + private var previousConfig: Realm.Configuration! + private var realm: Realm! + + override func setUpWithError() throws { + previousConfig = Realm.Configuration.defaultConfiguration + let config = Realm.Configuration( + inMemoryIdentifier: "PVPatchModelTests-\(UUID().uuidString)" + ) + Realm.Configuration.defaultConfiguration = config + realm = try Realm(configuration: config) + } + + override func tearDownWithError() throws { + realm = nil + Realm.Configuration.defaultConfiguration = previousConfig + } + + func testPatchPrimaryKeyIsIdempotentAcrossReimport() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PVPatchIdem-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let patchURL = dir.appendingPathComponent("hack.bps") + try Data("BPS1".utf8).write(to: patchURL) + + let file1 = PVFile(withURL: patchURL) + let patch1 = PVPatch(file: file1, format: .bps, title: "Hack") + try realm.write { realm.add(patch1, update: .modified) } + + let file2 = PVFile(withURL: patchURL) + let patch2 = PVPatch(file: file2, format: .bps, title: "Hack Again") + XCTAssertEqual(patch1.id, patch2.id) + + try realm.write { realm.add(patch2, update: .modified) } + XCTAssertEqual(realm.objects(PVPatch.self).count, 1) + } + + func testVariantFlagsDefaultHidden() throws { + let game = PVGame() + game.md5Hash = "ABCDEF" + game.title = "Base" + game.systemIdentifier = "SNES" + game.isPatchVariant = true + game.isHiddenPatchVariant = true + game.baseGameMD5 = "BASEMD5" + game.patchID = "patches/hack.bps" + + try realm.write { realm.add(game) } + + let fetched = realm.object(ofType: PVGame.self, forPrimaryKey: "ABCDEF") + XCTAssertEqual(fetched?.isPatchVariant, true) + XCTAssertEqual(fetched?.isHiddenPatchVariant, true) + XCTAssertEqual(fetched?.baseGameMD5, "BASEMD5") + + let visible = realm.objects(PVGame.self).filter("isHiddenPatchVariant == false") + XCTAssertEqual(visible.count, 0) + } + + func testMatchStateDefaultsPendingWithoutGame() { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PVPatchPending-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("p.ips") + try? Data([0]).write(to: url) + + let patch = PVPatch(file: PVFile(withURL: url), format: .ips, title: "P") + XCTAssertEqual(patch.matchState, .pending) + XCTAssertEqual(patch.isDefaultLaunch, false) + XCTAssertEqual(patch.showAsLibraryTile, false) + } + + func testDeletePatchSkipsStandaloneLinkedGame() { + XCTAssertFalse(PatchRepository.shouldDeleteVariantGame(isPatchVariant: nil)) + XCTAssertFalse(PatchRepository.shouldDeleteVariantGame(isPatchVariant: false)) + XCTAssertTrue(PatchRepository.shouldDeleteVariantGame(isPatchVariant: true)) + } +} diff --git a/PVPatching/Sources/PVPatching/PVPatching.swift b/PVPatching/Sources/PVPatching/PVPatching.swift index c4e7d429f4..0ff6a00c0d 100644 --- a/PVPatching/Sources/PVPatching/PVPatching.swift +++ b/PVPatching/Sources/PVPatching/PVPatching.swift @@ -8,16 +8,18 @@ /// PVPatching — ROM patch file support for Provenance. /// -/// Supports applying patches in common formats (IPS, BPS, UPS, xdelta) to ROM files. -/// Original ROMs are never modified; patched ROMs are cached in the app cache directory. +/// Supports applying and creating patches in common formats (IPS, BPS, UPS, PPF). +/// Original ROMs are never modified; patched ROMs are stored under `PatchedROMs/` +/// with unique filenames so each variant gets isolated battery-save / save-state +/// directories and resolves through the normal Documents/Caches file roots. /// /// ## Quick start /// ```swift /// let applier = PatchApplier() -/// let patchedURL = try await applier.apply(patchURL: patchURL, to: romURL) -/// // Launch core with patchedURL +/// let artifact = try await applier.apply(patchURL: patchURL, to: romURL) +/// // Launch core with artifact.url / artifact.patchedMD5 /// ``` public enum PVPatching { /// Module version string. - public static let version = "1.0.0" + public static let version = "1.1.0" } diff --git a/PVPatching/Sources/PVPatching/PatchApplier.swift b/PVPatching/Sources/PVPatching/PatchApplier.swift index 32e6b75eab..80268355b6 100644 --- a/PVPatching/Sources/PVPatching/PatchApplier.swift +++ b/PVPatching/Sources/PVPatching/PatchApplier.swift @@ -15,13 +15,6 @@ import PVLogging /// (or returns a cached result), and writes the output to the patch cache. /// /// Original ROM files are **never modified**. -/// -/// ## Usage -/// ```swift -/// let applier = PatchApplier() -/// let patchedURL = try await applier.apply(patchURL: patchURL, to: romURL) -/// // Pass patchedURL to the emulator core -/// ``` public actor PatchApplier { private let cache: PatchCache @@ -30,53 +23,111 @@ public actor PatchApplier { self.cache = cache } - /// Apply a patch file to a ROM, returning the URL of the patched ROM. - /// - /// If a valid cached result exists, it is returned immediately without re-patching. + /// Apply a patch file to a ROM, returning a typed artifact with hashes and URL. /// /// - Parameters: /// - patchURL: URL of the patch file. /// - romURL: URL of the source ROM to patch. - /// - Returns: URL of the patched ROM in the patch cache. - /// - Throws: `PatchError` if the format is unsupported or patching fails. - public func apply(patchURL: URL, to romURL: URL) async throws -> URL { + /// - outputStem: Unique human-readable stem for the cached file / save directories. + /// - progress: Optional progress callback (0...1). + public func apply( + patchURL: URL, + to romURL: URL, + outputStem: String? = nil, + progress: (@Sendable (PatchProgress) -> Void)? = nil + ) async throws -> PatchArtifact { guard FileManager.default.fileExists(atPath: patchURL.path) else { throw PatchError.patchFileNotFound(patchURL) } guard FileManager.default.fileExists(atPath: romURL.path) else { throw PatchError.sourceROMNotFound(romURL) } - guard let format = PatchFormat.detect(from: patchURL) else { throw PatchError.unsupportedFormat(patchURL.pathExtension) } - // Load files once; use data for both the cache key check and patch application. + progress?(PatchProgress(fractionCompleted: 0.05, status: "Reading files…")) let patchFileData = try Data(contentsOf: patchURL, options: .mappedIfSafe) - let romData = try Data(contentsOf: romURL, options: .mappedIfSafe) + let romData = try Data(contentsOf: romURL, options: .mappedIfSafe) + let stem = outputStem ?? Self.defaultOutputStem( + romURL: romURL, + patchURL: patchURL + ) + let sourceSHA = PatchHashing.sha256Hex(romData) + let patchSHA = PatchHashing.sha256Hex(patchFileData) - // Return cached result if available - if let cached = await cache.cachedURL(romData: romData, patchData: patchFileData, extension: romURL.pathExtension) { + if let cached = await cache.cachedURL( + romData: romData, + patchData: patchFileData, + outputStem: stem, + extension: romURL.pathExtension + ) { + progress?(PatchProgress(fractionCompleted: 1.0, status: "Using cached patch…")) + let patchedData = try Data(contentsOf: cached, options: .mappedIfSafe) ILOG("PatchApplier: returning cached result for \(patchURL.lastPathComponent)") - return cached + return PatchArtifact( + url: cached, + outputStem: stem, + patchedMD5: PatchHashing.md5Hex(patchedData), + patchedSHA256: PatchHashing.sha256Hex(patchedData), + sourceSHA256: sourceSHA, + patchSHA256: patchSHA, + format: format, + wasCached: true + ) } + progress?(PatchProgress(fractionCompleted: 0.25, status: "Applying \(format.displayName)…")) ILOG("PatchApplier: applying \(format.displayName) patch '\(patchURL.lastPathComponent)' to '\(romURL.lastPathComponent)'") - let patched = try applyPatch(format: format, patch: patchFileData, source: romData) + progress?(PatchProgress(fractionCompleted: 0.85, status: "Caching patched ROM…")) do { - let cachedURL = try await cache.store(patched, romData: romData, patchData: patchFileData, extension: romURL.pathExtension) + let cachedURL = try await cache.store( + patched, + romData: romData, + patchData: patchFileData, + outputStem: stem, + extension: romURL.pathExtension + ) + progress?(PatchProgress(fractionCompleted: 1.0, status: "Done")) ILOG("PatchApplier: cached patched ROM at \(cachedURL.path)") - return cachedURL + return PatchArtifact( + url: cachedURL, + outputStem: stem, + patchedMD5: PatchHashing.md5Hex(patched), + patchedSHA256: PatchHashing.sha256Hex(patched), + sourceSHA256: sourceSHA, + patchSHA256: patchSHA, + format: format, + wasCached: false + ) } catch { throw PatchError.outputWriteFailed(error) } } + /// Export a patched ROM copy into `directory` with a collision-safe name. + public func exportPatchedCopy( + patchURL: URL, + romURL: URL, + to directory: URL, + preferredName: String? = nil, + progress: (@Sendable (PatchProgress) -> Void)? = nil + ) async throws -> URL { + let artifact = try await apply( + patchURL: patchURL, + to: romURL, + outputStem: preferredName.map { ($0 as NSString).deletingPathExtension }, + progress: progress + ) + let name = preferredName ?? "\(artifact.outputStem).\(romURL.pathExtension)" + return try await cache.export(artifactURL: artifact.url, to: directory, preferredName: name) + } + /// Invalidate the cache entry for a specific ROM + patch pair. public func invalidateCache(patchURL: URL, romURL: URL) async throws { - let romData = try Data(contentsOf: romURL, options: .mappedIfSafe) + let romData = try Data(contentsOf: romURL, options: .mappedIfSafe) let patchData = try Data(contentsOf: patchURL, options: .mappedIfSafe) try await cache.remove(romData: romData, patchData: patchData) } @@ -86,6 +137,15 @@ public actor PatchApplier { try await cache.clearAll() } + public static func defaultOutputStem(romURL: URL, patchURL: URL) -> String { + let romStem = romURL.deletingPathExtension().lastPathComponent + let patchStem = patchURL.deletingPathExtension().lastPathComponent + if patchStem.localizedCaseInsensitiveContains(romStem) { + return patchStem + } + return "\(romStem) [\(patchStem)]" + } + // MARK: - Private private func applyPatch(format: PatchFormat, patch: Data, source: Data) throws -> Data { diff --git a/PVPatching/Sources/PVPatching/PatchArtifact.swift b/PVPatching/Sources/PVPatching/PatchArtifact.swift new file mode 100644 index 0000000000..f9d6cfeca1 --- /dev/null +++ b/PVPatching/Sources/PVPatching/PatchArtifact.swift @@ -0,0 +1,63 @@ +// +// PatchArtifact.swift +// PVPatching +// +// Created by Provenance Emu on 2026-10-10. +// Copyright © 2026 Provenance Emu. All rights reserved. +// + +import Foundation + +/// Result of applying a patch to a ROM. +/// +/// The artifact URL is a unique, human-readable cache file so each patch +/// variant gets its own battery-save / save-state directory name. +public struct PatchArtifact: Sendable, Equatable, Codable { + /// Location of the patched ROM on disk. + public var url: URL + /// Display stem used for the output file (without extension). + public var outputStem: String + /// MD5 of the patched ROM bytes (uppercase hex, no spaces). + public var patchedMD5: String + /// SHA-256 of the patched ROM bytes (lowercase hex). + public var patchedSHA256: String + /// SHA-256 of the source ROM used for the cache key. + public var sourceSHA256: String + /// SHA-256 of the patch file used for the cache key. + public var patchSHA256: String + /// Detected patch format that produced this artifact. + public var format: PatchFormat + /// Whether the result came from an existing cache entry. + public var wasCached: Bool + + public init( + url: URL, + outputStem: String, + patchedMD5: String, + patchedSHA256: String, + sourceSHA256: String, + patchSHA256: String, + format: PatchFormat, + wasCached: Bool + ) { + self.url = url + self.outputStem = outputStem + self.patchedMD5 = patchedMD5 + self.patchedSHA256 = patchedSHA256 + self.sourceSHA256 = sourceSHA256 + self.patchSHA256 = patchSHA256 + self.format = format + self.wasCached = wasCached + } +} + +/// Progress reported while applying or creating patches. +public struct PatchProgress: Sendable, Equatable { + public var fractionCompleted: Double + public var status: String + + public init(fractionCompleted: Double, status: String) { + self.fractionCompleted = min(max(fractionCompleted, 0), 1) + self.status = status + } +} diff --git a/PVPatching/Sources/PVPatching/PatchCache.swift b/PVPatching/Sources/PVPatching/PatchCache.swift index b927feb10a..2a75585980 100644 --- a/PVPatching/Sources/PVPatching/PatchCache.swift +++ b/PVPatching/Sources/PVPatching/PatchCache.swift @@ -7,18 +7,15 @@ // import Foundation -#if canImport(CryptoKit) -import CryptoKit -#elseif canImport(Crypto) -import Crypto -#endif /// Manages a disk cache of pre-applied patched ROMs. /// /// Cache entries are keyed by a hash derived from both the source ROM and the patch file, /// so changing either invalidates the cache entry. Original ROMs are never modified. /// -/// Cache directory: `Library/Caches/PVPatchedROMs//patched.` +/// Cache directory: `Documents/PatchedROMs//.` on iOS/macOS +/// (so `FileLocationResolver` / `PVFile` can resolve them), and +/// `Library/Caches/PatchedROMs/...` on tvOS. public actor PatchCache { private let cacheDirectory: URL @@ -27,42 +24,94 @@ public actor PatchCache { if let dir = cacheDirectory { self.cacheDirectory = dir } else { - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSTemporaryDirectory()) - self.cacheDirectory = caches.appendingPathComponent("PVPatchedROMs", isDirectory: true) + self.cacheDirectory = Self.defaultCacheDirectory() } } + /// Platform-appropriate root for patched ROM artifacts. + public static func defaultCacheDirectory() -> URL { +#if os(tvOS) + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) +#else + let base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) +#endif + return base.appendingPathComponent("PatchedROMs", isDirectory: true) + } + /// Look up a cached patched ROM using already-loaded file data. - /// - /// - Parameters: - /// - romData: The source ROM data (used to compute cache key). - /// - patchData: The patch file data (used to compute cache key). - /// - ext: The file extension of the original ROM (for the cached filename). - /// - Returns: URL of the cached patched ROM, or `nil` if not cached. - public func cachedURL(romData: Data, patchData: Data, extension ext: String) -> URL? { - let key = cacheKey(romData: romData, patchData: patchData) - let candidate = cacheEntry(for: key, extension: ext) - return FileManager.default.fileExists(atPath: candidate.path) ? candidate : nil + public func cachedURL( + romData: Data, + patchData: Data, + outputStem: String, + extension ext: String + ) -> URL? { + let key = PatchHashing.cacheKey(romData: romData, patchData: patchData) + let candidate = cacheEntry(for: key, outputStem: outputStem, extension: ext) + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + // Migrate older "patched." cache entries into the unique name. + let legacy = cacheEntry(for: key, outputStem: "patched", extension: ext) + if FileManager.default.fileExists(atPath: legacy.path) { + do { + try FileManager.default.moveItem(at: legacy, to: candidate) + return candidate + } catch { + return legacy + } + } + return nil } /// Store patched ROM data in the cache. - /// - /// - Parameters: - /// - data: The patched ROM data to cache. - /// - romData: The source ROM data (used to compute cache key). - /// - patchData: The patch file data (used to compute cache key). - /// - ext: File extension for the cached output file. - /// - Returns: URL of the saved cached file. - public func store(_ data: Data, romData: Data, patchData: Data, extension ext: String) throws -> URL { - let key = cacheKey(romData: romData, patchData: patchData) + public func store( + _ data: Data, + romData: Data, + patchData: Data, + outputStem: String, + extension ext: String + ) throws -> URL { + let key = PatchHashing.cacheKey(romData: romData, patchData: patchData) let entryDir = cacheDirectory.appendingPathComponent(key, isDirectory: true) try FileManager.default.createDirectory(at: entryDir, withIntermediateDirectories: true) - let outputURL = cacheEntry(for: key, extension: ext) + let outputURL = cacheEntry(for: key, outputStem: outputStem, extension: ext) + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) + } try data.write(to: outputURL, options: .atomic) return outputURL } + /// Copy a cached (or newly applied) artifact into a destination directory with a + /// collision-safe filename. Never overwrites an existing different file. + public func export( + artifactURL: URL, + to directory: URL, + preferredName: String + ) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let preferred = directory.appendingPathComponent(preferredName) + if !FileManager.default.fileExists(atPath: preferred.path) { + try FileManager.default.copyItem(at: artifactURL, to: preferred) + return preferred + } + let stem = (preferredName as NSString).deletingPathExtension + let ext = (preferredName as NSString).pathExtension + var index = 1 + while true { + let candidateName = ext.isEmpty ? "\(stem)-\(index)" : "\(stem)-\(index).\(ext)" + let candidate = directory.appendingPathComponent(candidateName) + if !FileManager.default.fileExists(atPath: candidate.path) { + try FileManager.default.copyItem(at: artifactURL, to: candidate) + return candidate + } + index += 1 + } + } + /// Remove all cached patched ROMs. public func clearAll() throws { if FileManager.default.fileExists(atPath: cacheDirectory.path) { @@ -72,7 +121,7 @@ public actor PatchCache { /// Remove the cached entry for a specific ROM + patch pair. public func remove(romData: Data, patchData: Data) throws { - let key = cacheKey(romData: romData, patchData: patchData) + let key = PatchHashing.cacheKey(romData: romData, patchData: patchData) let entryDir = cacheDirectory.appendingPathComponent(key, isDirectory: true) if FileManager.default.fileExists(atPath: entryDir.path) { try FileManager.default.removeItem(at: entryDir) @@ -81,35 +130,18 @@ public actor PatchCache { // MARK: - Private helpers - private func cacheEntry(for key: String, extension ext: String) -> URL { + private func cacheEntry(for key: String, outputStem: String, extension ext: String) -> URL { let dir = cacheDirectory.appendingPathComponent(key, isDirectory: true) - return dir.appendingPathComponent("patched.\(ext.isEmpty ? "rom" : ext)") + let safeStem = sanitizedStem(outputStem) + let suffix = ext.isEmpty ? "rom" : ext + return dir.appendingPathComponent("\(safeStem).\(suffix)") } - /// Cache key derived from the rom and patch data. - private func cacheKey(romData: Data, patchData: Data) -> String { -#if canImport(CryptoKit) - let romHash = SHA256.hash(data: romData).compactMap { String(format: "%02x", $0) }.joined() - let patchHash = SHA256.hash(data: patchData).compactMap { String(format: "%02x", $0) }.joined() - let combined = Data((romHash + patchHash).utf8) - return String(SHA256.hash(data: combined).compactMap { String(format: "%02x", $0) }.joined().prefix(32)) -#elseif canImport(Crypto) - let romHash = Crypto.SHA256.hash(data: romData).compactMap { String(format: "%02x", $0) }.joined() - let patchHash = Crypto.SHA256.hash(data: patchData).compactMap { String(format: "%02x", $0) }.joined() - let combined = Data((romHash + patchHash).utf8) - return String(Crypto.SHA256.hash(data: combined).compactMap { String(format: "%02x", $0) }.joined().prefix(32)) -#else - // Fallback: deterministic but weaker hash for platforms without CryptoKit or Crypto. - // Includes CRC32 of both inputs and their (possibly truncated) sizes. - let romCRC = patchCRC32(romData) - let patchCRC = patchCRC32(patchData) - return String( - format: "%08x%08x%08x%08x", - romCRC, - patchCRC, - UInt32(truncatingIfNeeded: romData.count), - UInt32(truncatingIfNeeded: patchData.count) - ) -#endif + private func sanitizedStem(_ stem: String) -> String { + let trimmed = stem.trimmingCharacters(in: .whitespacesAndNewlines) + let invalid = CharacterSet(charactersIn: "/\\:?%*|\"<>") + let cleaned = trimmed.unicodeScalars.map { invalid.contains($0) ? "_" : Character($0) } + let value = String(cleaned) + return value.isEmpty ? "patched" : value } } diff --git a/PVPatching/Sources/PVPatching/PatchHashing.swift b/PVPatching/Sources/PVPatching/PatchHashing.swift new file mode 100644 index 0000000000..76a035196a --- /dev/null +++ b/PVPatching/Sources/PVPatching/PatchHashing.swift @@ -0,0 +1,44 @@ +// +// PatchHashing.swift +// PVPatching +// +// Created by Provenance Emu on 2026-10-10. +// Copyright © 2026 Provenance Emu. All rights reserved. +// + +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#elseif canImport(Crypto) +import Crypto +#endif + +enum PatchHashing { + static func sha256Hex(_ data: Data) -> String { +#if canImport(CryptoKit) + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() +#elseif canImport(Crypto) + return Crypto.SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() +#else + // Fallback for exotic platforms: CRC32 + length is deterministic but weaker. + return String(format: "%08x%08x", patchCRC32(data), UInt32(truncatingIfNeeded: data.count)) +#endif + } + + static func md5Hex(_ data: Data) -> String { +#if canImport(CryptoKit) + return Insecure.MD5.hash(data: data).map { String(format: "%02X", $0) }.joined() +#else + // Platforms without CryptoKit fall back to a deterministic uppercase hex + // digest derived from SHA-256 truncation — not MD5, but stable for cache IDs. + return String(sha256Hex(data).prefix(32)).uppercased() +#endif + } + + static func cacheKey(romData: Data, patchData: Data) -> String { + let romHash = sha256Hex(romData) + let patchHash = sha256Hex(patchData) + let combined = Data((romHash + patchHash).utf8) + return String(sha256Hex(combined).prefix(32)) + } +} diff --git a/PVPatching/Sources/PVPatching/PatchInspector.swift b/PVPatching/Sources/PVPatching/PatchInspector.swift new file mode 100644 index 0000000000..8459200e45 --- /dev/null +++ b/PVPatching/Sources/PVPatching/PatchInspector.swift @@ -0,0 +1,285 @@ +// +// PatchInspector.swift +// PVPatching +// +// Created by Provenance Emu on 2026-10-10. +// Copyright © 2026 Provenance Emu. All rights reserved. +// + +import Foundation + +/// Information extracted from a patch file without applying it. +public struct PatchInspection: Sendable, Equatable { + public var format: PatchFormat + public var title: String? + public var author: String? + public var version: String? + public var patchDescription: String? + /// CRC32 of the expected source ROM, when the format embeds one. + public var sourceCRC32: UInt32? + /// CRC32 of the expected patched ROM, when the format embeds one. + public var targetCRC32: UInt32? + public var sourceSize: Int? + public var targetSize: Int? + public var patchSHA256: String + + public init( + format: PatchFormat, + title: String? = nil, + author: String? = nil, + version: String? = nil, + patchDescription: String? = nil, + sourceCRC32: UInt32? = nil, + targetCRC32: UInt32? = nil, + sourceSize: Int? = nil, + targetSize: Int? = nil, + patchSHA256: String + ) { + self.format = format + self.title = title + self.author = author + self.version = version + self.patchDescription = patchDescription + self.sourceCRC32 = sourceCRC32 + self.targetCRC32 = targetCRC32 + self.sourceSize = sourceSize + self.targetSize = targetSize + self.patchSHA256 = patchSHA256 + } +} + +/// How confidently a patch matches a candidate ROM. +public enum PatchMatchConfidence: String, Sendable, Codable, Comparable { + case none + case low + case medium + case high + case exact + + private var rank: Int { + switch self { + case .none: return 0 + case .low: return 1 + case .medium: return 2 + case .high: return 3 + case .exact: return 4 + } + } + + public static func < (lhs: PatchMatchConfidence, rhs: PatchMatchConfidence) -> Bool { + lhs.rank < rhs.rank + } +} + +public struct PatchMatchCandidate: Sendable, Equatable, Identifiable { + public var id: String + public var title: String + public var md5: String + public var crc32: UInt32? + public var filename: String + public var confidence: PatchMatchConfidence + public var score: Double + + public init( + id: String, + title: String, + md5: String, + crc32: UInt32? = nil, + filename: String, + confidence: PatchMatchConfidence, + score: Double + ) { + self.id = id + self.title = title + self.md5 = md5 + self.crc32 = crc32 + self.filename = filename + self.confidence = confidence + self.score = score + } +} + +/// Reads format-specific headers and scores ROM candidates for auto-matching. +public struct PatchInspector: Sendable { + public init() {} + + public func inspect(patchURL: URL) throws -> PatchInspection { + guard let format = PatchFormat.detect(from: patchURL) else { + throw PatchError.unsupportedFormat(patchURL.pathExtension) + } + let data = try Data(contentsOf: patchURL, options: .mappedIfSafe) + return try inspect(patchData: data, format: format, fallbackTitle: patchURL.deletingPathExtension().lastPathComponent) + } + + public func inspect(patchData: Data, format: PatchFormat, fallbackTitle: String? = nil) throws -> PatchInspection { + let sha = PatchHashing.sha256Hex(patchData) + switch format { + case .bps: + return try inspectBPS(patchData, sha: sha, fallbackTitle: fallbackTitle) + case .ups: + return try inspectUPS(patchData, sha: sha, fallbackTitle: fallbackTitle) + case .ips, .ips32, .ppf, .xdelta, .xdelta3, .aps, .rup, .nsp: + return PatchInspection( + format: format, + title: fallbackTitle, + patchSHA256: sha + ) + } + } + + /// Score candidate ROMs against a patch inspection. + /// + /// Auto-link only when a single candidate reaches `.exact` or `.high` and + /// no other candidate is within 0.15 of its score. + public func rank( + candidates: [PatchMatchCandidate], + inspection: PatchInspection, + patchFilename: String + ) -> [PatchMatchCandidate] { + let patchStem = normalizedStem(patchFilename) + return candidates.map { candidate in + var score = 0.0 + var confidence = PatchMatchConfidence.none + + if let expected = inspection.sourceCRC32, let actual = candidate.crc32, expected == actual { + score += 1.0 + confidence = .exact + } + + let romStem = normalizedStem(candidate.filename) + let titleStem = normalizedStem(candidate.title) + if !patchStem.isEmpty { + if romStem == patchStem || titleStem == patchStem { + score += 0.75 + confidence = max(confidence, .high) + } else if romStem.contains(patchStem) || patchStem.contains(romStem) + || titleStem.contains(patchStem) || patchStem.contains(titleStem) { + score += 0.45 + confidence = max(confidence, .medium) + } else { + let distance = levenshtein(patchStem, romStem.isEmpty ? titleStem : romStem) + let maxLen = max(patchStem.count, max(romStem.count, titleStem.count), 1) + let similarity = 1.0 - (Double(distance) / Double(maxLen)) + if similarity >= 0.7 { + score += similarity * 0.4 + confidence = max(confidence, .low) + } + } + } + + var updated = candidate + updated.score = score + updated.confidence = confidence + return updated + } + .sorted { lhs, rhs in + if lhs.score == rhs.score { + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + return lhs.score > rhs.score + } + } + + public func unambiguousMatch(from ranked: [PatchMatchCandidate]) -> PatchMatchCandidate? { + guard let best = ranked.first, best.confidence >= .high else { return nil } + if ranked.count == 1 { return best } + let second = ranked[1] + guard best.score - second.score >= 0.15 else { return nil } + return best + } + + // MARK: - Private + + private func inspectBPS(_ data: Data, sha: String, fallbackTitle: String?) throws -> PatchInspection { + guard data.count >= 19, data[0..<4].elementsEqual("BPS1".utf8) else { + throw PatchError.corruptPatchFile("Invalid BPS header") + } + var pos = 4 + let sourceSize = readVLI(data, pos: &pos) + let targetSize = readVLI(data, pos: &pos) + let metadataLength = readVLI(data, pos: &pos) + var title = fallbackTitle + var author: String? + var version: String? + var description: String? + if metadataLength > 0, pos + metadataLength <= data.count { + let metaData = data[pos..<(pos + metadataLength)] + if let json = try? JSONSerialization.jsonObject(with: Data(metaData)) as? [String: Any] { + title = (json["title"] as? String) ?? title + author = json["author"] as? String + version = json["version"] as? String + description = (json["description"] as? String) ?? (json["desc"] as? String) + } else if let text = String(data: Data(metaData), encoding: .utf8), !text.isEmpty { + description = text + } + } + let sourceCRC = data.readLE32(at: data.count - 12) + let targetCRC = data.readLE32(at: data.count - 8) + return PatchInspection( + format: .bps, + title: title, + author: author, + version: version, + patchDescription: description, + sourceCRC32: sourceCRC, + targetCRC32: targetCRC, + sourceSize: sourceSize, + targetSize: targetSize, + patchSHA256: sha + ) + } + + private func inspectUPS(_ data: Data, sha: String, fallbackTitle: String?) throws -> PatchInspection { + // UPS: "UPS1" + sourceSize VLI + targetSize VLI + ... + sourceCRC + targetCRC + patchCRC + guard data.count >= 16, data[0..<4].elementsEqual("UPS1".utf8) else { + throw PatchError.corruptPatchFile("Invalid UPS header") + } + var pos = 4 + let sourceSize = readVLI(data, pos: &pos) + let targetSize = readVLI(data, pos: &pos) + let sourceCRC = data.readLE32(at: data.count - 12) + let targetCRC = data.readLE32(at: data.count - 8) + return PatchInspection( + format: .ups, + title: fallbackTitle, + sourceCRC32: sourceCRC, + targetCRC32: targetCRC, + sourceSize: sourceSize, + targetSize: targetSize, + patchSHA256: sha + ) + } + + private func normalizedStem(_ value: String) -> String { + let noExt = (value as NSString).deletingPathExtension + let scalars = noExt.lowercased().unicodeScalars.map { scalar -> Character in + CharacterSet.alphanumerics.contains(scalar) ? Character(scalar) : Character(" ") + } + return String(scalars) + .split(separator: " ") + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func levenshtein(_ a: String, _ b: String) -> Int { + let aChars = Array(a) + let bChars = Array(b) + if aChars.isEmpty { return bChars.count } + if bChars.isEmpty { return aChars.count } + var previous = Array(0...bChars.count) + var current = Array(repeating: 0, count: bChars.count + 1) + for (i, aChar) in aChars.enumerated() { + current[0] = i + 1 + for (j, bChar) in bChars.enumerated() { + let cost = aChar == bChar ? 0 : 1 + current[j + 1] = min( + previous[j + 1] + 1, + current[j] + 1, + previous[j] + cost + ) + } + previous = current + } + return previous[bChars.count] + } +} diff --git a/PVPatching/Sources/PVPatching/Patchers/PatchCreator.swift b/PVPatching/Sources/PVPatching/Patchers/PatchCreator.swift new file mode 100644 index 0000000000..9bca02fc0f --- /dev/null +++ b/PVPatching/Sources/PVPatching/Patchers/PatchCreator.swift @@ -0,0 +1,246 @@ +// +// PatchCreator.swift +// PVPatching +// +// Created by Provenance Emu on 2026-10-10. +// Copyright © 2026 Provenance Emu. All rights reserved. +// + +import Foundation +import PVLogging + +/// Creates BPS or IPS/IPS32 patches from an original + modified ROM pair. +/// +/// Creation never modifies either input file. Round-trip verification is applied +/// by default so a bad encoder cannot silently produce an invalid patch. +public struct PatchCreator: Sendable { + + public enum CreationFormat: String, Sendable, CaseIterable, Identifiable { + case bps + case ips + case ips32 + + public var id: String { rawValue } + + public var patchFormat: PatchFormat { + switch self { + case .bps: return .bps + case .ips: return .ips + case .ips32: return .ips32 + } + } + + public var fileExtension: String { + switch self { + case .bps: return "bps" + case .ips: return "ips" + case .ips32: return "ips32" + } + } + + public var displayName: String { + patchFormat.displayName + } + } + + public init() {} + + /// Create a patch file from `originalURL` → `modifiedURL`. + @discardableResult + public func create( + originalURL: URL, + modifiedURL: URL, + outputURL: URL, + format: CreationFormat, + metadata: PatchMetadata = PatchMetadata(), + verifyRoundTrip: Bool = true, + progress: (@Sendable (PatchProgress) -> Void)? = nil + ) throws -> URL { + progress?(PatchProgress(fractionCompleted: 0.05, status: "Reading ROMs…")) + let original = try Data(contentsOf: originalURL, options: .mappedIfSafe) + let modified = try Data(contentsOf: modifiedURL, options: .mappedIfSafe) + guard original != modified else { + throw PatchError.patchApplicationFailed("Original and modified ROMs are identical") + } + + progress?(PatchProgress(fractionCompleted: 0.35, status: "Encoding \(format.displayName)…")) + let patchData: Data + switch format { + case .bps: + patchData = try createBPS(original: original, modified: modified, metadata: metadata) + case .ips: + patchData = try createIPS(original: original, modified: modified, isIPS32: false) + case .ips32: + patchData = try createIPS(original: original, modified: modified, isIPS32: true) + } + + if verifyRoundTrip { + progress?(PatchProgress(fractionCompleted: 0.75, status: "Verifying round-trip…")) + let applied: Data + switch format { + case .bps: + applied = try BPSPatcher().apply(patch: patchData, to: original) + case .ips: + applied = try IPSPatcher().apply(patch: patchData, to: original, isIPS32: false) + case .ips32: + applied = try IPSPatcher().apply(patch: patchData, to: original, isIPS32: true) + } + guard applied == modified else { + throw PatchError.patchedROMVerificationFailed + } + } + + progress?(PatchProgress(fractionCompleted: 0.95, status: "Writing patch…")) + try FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) + } + try patchData.write(to: outputURL, options: .atomic) + progress?(PatchProgress(fractionCompleted: 1.0, status: "Done")) + ILOG("PatchCreator: wrote \(format.displayName) patch to \(outputURL.lastPathComponent)") + return outputURL + } + + // MARK: - BPS + + private func createBPS(original: Data, modified: Data, metadata: PatchMetadata) throws -> Data { + var body = Data() + body.append(contentsOf: Array("BPS1".utf8)) + body.append(contentsOf: encodeVLI(original.count)) + body.append(contentsOf: encodeVLI(modified.count)) + + var metaObject: [String: String] = [:] + if let title = metadata.title { metaObject["title"] = title } + if let author = metadata.author { metaObject["author"] = author } + if let version = metadata.version { metaObject["version"] = version } + if let description = metadata.description { metaObject["description"] = description } + let metaData = (try? JSONSerialization.data(withJSONObject: metaObject, options: [])) ?? Data() + body.append(contentsOf: encodeVLI(metaData.count)) + body.append(metaData) + + // Encode only up to the modified (target) size. Extra source bytes when the + // target is shorter are simply ignored — never emit zero-padding TargetReads + // past `modified.count` or BPS apply will reject the patch as corrupt. + var index = 0 + while index < modified.count { + if index < original.count, original[index] == modified[index] { + var length = 0 + while index + length < original.count, + index + length < modified.count, + original[index + length] == modified[index + length] { + length += 1 + } + body.append(contentsOf: encodeVLI(((length - 1) << 2) | 0)) // SourceRead + index += length + } else { + var bytes = [UInt8]() + while index + bytes.count < modified.count { + let i = index + bytes.count + let same = i < original.count && original[i] == modified[i] + if same { break } + bytes.append(modified[i]) + // Cap TargetRead chunks to keep VLI encoding simple / bounded. + if bytes.count >= 1024 { break } + } + guard !bytes.isEmpty else { break } + body.append(contentsOf: encodeVLI(((bytes.count - 1) << 2) | 1)) // TargetRead + body.append(contentsOf: bytes) + index += bytes.count + } + } + + var patch = body + patch.append(contentsOf: encodeLE32(patchCRC32(original))) + patch.append(contentsOf: encodeLE32(patchCRC32(modified))) + patch.append(contentsOf: encodeLE32(patchCRC32(patch))) + return patch + } + + // MARK: - IPS + + private func createIPS(original: Data, modified: Data, isIPS32: Bool) throws -> Data { + var patch = Data(Array("PATCH".utf8)) + let maxOffset = isIPS32 ? 0xFFFF_FFFF : 0xFF_FFFF + // Only encode bytes that exist in the modified ROM. Shrinks are represented + // by the optional truncation trailer after EOF (classic IPS) rather than + // writing zero-padding records past the new end. + var index = 0 + + while index < modified.count { + // Skip matching bytes + while index < modified.count { + let same = index < original.count && original[index] == modified[index] + if !same { break } + index += 1 + } + guard index < modified.count else { break } + guard index <= maxOffset else { + throw PatchError.patchApplicationFailed("ROM too large for \(isIPS32 ? "IPS32" : "IPS") offsets") + } + + let recordStart = index + var bytes = [UInt8]() + while index < modified.count { + let same = index < original.count && original[index] == modified[index] + if same { break } + bytes.append(modified[index]) + index += 1 + if bytes.count >= 0xFFFF { break } + } + + // Prefer RLE when a single byte repeats. + if bytes.count >= 3, Set(bytes).count == 1 { + appendIPSOffset(&patch, offset: recordStart, isIPS32: isIPS32) + patch.append(contentsOf: [0x00, 0x00]) // length 0 => RLE + patch.append(contentsOf: [ + UInt8((bytes.count >> 8) & 0xFF), + UInt8(bytes.count & 0xFF), + bytes[0] + ]) + } else { + // Avoid writing a record whose 3-byte offset looks like "EOF". + if !isIPS32 && recordStart == 0x454F46 { + throw PatchError.patchApplicationFailed("IPS cannot encode a record at offset 0x454F46 (EOF)") + } + appendIPSOffset(&patch, offset: recordStart, isIPS32: isIPS32) + patch.append(contentsOf: [ + UInt8((bytes.count >> 8) & 0xFF), + UInt8(bytes.count & 0xFF) + ]) + patch.append(contentsOf: bytes) + } + } + + patch.append(contentsOf: Array((isIPS32 ? "EEOF" : "EOF").utf8)) + if !isIPS32, modified.count < original.count { + // Optional truncation size after EOF. + let size = modified.count + patch.append(contentsOf: [ + UInt8((size >> 16) & 0xFF), + UInt8((size >> 8) & 0xFF), + UInt8(size & 0xFF) + ]) + } + return patch + } + + private func appendIPSOffset(_ patch: inout Data, offset: Int, isIPS32: Bool) { + if isIPS32 { + patch.append(contentsOf: [ + UInt8((offset >> 24) & 0xFF), + UInt8((offset >> 16) & 0xFF), + UInt8((offset >> 8) & 0xFF), + UInt8(offset & 0xFF) + ]) + } else { + patch.append(contentsOf: [ + UInt8((offset >> 16) & 0xFF), + UInt8((offset >> 8) & 0xFF), + UInt8(offset & 0xFF) + ]) + } + } +} diff --git a/PVPatching/Sources/PVPatching/Patchers/PatcherUtilities.swift b/PVPatching/Sources/PVPatching/Patchers/PatcherUtilities.swift index 5b712f2e45..647fa1571b 100644 --- a/PVPatching/Sources/PVPatching/Patchers/PatcherUtilities.swift +++ b/PVPatching/Sources/PVPatching/Patchers/PatcherUtilities.swift @@ -58,3 +58,29 @@ func patchCRC32(_ data: some DataProtocol) -> UInt32 { } return ~crc } + +/// Encode an integer as a BPS/UPS variable-length integer. +func encodeVLI(_ n: Int) -> [UInt8] { + var bytes: [UInt8] = [] + var value = n + while true { + if value <= 127 { + bytes.append(UInt8(0x80 | value)) + break + } else { + bytes.append(UInt8(value & 0x7F)) + value = (value - 128) >> 7 + } + } + return bytes +} + +/// Write a UInt32 as 4 bytes little-endian. +func encodeLE32(_ value: UInt32) -> [UInt8] { + [ + UInt8(value & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 24) & 0xFF) + ] +} diff --git a/PVPatching/Tests/PVPatchingTests/PatchCreatorTests.swift b/PVPatching/Tests/PVPatchingTests/PatchCreatorTests.swift new file mode 100644 index 0000000000..ea8e2cc7ec --- /dev/null +++ b/PVPatching/Tests/PVPatchingTests/PatchCreatorTests.swift @@ -0,0 +1,203 @@ +// +// PatchCreatorTests.swift +// PVPatchingTests +// + +import Foundation +import XCTest +@testable import PVPatching + +final class PatchCreatorTests: XCTestCase { + func testCreateAndApplyBPSRoundTrip() throws { + let original = Data((0..<256).map { UInt8($0) }) + var modified = original + modified[10] = 0xAA + modified[11] = 0xBB + modified.append(contentsOf: [0x01, 0x02, 0x03]) + + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PatchCreatorBPS-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let originalURL = dir.appendingPathComponent("game.rom") + let modifiedURL = dir.appendingPathComponent("game-hack.rom") + let patchURL = dir.appendingPathComponent("game.bps") + try original.write(to: originalURL) + try modified.write(to: modifiedURL) + + _ = try PatchCreator().create( + originalURL: originalURL, + modifiedURL: modifiedURL, + outputURL: patchURL, + format: .bps, + metadata: PatchMetadata(title: "Test Hack", author: "Provenance") + ) + + let inspection = try PatchInspector().inspect(patchURL: patchURL) + XCTAssertEqual(inspection.format, .bps) + XCTAssertEqual(inspection.title, "Test Hack") + XCTAssertEqual(inspection.author, "Provenance") + XCTAssertEqual(inspection.sourceCRC32, crc32(original)) + XCTAssertEqual(inspection.targetCRC32, crc32(modified)) + + let applied = try BPSPatcher().apply(patch: Data(contentsOf: patchURL), to: original) + XCTAssertEqual(applied, modified) + } + + func testCreateBPSWhenModifiedIsShorterThanOriginal() throws { + let original = Data((0..<64).map { UInt8($0) }) + let modified = Data(original.prefix(40)) // shrink + + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PatchCreatorBPSShrink-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let originalURL = dir.appendingPathComponent("long.rom") + let modifiedURL = dir.appendingPathComponent("short.rom") + let patchURL = dir.appendingPathComponent("shrink.bps") + try original.write(to: originalURL) + try modified.write(to: modifiedURL) + + _ = try PatchCreator().create( + originalURL: originalURL, + modifiedURL: modifiedURL, + outputURL: patchURL, + format: .bps + ) + + let applied = try BPSPatcher().apply(patch: Data(contentsOf: patchURL), to: original) + XCTAssertEqual(applied, modified) + } + + func testCreateAndApplyIPSRoundTrip() throws { + let original = Data(repeating: 0x00, count: 64) + var modified = original + modified[5] = 0x11 + modified[6] = 0x22 + modified[20] = 0x33 + + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PatchCreatorIPS-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let originalURL = dir.appendingPathComponent("cart.bin") + let modifiedURL = dir.appendingPathComponent("cart-mod.bin") + let patchURL = dir.appendingPathComponent("cart.ips") + try original.write(to: originalURL) + try modified.write(to: modifiedURL) + + _ = try PatchCreator().create( + originalURL: originalURL, + modifiedURL: modifiedURL, + outputURL: patchURL, + format: .ips + ) + + let applied = try IPSPatcher().apply(patch: Data(contentsOf: patchURL), to: original) + XCTAssertEqual(applied, modified) + } + + func testApplierReturnsUniqueStemAndCaches() async throws { + let original = Data((0..<128).map { UInt8($0) }) + var modified = original + modified[0] = 0xFF + + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PatchApplierCache-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let romURL = dir.appendingPathComponent("Zelda.nes") + let modURL = dir.appendingPathComponent("Zelda-mod.nes") + let patchURL = dir.appendingPathComponent("Zelda Translation.bps") + try original.write(to: romURL) + try modified.write(to: modURL) + _ = try PatchCreator().create( + originalURL: romURL, + modifiedURL: modURL, + outputURL: patchURL, + format: .bps + ) + + let cacheDir = dir.appendingPathComponent("cache", isDirectory: true) + let applier = PatchApplier(cache: PatchCache(cacheDirectory: cacheDir)) + let first = try await applier.apply(patchURL: patchURL, to: romURL) + XCTAssertFalse(first.wasCached) + XCTAssertTrue(first.url.lastPathComponent.contains("Zelda")) + XCTAssertNotEqual(first.url.deletingPathExtension().lastPathComponent, "patched") + + let second = try await applier.apply(patchURL: patchURL, to: romURL) + XCTAssertTrue(second.wasCached) + XCTAssertEqual(first.url, second.url) + XCTAssertEqual(first.patchedMD5, second.patchedMD5) + } + + func testCreateAndApplyIPS32RoundTrip() throws { + // Force an offset that requires 32-bit IPS encoding (> 0xFFFFFF). + var original = Data(repeating: 0x00, count: 0x1000000 + 16) + var modified = original + modified[0x1000000] = 0x7F + modified[0x1000001] = 0x80 + + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PatchCreatorIPS32-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let originalURL = dir.appendingPathComponent("big.bin") + let modifiedURL = dir.appendingPathComponent("big-mod.bin") + let patchURL = dir.appendingPathComponent("big.ips32") + try original.write(to: originalURL) + try modified.write(to: modifiedURL) + + _ = try PatchCreator().create( + originalURL: originalURL, + modifiedURL: modifiedURL, + outputURL: patchURL, + format: .ips32 + ) + + let applied = try IPSPatcher().apply( + patch: Data(contentsOf: patchURL), + to: original, + isIPS32: true + ) + XCTAssertEqual(applied, modified) + } + + func testInspectorRanksExactCRCMatchHighest() throws { + let original = Data([1, 2, 3, 4, 5, 6, 7, 8]) + var modified = original + modified[2] = 9 + let patch = try PatchCreator().createBPSForTests(original: original, modified: modified) + + let inspection = try PatchInspector().inspect(patchData: patch, format: .bps, fallbackTitle: "Hack") + let candidates = [ + PatchMatchCandidate(id: "a", title: "Wrong", md5: "A", crc32: 0x11111111, filename: "wrong.rom", confidence: .none, score: 0), + PatchMatchCandidate(id: "b", title: "Right", md5: "B", crc32: inspection.sourceCRC32, filename: "right.rom", confidence: .none, score: 0) + ] + let ranked = PatchInspector().rank(candidates: candidates, inspection: inspection, patchFilename: "Hack.bps") + XCTAssertEqual(ranked.first?.id, "b") + XCTAssertEqual(ranked.first?.confidence, .exact) + XCTAssertNotNil(PatchInspector().unambiguousMatch(from: ranked)) + } +} + +private extension PatchCreator { + func createBPSForTests(original: Data, modified: Data) throws -> Data { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("bps-inline-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let o = dir.appendingPathComponent("o.bin") + let m = dir.appendingPathComponent("m.bin") + let p = dir.appendingPathComponent("p.bps") + try original.write(to: o) + try modified.write(to: m) + _ = try create(originalURL: o, modifiedURL: m, outputURL: p, format: .bps) + return try Data(contentsOf: p) + } +} diff --git a/PVPatching/Tests/PVPatchingTests/PatchInspectorAndCacheTests.swift b/PVPatching/Tests/PVPatchingTests/PatchInspectorAndCacheTests.swift new file mode 100644 index 0000000000..44f08cbae0 --- /dev/null +++ b/PVPatching/Tests/PVPatchingTests/PatchInspectorAndCacheTests.swift @@ -0,0 +1,102 @@ +// +// PatchInspectorAndCacheTests.swift +// PVPatchingTests +// + +import Foundation +import XCTest +@testable import PVPatching + +final class PatchInspectorAndCacheTests: XCTestCase { + + func testExportIsCollisionSafe() async throws { + let original = Data((0..<64).map { UInt8($0) }) + var modified = original + modified[1] = 0xEE + + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PatchExport-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let romURL = dir.appendingPathComponent("game.bin") + let modURL = dir.appendingPathComponent("game-mod.bin") + let patchURL = dir.appendingPathComponent("game.bps") + try original.write(to: romURL) + try modified.write(to: modURL) + _ = try PatchCreator().create( + originalURL: romURL, + modifiedURL: modURL, + outputURL: patchURL, + format: .bps + ) + + let cacheDir = dir.appendingPathComponent("cache", isDirectory: true) + let exportDir = dir.appendingPathComponent("out", isDirectory: true) + let applier = PatchApplier(cache: PatchCache(cacheDirectory: cacheDir)) + + let first = try await applier.exportPatchedCopy( + patchURL: patchURL, + romURL: romURL, + to: exportDir, + preferredName: "game [hack].bin" + ) + // Seed a same-named different file to force collision handling. + try Data([0x00]).write(to: first) + + let second = try await applier.exportPatchedCopy( + patchURL: patchURL, + romURL: romURL, + to: exportDir, + preferredName: "game [hack].bin" + ) + XCTAssertNotEqual(first.lastPathComponent, second.lastPathComponent) + XCTAssertTrue(FileManager.default.fileExists(atPath: second.path)) + } + + func testUnambiguousMatchRequiresClearWinner() throws { + let inspection = PatchInspection( + format: .ips, + title: "Hack", + patchSHA256: "abc" + ) + let candidates = [ + PatchMatchCandidate(id: "1", title: "A", md5: "1", filename: "hack.rom", confidence: .none, score: 0), + PatchMatchCandidate(id: "2", title: "B", md5: "2", filename: "hack.rom", confidence: .none, score: 0) + ] + let ranked = PatchInspector().rank( + candidates: candidates, + inspection: inspection, + patchFilename: "Hack.ips" + ) + // Two equally strong filename matches should not auto-link. + XCTAssertNil(PatchInspector().unambiguousMatch(from: ranked)) + } + + func testIdenticalROMsFailCreation() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PatchSame-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let data = Data(repeating: 1, count: 32) + let a = dir.appendingPathComponent("a.bin") + let b = dir.appendingPathComponent("b.bin") + let out = dir.appendingPathComponent("out.bps") + try data.write(to: a) + try data.write(to: b) + + XCTAssertThrowsError( + try PatchCreator().create(originalURL: a, modifiedURL: b, outputURL: out, format: .bps) + ) + } + + func testDefaultOutputStemIsHumanReadable() { + let rom = URL(fileURLWithPath: "/tmp/Zelda.nes") + let patch = URL(fileURLWithPath: "/tmp/French Translation.bps") + let stem = PatchApplier.defaultOutputStem(romURL: rom, patchURL: patch) + XCTAssertTrue(stem.contains("Zelda")) + XCTAssertTrue(stem.contains("French")) + XCTAssertNotEqual(stem, "patched") + } +} diff --git a/PVPatching/Tests/PVPatchingTests/TestPatchEncoding.swift b/PVPatching/Tests/PVPatchingTests/TestPatchEncoding.swift index 78f4dce71e..9f31150be4 100644 --- a/PVPatching/Tests/PVPatchingTests/TestPatchEncoding.swift +++ b/PVPatching/Tests/PVPatchingTests/TestPatchEncoding.swift @@ -10,25 +10,7 @@ import Foundation // MARK: - VLI / CRC helpers -/// Encode an integer as a BPS/UPS variable-length integer. -/// -/// Encoding mirrors the `readVLI` decode algorithm used by the patchers. -/// Single-byte form: `0x80 | value` for value ∈ 0…127. -/// Multi-byte form: emit `value & 0x7F`, subtract 128, shift right 7, repeat. -func encodeVLI(_ n: Int) -> [UInt8] { - var bytes: [UInt8] = [] - var value = n - while true { - if value <= 127 { - bytes.append(UInt8(0x80 | value)) - break - } else { - bytes.append(UInt8(value & 0x7F)) - value = (value - 128) >> 7 - } - } - return bytes -} +// `encodeVLI` lives in PVPatching (PatcherUtilities) and is available via `@testable import`. /// Write a UInt32 as 4 bytes little-endian. func le32(_ value: UInt32) -> [UInt8] { diff --git a/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesView+GameContextMenuDelegate.swift b/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesView+GameContextMenuDelegate.swift index 893cee2524..ed19985d7c 100644 --- a/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesView+GameContextMenuDelegate.swift +++ b/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesView+GameContextMenuDelegate.swift @@ -8,7 +8,9 @@ import SwiftUI import RealmSwift import PVLibrary +import PVFileSystem import PVCoreBridge +import PVRealm import protocol PVUIBase.GameContextMenuDelegate import struct PVUIBase.GameContextMenu import struct PVUIBase.DownloadProgress @@ -309,6 +311,55 @@ extension ConsoleGamesView: GameContextMenuDelegate { } } + func gameContextMenu(_ menu: GameContextMenu, didRequestPlayOriginalFor game: PVGame) { + guard !game.isInvalidated else { return } + SceneCoordinator.shared.launchOriginalGame(game.isFrozen ? game : game.freeze()) + } + + func gameContextMenu(_ menu: GameContextMenu, didRequestPlayPatch patch: PVPatch, for game: PVGame) { + guard !patch.isInvalidated else { return } + SceneCoordinator.shared.launchPatch(patch.isFrozen ? patch : patch.freeze()) + } + + func gameContextMenu(_ menu: GameContextMenu, didRequestImportPatchFor game: PVGame) { + guard !game.isInvalidated else { return } + gamesViewModel.patchImportGame = game.isFrozen ? game : game.freeze() + gamesViewModel.showPatchImportPicker = true + } + + func gameContextMenu(_ menu: GameContextMenu, didRequestManagePatchesFor game: PVGame) { + guard !game.isInvalidated else { return } + gamesViewModel.patchManagerGame = game.isFrozen ? game : game.freeze() + gamesViewModel.showPatchManager = true + } + + @MainActor + func handlePatchImport(urls: [URL], for game: PVGame) async { + guard let fileURL = urls.first else { return } + let accessed = fileURL.startAccessingSecurityScopedResource() + defer { if accessed { fileURL.stopAccessingSecurityScopedResource() } } + do { + let dest = try Self.copyPatchIntoLibrary(fileURL) + _ = try await PatchRepository.shared.importPatch(at: dest, preferredGame: game, autoMatch: false) + rootDelegate?.showMessage("Imported patch for \(game.title).", title: "Patch Imported") + } catch { + rootDelegate?.showMessage("Patch import failed: \(error.localizedDescription)", title: "Import Error") + } + } + + private static func copyPatchIntoLibrary(_ url: URL) throws -> URL { + let dir = Paths.patchesPath + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var dest = dir.appendingPathComponent(url.lastPathComponent) + if FileManager.default.fileExists(atPath: dest.path) { + let stem = dest.deletingPathExtension().lastPathComponent + let ext = dest.pathExtension + dest = dir.appendingPathComponent("\(stem)-\(UUID().uuidString.prefix(8)).\(ext)") + } + try FileManager.default.copyItem(at: url, to: dest) + return dest + } + func gameContextMenu(_ menu: GameContextMenu, didRequestSkinSelectionFor game: PVGame) { DLOG("ConsoleGamesView: Received request to show skin selection for game: \(game.title)") // Use NotificationCenter to communicate with the parent view diff --git a/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesView.swift b/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesView.swift index 5a6bef6158..40b7ffeaaa 100644 --- a/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesView.swift +++ b/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesView.swift @@ -20,6 +20,7 @@ import PVSettings import Combine import RealmSwift import PVWebServer +import UniformTypeIdentifiers struct ConsoleGamesFilterModeFlags: OptionSet { let rawValue: Int @@ -876,7 +877,29 @@ struct ConsoleGamesView: SwiftUI.View { ) } } + .sheet(isPresented: $gamesViewModel.showPatchManager) { + NavigationStack { + if let game = gamesViewModel.patchManagerGame, !game.isInvalidated { + PatchManagerView(gameMD5: game.md5Hash, gameTitle: game.title) + } else { + Text("Game unavailable") + .padding() + } + } + } #if !os(tvOS) + .fileImporter( + isPresented: $gamesViewModel.showPatchImportPicker, + allowedContentTypes: [.data, .item], + allowsMultipleSelection: false + ) { result in + let game = gamesViewModel.patchImportGame + gamesViewModel.patchImportGame = nil + guard let game, case .success(let urls) = result else { return } + Task { @MainActor in + await handlePatchImport(urls: urls, for: game) + } + } .sheet(isPresented: $gamesViewModel.showSaveExportShareSheet, onDismiss: { if let url = gamesViewModel.saveExportURL { SaveExporter.shared.cleanupExport(at: url) diff --git a/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesViewModel.swift b/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesViewModel.swift index 2a4a4c3207..8f2a50f7b1 100644 --- a/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesViewModel.swift +++ b/PVUI/Sources/PVSwiftUI/Consoles/ConsoleGamesViewModel.swift @@ -68,6 +68,13 @@ class ConsoleGamesViewModel: ObservableObject { @Published var showControllerPakSlots = false @Published var controllerPakGame: PVGame? + /// Patch manager / import state + @Published var showPatchManager = false + @Published var patchManagerGame: PVGame? + @Published var showPatchImportPicker = false + @Published var patchImportGame: PVGame? + @Published var patchStatusMessage: String? + /// Save Export share sheet state @Published var showSaveExportShareSheet = false @Published var saveExportURL: URL? = nil @@ -392,7 +399,7 @@ private extension ConsoleGamesViewModel { self.modelsRealm = realm let games = realm.objects(PVGame.self) - .filter("systemIdentifier == %@", self.consoleIdentifier) + .filter("systemIdentifier == %@ AND isHiddenPatchVariant == false", self.consoleIdentifier) self.gamesToken = games.observe(keyPaths: GameCellModel.observedKeyPaths, on: self.modelsQueue) { [weak self] change in autoreleasepool { diff --git a/PVUI/Sources/PVSwiftUI/Home/HomeView.swift b/PVUI/Sources/PVSwiftUI/Home/HomeView.swift index a1653aa955..2bd670fed6 100644 --- a/PVUI/Sources/PVSwiftUI/Home/HomeView.swift +++ b/PVUI/Sources/PVSwiftUI/Home/HomeView.swift @@ -11,10 +11,12 @@ import Foundation import SwiftUI import RealmSwift import PVLibrary +import PVFileSystem import PVThemes import Combine import PVUIBase import PVWebServer +import UniformTypeIdentifiers #if canImport(UIKit) import UIKit #endif @@ -94,6 +96,11 @@ struct HomeView: SwiftUI.View { @State private var discSelectionAlert: DiscSelectionAlert? + @State private var showPatchManager = false + @State private var patchManagerGame: PVGame? + @State private var showPatchImportPicker = false + @State private var patchImportGame: PVGame? + @State private var searchText = "" @@ -583,6 +590,29 @@ struct HomeView: SwiftUI.View { } } ) + .sheet(isPresented: $showPatchManager) { + NavigationStack { + if let game = patchManagerGame, !game.isInvalidated { + PatchManagerView(gameMD5: game.md5Hash, gameTitle: game.title) + } else { + Text("Game unavailable").padding() + } + } + } +#if !os(tvOS) + .fileImporter( + isPresented: $showPatchImportPicker, + allowedContentTypes: [.data, .item], + allowsMultipleSelection: false + ) { result in + let game = patchImportGame + patchImportGame = nil + guard let game, case .success(let urls) = result, let url = urls.first else { return } + Task { @MainActor in + await importPatch(url, for: game) + } + } +#endif .onReceive(LibraryNavigator.shared.$pendingAction) { _ in // Hot-launch / foreground Siri handoff: ConsolesWrapperView switches the // tab in the SAME render pass. Delay the search population by one tab- @@ -1468,6 +1498,18 @@ extension HomeView: GameContextMenuDelegate { presentDiscSelectionAlert(for: game, rootDelegate: rootDelegate) } + func gameContextMenu(_ menu: GameContextMenu, didRequestImportPatchFor game: PVGame) { + guard !game.isInvalidated else { return } + patchImportGame = game.isFrozen ? game : game.freeze() + showPatchImportPicker = true + } + + func gameContextMenu(_ menu: GameContextMenu, didRequestManagePatchesFor game: PVGame) { + guard !game.isInvalidated else { return } + patchManagerGame = game.isFrozen ? game : game.freeze() + showPatchManager = true + } + private func presentDiscSelectionAlert(for game: PVGame, rootDelegate: PVRootDelegate?) { let discs = game.relatedFiles.toArray() let alertDiscs: [DiscSelectionAlert.Disc] = discs.compactMap { (disc: PVFile?) -> DiscSelectionAlert.Disc? in @@ -1483,6 +1525,27 @@ extension HomeView: GameContextMenuDelegate { discs: alertDiscs ) } + + @MainActor + private func importPatch(_ url: URL, for game: PVGame) async { + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + do { + let dir = Paths.patchesPath + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var dest = dir.appendingPathComponent(url.lastPathComponent) + if FileManager.default.fileExists(atPath: dest.path) { + let stem = dest.deletingPathExtension().lastPathComponent + let ext = dest.pathExtension + dest = dir.appendingPathComponent("\(stem)-\(UUID().uuidString.prefix(8)).\(ext)") + } + try FileManager.default.copyItem(at: url, to: dest) + _ = try await PatchRepository.shared.importPatch(at: dest, preferredGame: game, autoMatch: false) + rootDelegate?.showMessage("Imported patch for \(game.title).", title: "Patch Imported") + } catch { + rootDelegate?.showMessage("Patch import failed: \(error.localizedDescription)", title: "Import Error") + } + } } // Add this struct at the end of the file diff --git a/PVUI/Sources/PVSwiftUI/Home/HomeViewModel.swift b/PVUI/Sources/PVSwiftUI/Home/HomeViewModel.swift index 6c4590029c..74260a5eaa 100644 --- a/PVUI/Sources/PVSwiftUI/Home/HomeViewModel.swift +++ b/PVUI/Sources/PVSwiftUI/Home/HomeViewModel.swift @@ -82,8 +82,9 @@ final class HomeViewModel: ObservableObject { let realm = try Realm() self.modelsRealm = realm - // All games + // All games (exclude hidden patch variants until "Add as separate tile") let allGames = realm.objects(PVGame.self) + .filter("isHiddenPatchVariant == false") self.allGamesToken = allGames.observe( keyPaths: GameCellModel.observedKeyPaths, on: self.modelsQueue @@ -103,7 +104,7 @@ final class HomeViewModel: ObservableObject { // Favorites let favorites = realm.objects(PVGame.self) - .filter("isFavorite == true") + .filter("isFavorite == true AND isHiddenPatchVariant == false") self.favoritesToken = favorites.observe( keyPaths: GameCellModel.observedKeyPaths, on: self.modelsQueue @@ -123,7 +124,7 @@ final class HomeViewModel: ObservableObject { // Most played let mostPlayed = realm.objects(PVGame.self) - .filter("playCount > 0") + .filter("playCount > 0 AND isHiddenPatchVariant == false") self.mostPlayedToken = mostPlayed.observe( keyPaths: GameCellModel.observedKeyPaths, on: self.modelsQueue diff --git a/PVUI/Sources/PVSwiftUI/Settings/SettingsSearchIndex+Entries.swift b/PVUI/Sources/PVSwiftUI/Settings/SettingsSearchIndex+Entries.swift index 5aaf524b14..c198c1bf49 100644 --- a/PVUI/Sources/PVSwiftUI/Settings/SettingsSearchIndex+Entries.swift +++ b/PVUI/Sources/PVSwiftUI/Settings/SettingsSearchIndex+Entries.swift @@ -54,6 +54,8 @@ extension SettingsSearchIndex { .general, SettingsSectionTitle.libraryManagement, keywords: ["battery", "sram", "save file", "restore"]), entry("libman.artworkmatcher", "Batch Artwork Matcher", "Find and apply artwork for multiple games at once.", .general, SettingsSectionTitle.libraryManagement, keywords: ["cover", "box art", "images", "metadata"]), + entry("libman.patches", "Patches", "Manage pending patches, create BPS/IPS files, and link unmatched imports.", + .general, SettingsSectionTitle.libraryManagement, keywords: ["ips", "bps", "ups", "rom hack", "translation", "patch"]), entry("libman.autonormalize", "Auto-Normalize Titles on Import", "Strip region/revision tags from ROM filenames (e.g. '(USA)', '[!]') when importing.", .general, SettingsSectionTitle.libraryManagement, keywords: ["rename", "clean", "region", "names"]), entry("libman.unsupportedcores", "Show Unsupported Cores", "Display experimental and unsupported cores.", diff --git a/PVUI/Sources/PVSwiftUI/Settings/SettingsSwiftUI.swift b/PVUI/Sources/PVSwiftUI/Settings/SettingsSwiftUI.swift index 4eeb82729d..6c338f839b 100644 --- a/PVUI/Sources/PVSwiftUI/Settings/SettingsSwiftUI.swift +++ b/PVUI/Sources/PVSwiftUI/Settings/SettingsSwiftUI.swift @@ -2757,6 +2757,15 @@ private struct LibrarySection2: View { .retroFocusButtonStyle(showBorder: false) #endif + NavigationLink(destination: PendingPatchesView()) { + SettingsRow(title: "Patches", + subtitle: "Manage pending patches, create BPS/IPS files, and link unmatched imports.", + icon: .sfSymbol("bandage")) + } + #if os(tvOS) + .retroFocusButtonStyle(showBorder: false) + #endif + ThemedToggle(isOn: $autoNormalizeROMTitles) { SettingsRow(title: "Auto-Normalize Titles on Import", subtitle: "Strip region/revision tags from ROM filenames (e.g. '(USA)', '[!]') when importing.", diff --git a/PVUI/Sources/PVUIBase/Game Library/PVGameLibraryUpdatesController.swift b/PVUI/Sources/PVUIBase/Game Library/PVGameLibraryUpdatesController.swift index 4cb952870d..813cf8d195 100644 --- a/PVUI/Sources/PVUIBase/Game Library/PVGameLibraryUpdatesController.swift +++ b/PVUI/Sources/PVUIBase/Game Library/PVGameLibraryUpdatesController.swift @@ -519,7 +519,8 @@ public final class PVGameLibraryUpdatesController: ObservableObject { do { let frozenGames = try await RealmContext.withBackgroundRealm { realm -> [PVGame] in - let allGames = realm.objects(PVGame.self) + // Hidden patch variants are launch-only; keep them out of Spotlight. + let allGames = realm.objects(PVGame.self).filter("isHiddenPatchVariant == false") ILOG("Found \(allGames.count) games to process") return allGames.map { $0.freeze() } } diff --git a/PVUI/Sources/PVUIBase/State Management/SceneCoordinator.swift b/PVUI/Sources/PVUIBase/State Management/SceneCoordinator.swift index 0a5aaed2c2..4de18f66e8 100644 --- a/PVUI/Sources/PVUIBase/State Management/SceneCoordinator.swift +++ b/PVUI/Sources/PVUIBase/State Management/SceneCoordinator.swift @@ -271,15 +271,39 @@ public class SceneCoordinator: ObservableObject { /// Launch a specific game with error handling and sync validation public func launchGame(_ game: PVGame) { - if deferLaunchIfBusy({ [weak self] in self?.launchGame(game) }) { return } - ILOG("SceneCoordinator: Launching game: \(game.title) (ID: \(game.id))") + launchGame(game, core: nil, bypassDefaultPatch: false) + } + + /// Always launch the original ROM, ignoring any default patch. + public func launchOriginalGame(_ game: PVGame, core: PVCore? = nil) { + launchGame(game, core: core, bypassDefaultPatch: true) + } + + /// Prepare and launch a matched patch variant with isolated identity. + public func launchPatch(_ patch: PVPatch, core: PVCore? = nil) { + if deferLaunchIfBusy({ [weak self] in self?.launchPatch(patch, core: core) }) { return } + ILOG("SceneCoordinator: Launching patch: \(patch.displayTitle)") + + let launchID = launchGate.begin() + let frozenPatch = patch.isFrozen ? patch : patch.freeze() + let frozenCore = core?.isFrozen == true ? core : core?.freeze() + activeLaunchTask = Task { @MainActor [weak self] in + defer { self?.finishLaunch(launchID) } + await self?.launchPatchWithPreparation(frozenPatch, core: frozenCore) + } + scheduleActiveLaunchTimeout() + } + + /// Launch a game with optional core and optional default-patch bypass. + public func launchGame(_ game: PVGame, core: PVCore?, bypassDefaultPatch: Bool) { + if deferLaunchIfBusy({ [weak self] in self?.launchGame(game, core: core, bypassDefaultPatch: bypassDefaultPatch) }) { return } + ILOG("SceneCoordinator: Launching game: \(game.title) (ID: \(game.id)) bypassDefaultPatch=\(bypassDefaultPatch)") let launchID = launchGate.begin() activeLaunchTask = Task { @MainActor [weak self] in defer { self?.finishLaunch(launchID) } - await self?.launchGameWithValidation(game) + await self?.launchGameWithValidation(game, core: core, bypassDefaultPatch: bypassDefaultPatch) } - // Safety net: if the launch hangs, clear the task so taps aren't permanently blocked scheduleActiveLaunchTimeout() } @@ -306,15 +330,7 @@ public class SceneCoordinator: ObservableObject { /// Launch a game with optional core (bypasses core selection if core is provided) public func launchGame(_ game: PVGame, core: PVCore?) { - if deferLaunchIfBusy({ [weak self] in self?.launchGame(game, core: core) }) { return } - ILOG("SceneCoordinator: Launching game: \(game.title) (ID: \(game.id)) with core: \(core?.projectName ?? "auto")") - - let launchID = launchGate.begin() - activeLaunchTask = Task { @MainActor [weak self] in - defer { self?.finishLaunch(launchID) } - await self?.launchGameWithValidation(game, core: core) - } - scheduleActiveLaunchTimeout() + launchGame(game, core: core, bypassDefaultPatch: false) } /// Launch a game with disc path (for multi-disc games) @@ -335,12 +351,30 @@ public class SceneCoordinator: ObservableObject { } /// Launch game with sync validation - private func launchGameWithValidation(_ game: PVGame, core: PVCore? = nil) async { + private func launchGameWithValidation(_ game: PVGame, core: PVCore? = nil, bypassDefaultPatch: Bool = false) async { // Pause background sync so on-demand downloads get full CloudKit bandwidth // and background BIOS processing doesn't interfere with targeted downloads. CloudSyncManager.shared.pause(reason: .gameLaunch) defer { CloudSyncManager.shared.resume(reason: .gameLaunch) } + // Default patch redirect — only for base games, never for already-patched variants. + if !bypassDefaultPatch, !game.isPatchVariant, + let defaultPatch = PatchRepository.shared.defaultPatch(forGameMD5: game.md5Hash) { + ILOG("SceneCoordinator: Redirecting launch to default patch \(defaultPatch.displayTitle)") + await launchPatchWithPreparation(defaultPatch, core: core) + return + } + + // Visible variant tiles: always re-enter patch prep so a purged PatchedROMs + // cache is rebuilt. `bypassDefaultPatch` means prep already ran (avoids a loop). + if !bypassDefaultPatch, game.isPatchVariant, + let patchID = game.patchID, !patchID.isEmpty, + let patch = RomDatabase.sharedInstance.realm.object(ofType: PVPatch.self, forPrimaryKey: patchID) { + ILOG("SceneCoordinator: Preparing managed patch variant \(patch.displayTitle) before launch") + await launchPatchWithPreparation(patch, core: core) + return + } + guard let system = game.system else { showGameLaunchError( title: "Cannot Launch Game", @@ -375,7 +409,12 @@ public class SceneCoordinator: ObservableObject { FileManager.default.fileExists(atPath: fileURL.path) { return true } - // Check 3: current system ROMs directory (file may have been moved between systems) + // Check 3: system ROMs directory by filename — never for patch variants. + // A missing PatchedROMs artifact must not silently open (or rewrite paths to) + // the original ROM when stems collide. + if game.isPatchVariant { + return false + } let filename = game.file?.fileName ?? (game.romPath.isEmpty ? nil : URL(fileURLWithPath: game.romPath).lastPathComponent) if let filename = filename, !filename.isEmpty { @@ -1207,6 +1246,41 @@ public class SceneCoordinator: ObservableObject { CloudSyncManager.shared.pause(reason: .gameLaunch) defer { CloudSyncManager.shared.resume(reason: .gameLaunch) } + // Rebuild patched artifacts before save-state launch when needed. + var game = game + if game.isPatchVariant, + let patchID = game.patchID, !patchID.isEmpty, + let patch = RomDatabase.sharedInstance.realm.object(ofType: PVPatch.self, forPrimaryKey: patchID) { + do { + syncStatusManager.show( + gameTitle: patch.displayTitle, + statusMessage: "Preparing patched ROM…", + onCancel: { [weak self] in + self?.activeLaunchTask?.cancel() + self?.syncStatusManager.hide() + } + ) + game = try await PatchRepository.shared.prepareForLaunch(patch: patch) { [weak self] progress in + Task { @MainActor in + let message = progress.status.isEmpty + ? String(format: "Patching… %.0f%%", progress.fractionCompleted * 100) + : progress.status + self?.syncStatusManager.update(statusMessage: message) + } + } + syncStatusManager.hide() + guard !Task.isCancelled else { return } + } catch { + syncStatusManager.hide() + ELOG("SceneCoordinator: Patch prep for save state failed — \(error.localizedDescription)") + showGameLaunchError( + title: "Cannot Apply Patch", + message: error.localizedDescription + ) + return + } + } + guard let system = game.system else { showGameLaunchError( title: "Cannot Launch Save State", @@ -1228,7 +1302,10 @@ public class SceneCoordinator: ObservableObject { FileManager.default.fileExists(atPath: fileURL.path) { return true } - // Check 3: current system ROMs directory (file may have been moved between systems) + // Check 3: system ROMs by filename — never for patch variants. + if game.isPatchVariant { + return false + } let filename = game.file?.fileName ?? (game.romPath.isEmpty ? nil : URL(fileURLWithPath: game.romPath).lastPathComponent) if let filename = filename, !filename.isEmpty { @@ -1613,6 +1690,43 @@ public class SceneCoordinator: ObservableObject { /// `actionButtonTitle`/`action` let a caller attach one recovery affordance /// (e.g. "BIOS Guide") without every error path having to know about the /// alert plumbing. + /// Apply/reuse the patch cache off the critical path, then launch the managed variant. + private func launchPatchWithPreparation(_ patch: PVPatch, core: PVCore?) async { + syncStatusManager.show( + gameTitle: patch.displayTitle, + statusMessage: "Preparing patched ROM…", + onCancel: { [weak self] in + self?.activeLaunchTask?.cancel() + self?.syncStatusManager.hide() + } + ) + + do { + let variant = try await PatchRepository.shared.prepareForLaunch(patch: patch) { [weak self] progress in + Task { @MainActor in + let message = progress.status.isEmpty + ? String(format: "Patching… %.0f%%", progress.fractionCompleted * 100) + : progress.status + self?.syncStatusManager.update(statusMessage: message) + } + } + guard !Task.isCancelled else { + syncStatusManager.hide() + return + } + syncStatusManager.hide() + // Launch the prepared variant with isolated identity (saves/cheats/achievements). + await launchGameWithValidation(variant, core: core, bypassDefaultPatch: true) + } catch { + syncStatusManager.hide() + ELOG("SceneCoordinator: Patch launch failed — \(error.localizedDescription)") + showGameLaunchError( + title: "Cannot Apply Patch", + message: error.localizedDescription + ) + } + } + private func showGameLaunchError(title: String, message: String, actionButtonTitle: String? = nil, diff --git a/PVUI/Sources/PVUIBase/SwiftUI/Components/GameContextMenu.swift b/PVUI/Sources/PVUIBase/SwiftUI/Components/GameContextMenu.swift index 0eec4ff1cf..9ee6ccf5fa 100644 --- a/PVUI/Sources/PVUIBase/SwiftUI/Components/GameContextMenu.swift +++ b/PVUI/Sources/PVUIBase/SwiftUI/Components/GameContextMenu.swift @@ -143,6 +143,50 @@ private struct GameContextMenuItems: View { } } + // Patches submenu — modeled after disc selection; base games only. + if !game.isPatchVariant { + let linkedPatches = Array( + PatchRepository.shared.patches(forGameMD5: game.md5Hash) + .map { $0.isFrozen ? $0 : $0.freeze() } + ) + Menu { + Button { + contextMenuDelegate?.gameContextMenu(menu, didRequestPlayOriginalFor: game) + } label: { + Label("Play Original", systemImage: "play.circle") + } + if !linkedPatches.isEmpty { + Divider() + ForEach(linkedPatches, id: \.id) { patch in + Button { + contextMenuDelegate?.gameContextMenu(menu, didRequestPlayPatch: patch, for: game) + } label: { + Label( + patch.isDefaultLaunch ? "\(patch.displayTitle) ★" : patch.displayTitle, + systemImage: "bandage" + ) + } + } + Divider() + } + Button { + contextMenuDelegate?.gameContextMenu(menu, didRequestImportPatchFor: game) + } label: { + Label("Import Patch…", systemImage: "square.and.arrow.down") + } + Button { + contextMenuDelegate?.gameContextMenu(menu, didRequestManagePatchesFor: game) + } label: { + Label("Manage Patches…", systemImage: "list.bullet.rectangle") + } + } label: { + Label( + linkedPatches.isEmpty ? "Patches" : "Patches (\(linkedPatches.count))", + systemImage: "bandage" + ) + } + } + // Transfer Pak configuration — only shown for N64 games that are known to // support the Transfer Pak accessory (e.g. Pokémon Stadium, Mario Tennis). // Showing the option for all N64 games is confusing because most titles diff --git a/PVUI/Sources/PVUIBase/SwiftUI/Components/GameContextMenuDelegate.swift b/PVUI/Sources/PVUIBase/SwiftUI/Components/GameContextMenuDelegate.swift index fb52a5c5ad..46f94ac2a4 100644 --- a/PVUI/Sources/PVUIBase/SwiftUI/Components/GameContextMenuDelegate.swift +++ b/PVUI/Sources/PVUIBase/SwiftUI/Components/GameContextMenuDelegate.swift @@ -22,6 +22,10 @@ public protocol GameContextMenuDelegate { func gameContextMenu(_ menu: GameContextMenu, didRequestShowArtworkSearchFor game: PVGame) func gameContextMenu(_ menu: GameContextMenu, didRequestChooseArtworkSourceFor game: PVGame) func gameContextMenu(_ menu: GameContextMenu, didRequestDiscSelectionFor game: PVGame) + func gameContextMenu(_ menu: GameContextMenu, didRequestPlayOriginalFor game: PVGame) + func gameContextMenu(_ menu: GameContextMenu, didRequestPlayPatch patch: PVPatch, for game: PVGame) + func gameContextMenu(_ menu: GameContextMenu, didRequestImportPatchFor game: PVGame) + func gameContextMenu(_ menu: GameContextMenu, didRequestManagePatchesFor game: PVGame) func gameContextMenu(_ menu: GameContextMenu, didRequestDownloadFromCloudFor game: PVGame) func gameContextMenu(_ menu: GameContextMenu, didRequestSkinSelectionFor game: PVGame) func gameContextMenu(_ menu: GameContextMenu, didRequestResetSkinFor game: PVGame) @@ -74,6 +78,22 @@ public extension GameContextMenuDelegate { DLOG("Default implementation: didRequestDiscSelectionFor not implemented") } + func gameContextMenu(_ menu: GameContextMenu, didRequestPlayOriginalFor game: PVGame) { + SceneCoordinator.shared.launchOriginalGame(game) + } + + func gameContextMenu(_ menu: GameContextMenu, didRequestPlayPatch patch: PVPatch, for game: PVGame) { + SceneCoordinator.shared.launchPatch(patch) + } + + func gameContextMenu(_ menu: GameContextMenu, didRequestImportPatchFor game: PVGame) { + DLOG("Default implementation: didRequestImportPatchFor not implemented") + } + + func gameContextMenu(_ menu: GameContextMenu, didRequestManagePatchesFor game: PVGame) { + DLOG("Default implementation: didRequestManagePatchesFor not implemented") + } + func gameContextMenu(_ menu: GameContextMenu, didRequestDownloadFromCloudFor game: PVGame) { DLOG("Default implementation: didRequestDownloadFromCloudFor not implemented") } diff --git a/PVUI/Sources/PVUIBase/SwiftUI/Components/Patches/CreatePatchView.swift b/PVUI/Sources/PVUIBase/SwiftUI/Components/Patches/CreatePatchView.swift new file mode 100644 index 0000000000..66970285ee --- /dev/null +++ b/PVUI/Sources/PVUIBase/SwiftUI/Components/Patches/CreatePatchView.swift @@ -0,0 +1,192 @@ +// +// CreatePatchView.swift +// PVUI +// +// Created by Provenance Emu on 2026-10-10. +// Copyright © 2026 Provenance Emu. All rights reserved. +// + +import SwiftUI +import UniformTypeIdentifiers +import PVLibrary +import PVLogging +import PVPatching +import PVFileSystem +import PVRealm + +/// Create a BPS or IPS/IPS32 patch from an original + modified ROM pair. +/// Never modifies either input. Optionally imports/links the result. +public struct CreatePatchView: View { + private enum PickerTarget { + case original + case modified + } + + @State private var originalURL: URL? + @State private var modifiedURL: URL? + @State private var format: PatchCreator.CreationFormat = .bps + @State private var titleText = "" + @State private var authorText = "" + @State private var importAfterCreate = true + @State private var linkGameMD5: String? + @State private var statusMessage: String? + @State private var isBusy = false + @State private var showFilePicker = false + @State private var pickerTarget: PickerTarget = .original + @State private var games: [PVGame] = [] + + public init() {} + + public var body: some View { + Form { + Section("Source ROMs") { + Button(originalURL?.lastPathComponent ?? "Choose Original ROM…") { + pickerTarget = .original + showFilePicker = true + } + Button(modifiedURL?.lastPathComponent ?? "Choose Modified ROM…") { + pickerTarget = .modified + showFilePicker = true + } + } + + Section("Format") { + Picker("Patch Format", selection: $format) { + ForEach(PatchCreator.CreationFormat.allCases) { item in + Text(item.displayName).tag(item) + } + } +#if !os(tvOS) + .pickerStyle(.segmented) +#endif + } + + Section("Metadata") { + TextField("Title", text: $titleText) + TextField("Author", text: $authorText) + } + + Section("After Create") { + Toggle("Import into library", isOn: $importAfterCreate) + if importAfterCreate { + Picker("Link to game (optional)", selection: Binding( + get: { linkGameMD5 ?? "" }, + set: { linkGameMD5 = $0.isEmpty ? nil : $0 } + )) { + Text("Auto-match").tag("") + ForEach(games, id: \.md5Hash) { game in + Text(game.title).tag(game.md5Hash) + } + } + } + } + + if let statusMessage { + Section { + Text(statusMessage) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Section { + Button { + create() + } label: { + if isBusy { + ProgressView() + } else { + Text("Create Patch") + } + } + .disabled(isBusy || originalURL == nil || modifiedURL == nil) + } + } + .navigationTitle("Create Patch") +#if !os(tvOS) + .navigationBarTitleDisplayMode(.inline) + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.data, .item], + allowsMultipleSelection: false + ) { result in + guard case .success(let urls) = result, let url = urls.first else { return } + switch pickerTarget { + case .original: originalURL = url + case .modified: modifiedURL = url + } + } +#endif + .onAppear { + let realm = RomDatabase.sharedInstance.realm + games = realm.objects(PVGame.self) + .filter("isPatchVariant == false AND contentless == false") + .sorted(byKeyPath: "title", ascending: true) + .prefix(500) + .map { $0.isFrozen ? $0 : $0.freeze() } + } + } + + private func create() { + guard let originalURL, let modifiedURL else { return } + isBusy = true + statusMessage = "Creating…" + let format = self.format + let titleText = self.titleText + let authorText = self.authorText + let importAfterCreate = self.importAfterCreate + let linkGameMD5 = self.linkGameMD5 + + Task { + let accessedO = originalURL.startAccessingSecurityScopedResource() + let accessedM = modifiedURL.startAccessingSecurityScopedResource() + defer { + if accessedO { originalURL.stopAccessingSecurityScopedResource() } + if accessedM { modifiedURL.stopAccessingSecurityScopedResource() } + } + + do { + let patchesDir = Paths.patchesPath + try FileManager.default.createDirectory(at: patchesDir, withIntermediateDirectories: true) + let stem = titleText.isEmpty + ? modifiedURL.deletingPathExtension().lastPathComponent + : titleText + let output = patchesDir.appendingPathComponent("\(stem).\(format.fileExtension)") + let metadata = PatchMetadata( + title: titleText.isEmpty ? nil : titleText, + author: authorText.isEmpty ? nil : authorText + ) + + // Encoding can be CPU-heavy — keep it off the main actor. + let created = try await Task.detached(priority: .userInitiated) { + try PatchCreator().create( + originalURL: originalURL, + modifiedURL: modifiedURL, + outputURL: output, + format: format, + metadata: metadata, + verifyRoundTrip: true + ) + }.value + + if importAfterCreate { + let preferred: PVGame? = { + guard let linkGameMD5 else { return nil } + return RomDatabase.sharedInstance.realm.object(ofType: PVGame.self, forPrimaryKey: linkGameMD5) + }() + _ = try await PatchRepository.shared.importPatch( + at: created, + preferredGame: preferred, + autoMatch: preferred == nil + ) + statusMessage = "Created and imported \(created.lastPathComponent)" + } else { + statusMessage = "Created \(created.lastPathComponent)" + } + } catch { + statusMessage = error.localizedDescription + } + isBusy = false + } + } +} diff --git a/PVUI/Sources/PVUIBase/SwiftUI/Components/Patches/PatchManagerView.swift b/PVUI/Sources/PVUIBase/SwiftUI/Components/Patches/PatchManagerView.swift new file mode 100644 index 0000000000..9a542d073a --- /dev/null +++ b/PVUI/Sources/PVUIBase/SwiftUI/Components/Patches/PatchManagerView.swift @@ -0,0 +1,226 @@ +// +// PatchManagerView.swift +// PVUI +// +// Created by Provenance Emu on 2026-10-10. +// Copyright © 2026 Provenance Emu. All rights reserved. +// + +import SwiftUI +import RealmSwift +import PVLibrary +import PVRealm +import PVLogging +import PVPatching + +/// Per-game patch manager: list, play, default, tile visibility, export, reapply, delete. +public struct PatchManagerView: View { + let gameMD5: String + let gameTitle: String + + @State private var patches: [PVPatch] = [] + @State private var statusMessage: String? + @State private var isBusy = false + @State private var exportURL: URL? + @State private var showExporter = false + @Environment(\.dismiss) private var dismiss + + public init(gameMD5: String, gameTitle: String) { + self.gameMD5 = gameMD5 + self.gameTitle = gameTitle + } + + public var body: some View { + List { + if let statusMessage { + Section { + Text(statusMessage) + .foregroundStyle(.secondary) + .font(.footnote) + } + } + + if patches.isEmpty { + Section { + Text("No patches linked to this game yet. Import a patch from the game menu or ROM Tools.") + .foregroundStyle(.secondary) + } + } else { + Section("Installed Patches") { + ForEach(patches, id: \.id) { patch in + PatchRowView( + patch: patch, + isBusy: isBusy, + onPlay: { play(patch) }, + onToggleDefault: { toggleDefault(patch) }, + onToggleTile: { toggleTile(patch) }, + onReapply: { reapply(patch) }, + onExport: { export(patch) }, + onDelete: { delete(patch) } + ) + } + } + } + } + .navigationTitle("Patches") +#if !os(tvOS) + .navigationBarTitleDisplayMode(.inline) +#endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .onAppear(perform: reload) + .disabled(isBusy) +#if !os(tvOS) + .sheet(isPresented: $showExporter) { + if let exportURL { + ActivityViewController(activityItems: [exportURL]) + } + } +#endif + } + + private func reload() { + let results = PatchRepository.shared.patches(forGameMD5: gameMD5) + patches = results.map { $0.isFrozen ? $0 : $0.freeze() } + } + + private func play(_ patch: PVPatch) { + dismiss() + SceneCoordinator.shared.launchPatch(patch) + } + + private func toggleDefault(_ patch: PVPatch) { + let enabling = !patch.isDefaultLaunch + do { + try PatchRepository.shared.setDefaultLaunch(enabling, for: patch) + statusMessage = enabling + ? "\(patch.displayTitle) set as default launch." + : "Cleared default patch." + reload() + } catch { + statusMessage = error.localizedDescription + } + } + + private func toggleTile(_ patch: PVPatch) { + let showing = !patch.showAsLibraryTile + do { + try PatchRepository.shared.setShowAsLibraryTile(showing, for: patch) + statusMessage = showing + ? "Added separate library tile." + : "Removed separate library tile." + reload() + } catch { + statusMessage = error.localizedDescription + } + } + + private func reapply(_ patch: PVPatch) { + isBusy = true + statusMessage = "Reapplying…" + Task { + do { + _ = try await PatchRepository.shared.reapply(patch) + statusMessage = "Cache rebuilt." + reload() + } catch { + statusMessage = error.localizedDescription + } + isBusy = false + } + } + + private func export(_ patch: PVPatch) { + isBusy = true + statusMessage = "Creating patched copy…" + Task { + do { + let url = try await PatchRepository.shared.exportPatchedCopy(patch: patch) + exportURL = url + statusMessage = "Exported to \(url.lastPathComponent)" +#if !os(tvOS) + showExporter = true +#endif + } catch { + statusMessage = error.localizedDescription + } + isBusy = false + } + } + + private func delete(_ patch: PVPatch) { + do { + try PatchRepository.shared.deletePatch(patch) + statusMessage = "Patch deleted." + reload() + } catch { + statusMessage = error.localizedDescription + } + } +} + +private struct PatchRowView: View { + let patch: PVPatch + let isBusy: Bool + let onPlay: () -> Void + let onToggleDefault: () -> Void + let onToggleTile: () -> Void + let onReapply: () -> Void + let onExport: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(patch.displayTitle) + .font(.headline) + Text(metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if patch.isDefaultLaunch { + Label("Default", systemImage: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + } + + HStack(spacing: 12) { + Button("Play", action: onPlay) + Button(patch.isDefaultLaunch ? "Clear Default" : "Set Default", action: onToggleDefault) + Button(patch.showAsLibraryTile ? "Hide Tile" : "Add Tile", action: onToggleTile) + } + .buttonStyle(.bordered) + .disabled(isBusy) + + HStack(spacing: 12) { + Button("Reapply", action: onReapply) + Button("Export Copy", action: onExport) + Button("Delete", role: .destructive, action: onDelete) + } + .buttonStyle(.bordered) + .disabled(isBusy) + } + .padding(.vertical, 4) + } + + private var metadataLine: String { + var parts: [String] = [] + if let format = patch.format { + parts.append(format.displayName) + } + parts.append(patch.matchState.rawValue.capitalized) + if patch.matchScore > 0 { + parts.append(String(format: "score %.2f", patch.matchScore)) + } + if let author = patch.author, !author.isEmpty { + parts.append(author) + } + return parts.joined(separator: " · ") + } +} diff --git a/PVUI/Sources/PVUIBase/SwiftUI/Components/Patches/PendingPatchesView.swift b/PVUI/Sources/PVUIBase/SwiftUI/Components/Patches/PendingPatchesView.swift new file mode 100644 index 0000000000..d894921a6b --- /dev/null +++ b/PVUI/Sources/PVUIBase/SwiftUI/Components/Patches/PendingPatchesView.swift @@ -0,0 +1,282 @@ +// +// PendingPatchesView.swift +// PVUI +// +// Created by Provenance Emu on 2026-10-10. +// Copyright © 2026 Provenance Emu. All rights reserved. +// + +import SwiftUI +import RealmSwift +import PVLibrary +import PVRealm +import PVLogging +import PVPatching +import PVFileSystem +import UniformTypeIdentifiers + +/// Global ROM Tools screen for unmatched / pending patches and patch creation entry. +public struct PendingPatchesView: View { + @State private var pending: [PVPatch] = [] + @State private var allPatches: [PVPatch] = [] + @State private var games: [PVGame] = [] + @State private var selectedPatch: PVPatch? + @State private var statusMessage: String? + @State private var showGamePicker = false + @State private var showImporter = false + @State private var isBusy = false + + public init() {} + + public var body: some View { + List { + if let statusMessage { + Section { + Text(statusMessage) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Section { + Button { + showImporter = true + } label: { + Label("Import Patch File…", systemImage: "square.and.arrow.down") + } + NavigationLink { + CreatePatchView() + } label: { + Label("Create Patch (BPS / IPS)…", systemImage: "doc.badge.plus") + } + Button { + rematchAll() + } label: { + Label("Retry Auto-Match", systemImage: "arrow.triangle.2.circlepath") + } + .disabled(isBusy || pending.isEmpty) + } + + Section("Pending / Unmatched") { + if pending.isEmpty { + Text("No pending patches.") + .foregroundStyle(.secondary) + } else { + ForEach(pending, id: \.id) { patch in + VStack(alignment: .leading, spacing: 6) { + Text(patch.displayTitle) + .font(.headline) + Text(subtitle(for: patch)) + .font(.caption) + .foregroundStyle(.secondary) + HStack { + Button("Link to Game…") { + selectedPatch = patch + showGamePicker = true + } + .buttonStyle(.bordered) + Button("Delete", role: .destructive) { + delete(patch) + } + .buttonStyle(.bordered) + } + } + .padding(.vertical, 4) + } + } + } + + Section("All Patches") { + if allPatches.isEmpty { + Text("No patches imported yet.") + .foregroundStyle(.secondary) + } else { + ForEach(allPatches, id: \.id) { patch in + NavigationLink { + if let md5 = patch.baseGameMD5 ?? patch.game?.md5Hash { + PatchManagerView( + gameMD5: md5, + gameTitle: patch.game?.title ?? patch.displayTitle + ) + } else { + Text("This patch is not linked to a game.") + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(patch.displayTitle) + Text(subtitle(for: patch)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + .navigationTitle("Patches") +#if !os(tvOS) + .navigationBarTitleDisplayMode(.inline) + .fileImporter( + isPresented: $showImporter, + allowedContentTypes: Self.patchTypes, + allowsMultipleSelection: true + ) { result in + handleImport(result) + } +#endif + .sheet(isPresented: $showGamePicker) { + PatchGamePickerView(games: games) { game in + guard let selectedPatch else { return } + link(selectedPatch, to: game) + } + } + .onAppear(perform: reload) + } + + private static var patchTypes: [UTType] { + var types: [UTType] = [.data, .item] + for ext in ["ips", "bps", "ups", "ppf", "ips32"] { + if let type = UTType(filenameExtension: ext) { + types.append(type) + } + } + return types + } + + private func subtitle(for patch: PVPatch) -> String { + var parts: [String] = [] + if let format = patch.format { parts.append(format.displayName) } + parts.append(patch.matchState.rawValue.capitalized) + if let gameTitle = patch.game?.title { + parts.append(gameTitle) + } else if let md5 = patch.baseGameMD5 { + parts.append("MD5 \(md5.prefix(8))…") + } + return parts.joined(separator: " · ") + } + + private func reload() { + let realm = RomDatabase.sharedInstance.realm + pending = PatchRepository.shared.pendingPatches().map { $0.isFrozen ? $0 : $0.freeze() } + allPatches = realm.objects(PVPatch.self) + .sorted(byKeyPath: "date", ascending: false) + .map { $0.isFrozen ? $0 : $0.freeze() } + games = realm.objects(PVGame.self) + .filter("isPatchVariant == false AND contentless == false") + .sorted(byKeyPath: "title", ascending: true) + .map { $0.isFrozen ? $0 : $0.freeze() } + } + + private func rematchAll() { + isBusy = true + statusMessage = "Matching…" + Task { + await PatchRepository.shared.rematchPendingPatches() + statusMessage = "Auto-match finished." + reload() + isBusy = false + } + } + + private func link(_ patch: PVPatch, to game: PVGame) { + isBusy = true + Task { + do { + try await PatchRepository.shared.link(patch, to: game) + statusMessage = "Linked \(patch.displayTitle) → \(game.title)" + reload() + } catch { + statusMessage = error.localizedDescription + } + isBusy = false + showGamePicker = false + selectedPatch = nil + } + } + + private func delete(_ patch: PVPatch) { + do { + try PatchRepository.shared.deletePatch(patch) + statusMessage = "Deleted \(patch.displayTitle)" + reload() + } catch { + statusMessage = error.localizedDescription + } + } + + private func handleImport(_ result: Result<[URL], Error>) { + switch result { + case .failure(let error): + statusMessage = error.localizedDescription + case .success(let urls): + isBusy = true + Task { + for url in urls { + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + do { + let dest = try copyIntoPatchesDirectory(url) + _ = try await PatchRepository.shared.importPatch(at: dest) + } catch { + statusMessage = error.localizedDescription + } + } + statusMessage = "Import finished." + reload() + isBusy = false + } + } + } + + private func copyIntoPatchesDirectory(_ url: URL) throws -> URL { + let dir = Paths.patchesPath + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var dest = dir.appendingPathComponent(url.lastPathComponent) + if FileManager.default.fileExists(atPath: dest.path) { + let stem = dest.deletingPathExtension().lastPathComponent + let ext = dest.pathExtension + dest = dir.appendingPathComponent("\(stem)-\(UUID().uuidString.prefix(8)).\(ext)") + } + try FileManager.default.copyItem(at: url, to: dest) + return dest + } +} + +private struct PatchGamePickerView: View { + let games: [PVGame] + let onSelect: (PVGame) -> Void + @Environment(\.dismiss) private var dismiss + @State private var query = "" + + var body: some View { + NavigationStack { + List(filtered, id: \.md5Hash) { game in + Button { + onSelect(game) + dismiss() + } label: { + VStack(alignment: .leading) { + Text(game.title) + Text(game.systemIdentifier) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Choose Base Game") +#if !os(tvOS) + .searchable(text: $query) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } +#endif + } + } + + private var filtered: [PVGame] { + guard !query.isEmpty else { return games } + return games.filter { $0.title.localizedCaseInsensitiveContains(query) } + } +} diff --git a/PVUI/Sources/PVUIBase/SwiftUI/Components/RetroWave/RetroGameLibraryView.swift b/PVUI/Sources/PVUIBase/SwiftUI/Components/RetroWave/RetroGameLibraryView.swift index bd2585e5db..ada2cb1587 100644 --- a/PVUI/Sources/PVUIBase/SwiftUI/Components/RetroWave/RetroGameLibraryView.swift +++ b/PVUI/Sources/PVUIBase/SwiftUI/Components/RetroWave/RetroGameLibraryView.swift @@ -817,16 +817,18 @@ public struct RetroGameLibraryView: View { } } - /// Function to refresh the import queue items - // Import queue refresh is now handled by ImportProgressView +} + +// MARK: - Import + Launch + Data Refresh +extension RetroGameLibraryView { /// Shows import options alert with document picker and web server options - private func showImportOptions() { + fileprivate func showImportOptions() { showingImportOptionsAlert = true } /// Starts the web server for importing files - private func startWebServer() { + fileprivate func startWebServer() { #if canImport(PVWebServer) ILOG("RetroGameLibraryView: Starting web server for imports") Task { @MainActor in @@ -855,7 +857,7 @@ public struct RetroGameLibraryView: View { #endif } - private func importFiles(urls: [URL]) { + fileprivate func importFiles(urls: [URL]) { ILOG("RetroGameLibraryView: Importing \(urls.count) files") guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { @@ -912,21 +914,19 @@ public struct RetroGameLibraryView: View { } // Launch game - private func launchGame(_ game: PVGame) { + fileprivate func launchGame(_ game: PVGame) { ILOG("RetroGameLibraryView: Launching game: \(game.title) (ID: \(game.id))") // Use the SceneCoordinator to launch the game sceneCoordinator.launchGame(game) } - // MARK: - Data Refresh - /// Debounced entry-point for data refreshes. /// /// - Parameter debounce: Seconds to wait before issuing the actual refresh. /// Pass `0` for an immediate (but still async) refresh. /// Pass a positive value (e.g. `0.5`) to coalesce rapid notifications. - private func scheduleRefresh(debounce seconds: Double) { + fileprivate func scheduleRefresh(debounce seconds: Double) { importDebounceTask?.cancel() importDebounceTask = Task(priority: .userInitiated) { if seconds > 0 { @@ -939,14 +939,14 @@ public struct RetroGameLibraryView: View { /// Synchronous wrapper kept for call-sites that already run on the main actor /// (e.g. `.onAppear`). Schedules an immediate async refresh. - private func refreshGameData() { + fileprivate func refreshGameData() { scheduleRefresh(debounce: 0) } /// Fetches games and systems on a background thread, then publishes results /// back to the main actor. Never blocks the main thread with `Array(Results)`. @MainActor - private func refreshGameDataAsync() async { + fileprivate func refreshGameDataAsync() async { DLOG("RetroGameLibraryView: Refreshing game data (async)") // Fetch and freeze Realm objects so they're safe to access from any thread. @@ -954,7 +954,10 @@ public struct RetroGameLibraryView: View { // and crash with "Realm accessed from incorrect thread". let (games, systems) = await Task.detached(priority: .userInitiated) { let db = RomDatabase.sharedInstance - let g = Array(db.all(PVGame.self, sortedByKeyPath: "title")).map { $0.freeze() } + let g = Array( + db.all(PVGame.self, sortedByKeyPath: "title") + .filter("isHiddenPatchVariant == false") + ).map { $0.freeze() } let s = Array(db.all(PVSystem.self, sortedByKeyPath: "name")).map { $0.freeze() } return (g, s) }.value @@ -975,7 +978,7 @@ public struct RetroGameLibraryView: View { /// Scans all local ROM files and updates games marked as iCloud-only /// that actually have local files present. Fixes race condition between CloudKit /// sync and local file scanning. - private func scanAndFixAllLocalFileStatus() async { + fileprivate func scanAndFixAllLocalFileStatus() async { // Step 1: Collect game info on main thread (fast Realm query) let gameInfoList: [(md5: String, systemId: String, filenames: [String], title: String)] = await MainActor.run { let realm = RomDatabase.sharedInstance.realm diff --git a/PVUI/Sources/PVUIBase/SwiftUI/Components/RetroWave/RetroGameLibraryViewModel.swift b/PVUI/Sources/PVUIBase/SwiftUI/Components/RetroWave/RetroGameLibraryViewModel.swift index c6d1af32dd..38d3428b3b 100644 --- a/PVUI/Sources/PVUIBase/SwiftUI/Components/RetroWave/RetroGameLibraryViewModel.swift +++ b/PVUI/Sources/PVUIBase/SwiftUI/Components/RetroWave/RetroGameLibraryViewModel.swift @@ -410,8 +410,9 @@ public class RetroGameLibraryViewModel: ObservableObject { return cached } - /// Compute sorted games and cache them - let sorted = sortGames(Array(games), by: sortOption) + /// Compute sorted games and cache them (exclude hidden patch variants). + let visible = Array(games).filter { !$0.isHiddenPatchVariant } + let sorted = sortGames(visible, by: sortOption) cachedSystemGames[systemId] = sorted return sorted } diff --git a/PVUI/Tests/PVUIBaseTests/PatchLaunchRoutingTests.swift b/PVUI/Tests/PVUIBaseTests/PatchLaunchRoutingTests.swift new file mode 100644 index 0000000000..1987bb45d1 --- /dev/null +++ b/PVUI/Tests/PVUIBaseTests/PatchLaunchRoutingTests.swift @@ -0,0 +1,36 @@ +// +// PatchLaunchRoutingTests.swift +// PVUIBaseTests +// + +import Foundation +import Testing +@testable import PVUIBase + +/// Documents the intended default-patch / play-original routing contract for SceneCoordinator. +/// Full Realm-backed launch tests require the app host; these guard the gate helpers used by patch launches. +struct PatchLaunchRoutingTests { + + @Test func patchLaunchUsesIndependentLaunchGateSlot() { + var gate = LaunchGate() + let first = gate.begin() + #expect(gate.ownerID == first) + gate.cancel() + let second = gate.begin() + #expect(gate.ownerID == second) + #expect(gate.finish(second)) + #expect(gate.ownerID == nil) + } + + @Test func concurrentLaunchIsRejectedUntilFinished() { + var gate = LaunchGate() + let owner = gate.begin() + // A second begin while owned replaces via cancel in SceneCoordinator; + // finish of a stale id must not clear the new owner. + gate.cancel() + let replacement = gate.begin() + #expect(!gate.finish(owner)) + #expect(gate.ownerID == replacement) + #expect(gate.finish(replacement)) + } +}