From a135ea236dcb934dfa7407b3446af0313440421d Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 25 Jan 2023 17:28:25 -0600 Subject: [PATCH] Integrates CheatBase to browse and easily add cheats for recognized games Limited to DS games right now. --- Delta.xcodeproj/project.pbxproj | 32 +++ Delta/Database/Cheats/CheatBase.swift | 141 ++++++++++ Delta/Database/Cheats/CheatBaseView.swift | 251 ++++++++++++++++++ Delta/Database/Cheats/CheatDevice.swift | 111 ++++++++ Delta/Database/Cheats/CheatMetadata.swift | 45 ++++ Delta/Database/Cheats/LegacySearchBar.swift | 51 ++++ Delta/Database/DatabaseManager.swift | 12 + Delta/Database/OpenVGDB/GameMetadata.swift | 12 +- Delta/Database/OpenVGDB/GamesDatabase.swift | 19 +- Delta/Pause Menu/Cheats/CheatValidator.swift | 15 +- .../Cheats/CheatsViewController.swift | 72 +++++ 11 files changed, 746 insertions(+), 15 deletions(-) create mode 100644 Delta/Database/Cheats/CheatBase.swift create mode 100644 Delta/Database/Cheats/CheatBaseView.swift create mode 100644 Delta/Database/Cheats/CheatDevice.swift create mode 100644 Delta/Database/Cheats/CheatMetadata.swift create mode 100644 Delta/Database/Cheats/LegacySearchBar.swift diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index a803773..7793ae0 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -169,7 +169,13 @@ D524F4A3273DE9C000D500B2 /* ProcessInfo+JIT.swift in Sources */ = {isa = PBXBuildFile; fileRef = D524F4A2273DE9C000D500B2 /* ProcessInfo+JIT.swift */; }; D524F4A5273DEBB400D500B2 /* ServerManager+Delta.swift in Sources */ = {isa = PBXBuildFile; fileRef = D524F4A4273DEBB400D500B2 /* ServerManager+Delta.swift */; }; D5011C48281B6E8B00A0760B /* CharacterSet+Filename.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5011C47281B6E8B00A0760B /* CharacterSet+Filename.swift */; }; + D5864970297734280081477E /* CheatMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D586496F297734280081477E /* CheatMetadata.swift */; }; + D586497229774ABD0081477E /* CheatBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D586497129774ABD0081477E /* CheatBase.swift */; }; + D5864978297756CE0081477E /* CheatBaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5864977297756CE0081477E /* CheatBaseView.swift */; }; D5A98CE2284EF14B00E023E5 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */; }; + D5AAF27729884F8600F21ACF /* CheatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF27629884F8600F21ACF /* CheatDevice.swift */; }; + D5B6A6472988651800223C5F /* cheatbase.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = D5B6A6462988651400223C5F /* cheatbase.sqlite */; }; + D5F82FB82981D3AC00B229AF /* LegacySearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F82FB72981D3AC00B229AF /* LegacySearchBar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -366,7 +372,13 @@ D524F4A2273DE9C000D500B2 /* ProcessInfo+JIT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+JIT.swift"; sourceTree = ""; }; D524F4A4273DEBB400D500B2 /* ServerManager+Delta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ServerManager+Delta.swift"; sourceTree = ""; }; D5011C47281B6E8B00A0760B /* CharacterSet+Filename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CharacterSet+Filename.swift"; sourceTree = ""; }; + D586496F297734280081477E /* CheatMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatMetadata.swift; sourceTree = ""; }; + D586497129774ABD0081477E /* CheatBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBase.swift; sourceTree = ""; }; + D5864977297756CE0081477E /* CheatBaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBaseView.swift; sourceTree = ""; }; D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + D5AAF27629884F8600F21ACF /* CheatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatDevice.swift; sourceTree = ""; }; + D5B6A6462988651400223C5F /* cheatbase.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; name = cheatbase.sqlite; path = External/CheatBase/cheatbase.sqlite; sourceTree = SOURCE_ROOT; }; + D5F82FB72981D3AC00B229AF /* LegacySearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacySearchBar.swift; sourceTree = ""; }; DC866E433B3BA9AE18ABA1EC /* libPods-Delta.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Delta.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -527,6 +539,7 @@ BF59426D1E09BC5D0051894B /* DatabaseManager.swift */, BF5942711E09BC690051894B /* Model */, BF95E2751E49763D0030E7AD /* OpenVGDB */, + D586496E297734060081477E /* Cheats */, ); path = Database; sourceTree = ""; @@ -781,6 +794,7 @@ children = ( BF6BB2451BB73FE800CCF94A /* Assets.xcassets */, BF02D5D91DDEBB3000A5E131 /* openvgdb.sqlite */, + D5B6A6462988651400223C5F /* cheatbase.sqlite */, ); path = Resources; sourceTree = ""; @@ -891,6 +905,18 @@ path = Launch; sourceTree = ""; }; + D586496E297734060081477E /* Cheats */ = { + isa = PBXGroup; + children = ( + D586496F297734280081477E /* CheatMetadata.swift */, + D5AAF27629884F8600F21ACF /* CheatDevice.swift */, + D586497129774ABD0081477E /* CheatBase.swift */, + D5864977297756CE0081477E /* CheatBaseView.swift */, + D5F82FB72981D3AC00B229AF /* LegacySearchBar.swift */, + ); + path = Cheats; + sourceTree = ""; + }; FD1E8AE87FA2DB8793F7B937 /* Pods */ = { isa = PBXGroup; children = ( @@ -999,6 +1025,7 @@ BF02D5DA1DDEBB3000A5E131 /* openvgdb.sqlite in Resources */, BF71CF8A1FE904B1001F1613 /* GameTableViewCell.xib in Resources */, BFFC46461D59861000AF2CC6 /* LaunchScreen.storyboard in Resources */, + D5B6A6472988651800223C5F /* cheatbase.sqlite in Resources */, BF1F45AB21AF4B5800EF9895 /* SyncResultsViewController.storyboard in Resources */, BF353FF61C5D837600C1184C /* PauseMenu.storyboard in Resources */, BF27CC8E1BC9FEA200A20D89 /* Assets.xcassets in Resources */, @@ -1158,6 +1185,7 @@ BF353FF91C5D870B00C1184C /* MenuItem.swift in Sources */, BFDB3418219E4B1700595A62 /* SyncStatusViewController.swift in Sources */, BF18B61F1E2985F900F70067 /* UIAlertController+Importing.swift in Sources */, + D586497229774ABD0081477E /* CheatBase.swift in Sources */, BFDD04F11D5E2C27002D450E /* GameCollectionViewController.swift in Sources */, BFE4269E1D9C68E600DC913F /* SaveStatesStoryboardSegue.swift in Sources */, BFFA71DD1AAC406100EE9DD1 /* AppDelegate.swift in Sources */, @@ -1168,6 +1196,7 @@ BF5942891E09BC8B0051894B /* _GameCollection.swift in Sources */, BF34FA111CF1899D006624C7 /* CheatTextView.swift in Sources */, D524F4A5273DEBB400D500B2 /* ServerManager+Delta.swift in Sources */, + D5AAF27729884F8600F21ACF /* CheatDevice.swift in Sources */, BF1F45AD21AF57BA00EF9895 /* HarmonyMetadataKey+Keys.swift in Sources */, BFD1EF402336BD8800D197CF /* UIDevice+Processor.swift in Sources */, BF71CF871FE90006001F1613 /* AppIconShortcutsViewController.swift in Sources */, @@ -1176,6 +1205,7 @@ BF6BF31C1EB821A0008E83CD /* GamesDatabaseImportOption.swift in Sources */, BFFA4C091E8A24D600D87934 /* GameTableViewCell.swift in Sources */, BFFC46231D5984A000AF2CC6 /* LaunchViewController.swift in Sources */, + D5864970297734280081477E /* CheatMetadata.swift in Sources */, BF8DDD241F4F6C880088A21B /* InputCalloutView.swift in Sources */, BF5942701E09BC5D0051894B /* GamesDatabase.swift in Sources */, BF34FA071CF0F510006624C7 /* EditCheatViewController.swift in Sources */, @@ -1187,6 +1217,7 @@ BFFC461F1D59823500AF2CC6 /* GamesStoryboardSegue.swift in Sources */, BF2B98E61C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift in Sources */, BFC9B7391CEFCD34008629BB /* CheatsViewController.swift in Sources */, + D5864978297756CE0081477E /* CheatBaseView.swift in Sources */, BF353FFF1C5DA3C500C1184C /* PausePresentationController.swift in Sources */, BFFC464C1D5998D600AF2CC6 /* CheatTableViewCell.swift in Sources */, BF5942941E09BD1A0051894B /* NSManagedObject+Conveniences.swift in Sources */, @@ -1205,6 +1236,7 @@ BFD097211D3A01B8005A44C2 /* SaveStatesViewController.swift in Sources */, BF6EE5EB1F7C5F8F0051AD6C /* GameControllerInputMapping.swift in Sources */, BF8A333421A484A000A42FD4 /* BadgedTableViewCell.swift in Sources */, + D5F82FB82981D3AC00B229AF /* LegacySearchBar.swift in Sources */, BF3540021C5DA3D500C1184C /* PauseStoryboardSegue.swift in Sources */, BF107EC41BF413F000E0C32C /* GamesViewController.swift in Sources */, BF3D6C512202865F0083E05A /* Delta2ToDelta3.xcmappingmodel in Sources */, diff --git a/Delta/Database/Cheats/CheatBase.swift b/Delta/Database/Cheats/CheatBase.swift new file mode 100644 index 0000000..b3278b6 --- /dev/null +++ b/Delta/Database/Cheats/CheatBase.swift @@ -0,0 +1,141 @@ +// +// CheatBase.swift +// Delta +// +// Created by Riley Testut on 1/17/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import Foundation +import SQLite + +import Roxas + +private extension UserDefaults +{ + @NSManaged var previousCheatBaseVersion: Int +} + +extension ExpressionType +{ + static var cheatID: SQLite.Expression { + return SQLite.Expression("cheatID") + } + + static var cheatName: SQLite.Expression { + return SQLite.Expression("cheatName") + } + + static var cheatDescription: SQLite.Expression { + return SQLite.Expression("cheatDescription") + } + + static var cheatCode: SQLite.Expression { + return SQLite.Expression("cheatCode") + } + + static var cheatDeviceID: SQLite.Expression { + return SQLite.Expression("cheatDeviceID") + } + + static var cheatActivation: SQLite.Expression { + return SQLite.Expression("cheatActivation") + } + + static var cheatCategoryID: SQLite.Expression { + return SQLite.Expression("cheatCategoryID") + } + + static var cheatCategoryName: SQLite.Expression { + return SQLite.Expression("cheatCategory") + } + + static var cheatCategoryDescription: SQLite.Expression { + return SQLite.Expression("cheatCategoryDescription") + } +} + + +extension Table +{ + static var cheats: Table { + return Table("CHEATS") + } + + static var cheatCategories: Table { + return Table("CHEAT_CATEGORIES") + } +} + +@available(iOS 14, *) +class CheatBase: GamesDatabase +{ + static let cheatsVersion = 1 + static var previousCheatsVersion: Int? { + return UserDefaults.standard.previousCheatBaseVersion + } + + private let connection: Connection + + override init() throws + { + let fileURL = DatabaseManager.cheatBaseURL + self.connection = try Connection(fileURL.path) + + try super.init() + + UserDefaults.standard.previousCheatBaseVersion = CheatBase.cheatsVersion + } + + func cheats(for game: Game) async throws -> [CheatMetadata]? + { + let metadata = await withCheckedContinuation { continuation in + if let context = game.managedObjectContext + { + context.perform { + let metadata = self.metadata(for: game) + continuation.resume(returning: metadata) + } + } + else + { + let metadata = self.metadata(for: game) + continuation.resume(returning: metadata) + } + } + + guard let romIDValue = metadata?.romID else { return nil } + + let cheatID = Expression.cheatID + let cheatName = Expression.cheatName + let cheatCode = Expression.cheatCode + let cheatDescription = Expression.cheatDescription + let cheatActivation = Expression.cheatActivation + let cheatDeviceID = Expression.cheatDeviceID + + let categoryID = Expression.cheatCategoryID + let categoryName = Expression.cheatCategoryName + let categoryDescription = Expression.cheatCategoryDescription + + let romID = Expression.romID + + let query = Table.cheats.select(cheatID, cheatName, cheatCode, cheatDescription, cheatActivation, cheatDeviceID, Table.cheats[categoryID], categoryName, categoryDescription) + .filter(romID == romIDValue) + .join(Table.cheatCategories, on: Table.cheats[categoryID] == Table.cheatCategories[categoryID]) + .order(cheatName) + + let rows = try self.connection.prepare(query) + + let results = rows.compactMap { (row) -> CheatMetadata? in + guard case let deviceID = Int16(row[cheatDeviceID]), let device = CheatDevice(rawValue: deviceID) else { return nil } + + let id = row[Table.cheats[categoryID]] + + let category = CheatCategory(id: id, name: row[categoryName], categoryDescription: row[categoryDescription]) + let metadata = CheatMetadata(id: row[cheatID], name: row[cheatName], code: row[cheatCode], description: row[cheatDescription], activationHint: row[cheatActivation], device: device, category: category) + return metadata + } + + return results + } +} diff --git a/Delta/Database/Cheats/CheatBaseView.swift b/Delta/Database/Cheats/CheatBaseView.swift new file mode 100644 index 0000000..d9d4d6f --- /dev/null +++ b/Delta/Database/Cheats/CheatBaseView.swift @@ -0,0 +1,251 @@ +// +// CheatBaseView.swift +// Delta +// +// Created by Riley Testut on 1/17/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import SwiftUI + +@available(iOS 14, *) +extension CheatBaseView +{ + private class ViewModel: ObservableObject + { + @Published + private(set) var database: CheatBase? + + @Published + private(set) var allCheats: [CheatMetadata]? + + @Published + private(set) var cheatsByCategory: [(CheatCategory, [CheatMetadata])]? + + @Published + private(set) var error: Error? + + @Published + var searchText: String = "" { + didSet { + self.searchCheats() + } + } + + @Published + private(set) var filteredCheats: [CheatMetadata]? + + @MainActor + func fetchCheats(for game: Game) async + { + guard self.allCheats == nil else { return } + + do + { + let database = try CheatBase() + self.database = database + + let cheats = try await database.cheats(for: game) ?? [] + self.allCheats = cheats + + let cheatsByCategory = Dictionary(grouping: cheats, by: { $0.category }).sorted { $0.key.id < $1.key.id } + self.cheatsByCategory = cheatsByCategory + } + catch + { + self.error = error + } + } + + private func searchCheats() + { + if let cheats = self.allCheats, !self.searchText.isEmpty + { + let predicate = NSPredicate(forSearchingForText: self.searchText, inValuesForKeyPaths: [#keyPath(CheatMetadata.name), #keyPath(CheatMetadata.cheatDescription)]) + + let filteredCheats = cheats.filter { predicate.evaluate(with: $0) } + self.filteredCheats = filteredCheats + } + else + { + self.filteredCheats = nil + } + } + } +} + +@available(iOS 14, *) +struct CheatBaseView: View +{ + let game: Game? + + var cancellationHandler: (() -> Void)? + var selectionHandler: ((CheatMetadata) -> Void)? + + @StateObject + private var viewModel = ViewModel() + + @State + private var activationHintCheat: CheatMetadata? + + var body: some View { + NavigationView { + ZStack { + if let cheats = viewModel.allCheats, !cheats.isEmpty + { + // Only show List if there is at least one cheat for this game. + cheatList() + } + + // Place above List + placeholderView() + } + .alert(item: $activationHintCheat) { cheat in + Alert(title: Text("Activation Hint"), + message: Text(cheat.activationHint ?? ""), + dismissButton: .default(Text("OK"))) + } + .navigationTitle(Text(game?.name ?? "CheatBase")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + cancellationHandler?() + } + } + } + } + .onAppear { + Task { + guard let game = self.game else { return } + await viewModel.fetchCheats(for: game) + } + } + } + + private func cheatList() -> some View + { + VStack { + if #unavailable(iOS 15) + { + LegacySearchBar(text: $viewModel.searchText) + } + + let listView = List { + if let filteredCheats = viewModel.filteredCheats + { + ForEach(filteredCheats) { cheat in + cell(for: cheat) + } + } + else if let cheats = viewModel.cheatsByCategory + { + ForEach(cheats, id: \.0.id) { (category, cheats) in + Section { + DisclosureGroup { + ForEach(cheats) { cheat in + cell(for: cheat) + } + } label: { + Text(category.name) + } + } footer: { + Text(category.categoryDescription) + } + } + } + } + + if #available(iOS 15, *) + { + listView.searchable(text: $viewModel.searchText) + } + else + { + listView + } + } + .listStyle(.insetGrouped) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top) + } + + + private func cell(for cheat: CheatMetadata) -> some View + { + ZStack(alignment: .leading) { + Button(action: { choose(cheat) }) {} + + HStack { + // Name + Description + VStack(alignment: .leading, spacing: 4) { + Text(cheat.name) + + if let description = cheat.cheatDescription + { + Text(description) + .font(.caption) + } + } + + // Activation Hint + if cheat.activationHint != nil + { + Spacer() + + Button(action: { activationHintCheat = cheat }) { + Image(systemName: "info.circle") + } + .buttonStyle(.borderless) + } + } + .multilineTextAlignment(.leading) + } + } + + private func placeholderView() -> some View + { + VStack(spacing: 8) { + if let error = viewModel.error + { + Text("Unable to Load Cheats") + .font(.title) + + Text(error.localizedDescription) + .font(.callout) + } + else if let filteredCheats = viewModel.filteredCheats, filteredCheats.isEmpty + { + Text("Cheat Not Found") + .font(.title) + + Text("Please make sure the name is correct, or try searching for another cheat.") + .font(.callout) + } + else if let cheats = viewModel.allCheats, cheats.isEmpty + { + Text("No Cheats") + .font(.title) + + Text("There are no cheats for this game in Delta's CheatBase. Please try a different game.") + .font(.callout) + } + else if viewModel.allCheats == nil + { + ProgressView() + .progressViewStyle(.circular) + } + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + .foregroundColor(.gray) + .padding() + } +} + +@available(iOS 14, *) +private extension CheatBaseView +{ + func choose(_ cheatMetadata: CheatMetadata) + { + self.selectionHandler?(cheatMetadata) + } +} diff --git a/Delta/Database/Cheats/CheatDevice.swift b/Delta/Database/Cheats/CheatDevice.swift new file mode 100644 index 0000000..334f814 --- /dev/null +++ b/Delta/Database/Cheats/CheatDevice.swift @@ -0,0 +1,111 @@ +// +// CheatDevice.swift +// Delta +// +// Created by Riley Testut on 1/30/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import Foundation + +import DeltaCore +import NESDeltaCore + +@objc +enum CheatDevice: Int16 +{ + case famicomGameGenie = 1 + case famicomRaw = 2 + case famicomRawCompare = 3 + + case gbGameGenie = 4 + + case gbaActionReplayMax = 5 + case gbaCodeBreaker = 6 + case gbaGameShark = 7 + + case gbcGameShark = 8 + + case n64GameShark = 9 + + case dsActionReplay = 10 + case dsCodeBreaker = 11 + + case nesGameGenie = 12 + case nesRaw = 13 + case nesRawCompare = 14 + + case snesActionReplay = 15 + case snesGameGenie = 16 + + case gameGearActionReplay = 17 + case gameGearGameGenie = 18 + + case masterSystemActionReplay = 19 + case masterSystemGameGenie = 20 + + case cdActionReplay10 = 21 + case cdActionReplay8 = 22 + + case genesisActionReplay10 = 23 + case genesisActionReplay8 = 24 +} + +extension CheatDevice +{ + var cheatType: CheatType? { + switch self + { + case .snesActionReplay, .gbaActionReplayMax, .dsActionReplay, .gameGearActionReplay, .masterSystemActionReplay, .genesisActionReplay8, .genesisActionReplay10, .cdActionReplay8, .cdActionReplay10: + return .actionReplay + + case .n64GameShark, .gbcGameShark, .gbaGameShark: + return .gameShark + + case .famicomGameGenie, .snesGameGenie, .gbGameGenie, .gameGearGameGenie, .masterSystemGameGenie: + return .gameGenie + + case .nesGameGenie: + return CheatType(rawValue: DeltaCore.CheatType.gameGenie8.rawValue) + + case .gbaCodeBreaker, .dsCodeBreaker: + return .codeBreaker + + case .famicomRaw, .famicomRawCompare: + return nil + + case .nesRaw, .nesRawCompare: + return nil + } + } + + var gameType: GameType? { + switch self + { + case .famicomGameGenie, .famicomRaw, .famicomRawCompare: return .nes + case .nesGameGenie, .nesRaw, .nesRawCompare: return .nes + case .snesActionReplay, .snesGameGenie: return .snes + case .n64GameShark: return .n64 + case .gbGameGenie, .gbcGameShark: return .gbc + case .gbaActionReplayMax, .gbaGameShark, .gbaCodeBreaker: return .gba + case .dsActionReplay, .dsCodeBreaker: return .ds + case .genesisActionReplay8, .genesisActionReplay10: return .genesis + case .cdActionReplay8, .cdActionReplay10: return .genesis + + // Not yet supported + case .gameGearActionReplay, .gameGearGameGenie: return nil + case .masterSystemActionReplay, .masterSystemGameGenie: return nil + } + } + + var cheatFormat: CheatFormat? { + guard + let cheatType = self.cheatType, + let gameType = self.gameType, + let deltaCore = Delta.core(for: gameType) + else { return nil } + + let cheatFormat = deltaCore.supportedCheatFormats.first { $0.type == cheatType } + return cheatFormat + } +} diff --git a/Delta/Database/Cheats/CheatMetadata.swift b/Delta/Database/Cheats/CheatMetadata.swift new file mode 100644 index 0000000..99dffef --- /dev/null +++ b/Delta/Database/Cheats/CheatMetadata.swift @@ -0,0 +1,45 @@ +// +// CheatMetadata.swift +// Delta +// +// Created by Riley Testut on 1/17/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import DeltaCore + +struct CheatCategory: Identifiable, Hashable +{ + var id: Int + + var name: String + var categoryDescription: String +} + +@objcMembers // @objcMembers required for NSPredicate-based filtering. +final class CheatMetadata: NSObject, Identifiable +{ + let id: Int + + let name: String + let code: String + + let cheatDescription: String? + let activationHint: String? + + let device: CheatDevice + let category: CheatCategory + + init(id: Int, name: String, code: String, description: String?, activationHint: String?, device: CheatDevice, category: CheatCategory) + { + self.id = id + self.name = name + self.code = code + self.cheatDescription = description + self.activationHint = activationHint + self.device = device + self.category = category + } +} diff --git a/Delta/Database/Cheats/LegacySearchBar.swift b/Delta/Database/Cheats/LegacySearchBar.swift new file mode 100644 index 0000000..53fcae0 --- /dev/null +++ b/Delta/Database/Cheats/LegacySearchBar.swift @@ -0,0 +1,51 @@ +// +// LegacySearchBar.swift +// Delta +// +// Created by Riley Testut on 1/25/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit +import SwiftUI + +@available(iOS 13, *) +struct LegacySearchBar: UIViewRepresentable +{ + class Coordinator: NSObject, UISearchBarDelegate + { + @Binding + var text: String + + init(text: Binding) + { + self._text = text + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) + { + self.text = searchText + } + } + + @Binding + var text: String + + func makeUIView(context: Context) -> UISearchBar + { + let searchBar = UISearchBar(frame: .zero) + searchBar.delegate = context.coordinator + searchBar.placeholder = NSLocalizedString("Search", comment: "") + return searchBar + } + + func updateUIView(_ uiView: UISearchBar, context: Context) + { + uiView.text = self.text + } + + func makeCoordinator() -> Coordinator + { + return Coordinator(text: $text) + } +} diff --git a/Delta/Database/DatabaseManager.swift b/Delta/Database/DatabaseManager.swift index cfec0ce..211e232 100644 --- a/Delta/Database/DatabaseManager.swift +++ b/Delta/Database/DatabaseManager.swift @@ -261,6 +261,12 @@ private extension DatabaseManager try FileManager.default.copyItem(at: bundleURL, to: DatabaseManager.gamesDatabaseURL, shouldReplace: true) } + if #available(iOS 14, *), !FileManager.default.fileExists(atPath: DatabaseManager.cheatBaseURL.path) || CheatBase.cheatsVersion != CheatBase.previousCheatsVersion + { + guard let bundleURL = Bundle.main.url(forResource: "cheatbase", withExtension: "sqlite") else { throw GamesDatabase.Error.doesNotExist } + try FileManager.default.copyItem(at: bundleURL, to: DatabaseManager.cheatBaseURL, shouldReplace: true) + } + self.gamesDatabase = try GamesDatabase() } catch @@ -618,6 +624,12 @@ extension DatabaseManager let gamesDatabaseURL = self.defaultDirectoryURL().appendingPathComponent("openvgdb.sqlite") return gamesDatabaseURL } + + class var cheatBaseURL: URL + { + let gamesDatabaseURL = self.defaultDirectoryURL().appendingPathComponent("cheatbase.sqlite") + return gamesDatabaseURL + } class var gamesDirectoryURL: URL { diff --git a/Delta/Database/OpenVGDB/GameMetadata.swift b/Delta/Database/OpenVGDB/GameMetadata.swift index 666daa5..3dd467f 100644 --- a/Delta/Database/OpenVGDB/GameMetadata.swift +++ b/Delta/Database/OpenVGDB/GameMetadata.swift @@ -11,15 +11,17 @@ import Foundation // Must be an NSObject subclass so it can be used with RSTCellContentDataSource. class GameMetadata: NSObject { - let identifier: Int + let releaseID: Int + let romID: Int let name: String? let artworkURL: URL? - init(identifier: Int, name: String?, artworkURL: URL?) + init(releaseID: Int, romID: Int, name: String?, artworkURL: URL?) { + self.releaseID = releaseID + self.romID = romID self.name = name - self.identifier = identifier self.artworkURL = artworkURL } } @@ -27,13 +29,13 @@ class GameMetadata: NSObject extension GameMetadata { override var hash: Int { - return self.identifier.hashValue + return self.releaseID.hashValue ^ self.romID.hashValue } override func isEqual(_ object: Any?) -> Bool { guard let metadata = object as? GameMetadata else { return false } - return self.identifier == metadata.identifier + return self.releaseID == metadata.releaseID && self.romID == metadata.romID } } diff --git a/Delta/Database/OpenVGDB/GamesDatabase.swift b/Delta/Database/OpenVGDB/GamesDatabase.swift index deca604..28c4a13 100644 --- a/Delta/Database/OpenVGDB/GamesDatabase.swift +++ b/Delta/Database/OpenVGDB/GamesDatabase.swift @@ -60,13 +60,12 @@ extension GamesDatabase enum Error: Swift.Error { case doesNotExist - case connection(Swift.Error) } } class GamesDatabase { - static let version = 2 + static let version = 3 static var previousVersion: Int? { return UserDefaults.standard.previousGamesDatabaseVersion } @@ -83,7 +82,7 @@ class GamesDatabase } catch { - throw Error.connection(error) + throw error } self.invalidateVirtualTableIfNeeded() @@ -92,10 +91,11 @@ class GamesDatabase func metadataResults(forGameName gameName: String) -> [GameMetadata] { let releaseID = Expression.releaseID + let romID = Expression.romID let name = Expression.name let artworkAddress = Expression.artworkAddress - let query = VirtualTable.search.select(releaseID, name, artworkAddress).filter(name.match(gameName + "*")) + let query = VirtualTable.search.select(releaseID, romID, name, artworkAddress).filter(name.match(gameName + "*")) do { @@ -114,7 +114,7 @@ class GamesDatabase } - let metadata = GameMetadata(identifier: row[releaseID], name: row[name], artworkURL: artworkURL) + let metadata = GameMetadata(releaseID: row[releaseID], romID: row[romID], name: row[name], artworkURL: artworkURL) return metadata } @@ -148,7 +148,7 @@ class GamesDatabase let romID = Expression.romID let gameHash = game.identifier.uppercased() - let query = Table.roms.select(releaseID, name, artworkAddress).filter(sha1Hash == gameHash).join(Table.releases, on: Table.roms[romID] == Table.releases[romID]) + let query = Table.roms.select(releaseID, name, artworkAddress, Table.roms[romID]).filter(sha1Hash == gameHash).join(Table.releases, on: Table.roms[romID] == Table.releases[romID]) do { @@ -164,7 +164,7 @@ class GamesDatabase artworkURL = nil } - let metadata = GameMetadata(identifier: row[releaseID], name: row[name], artworkURL: artworkURL) + let metadata = GameMetadata(releaseID: row[releaseID], romID: row[Table.roms[romID]], name: row[name], artworkURL: artworkURL) return metadata } } @@ -200,12 +200,13 @@ private extension GamesDatabase let name = Expression.name let artworkAddress = Expression.artworkAddress let releaseID = Expression.releaseID + let romID = Expression.romID do { - try self.connection.run(VirtualTable.search.create(.FTS4([releaseID, name, artworkAddress], tokenize: .Unicode61()))) + try self.connection.run(VirtualTable.search.create(.FTS4([releaseID, romID, name, artworkAddress], tokenize: .Unicode61()))) - let update = VirtualTable.search.insert(Table.releases.select(releaseID, name, artworkAddress)) + let update = VirtualTable.search.insert(Table.releases.select(releaseID, romID, name, artworkAddress)) _ = try self.connection.run(update) } catch diff --git a/Delta/Pause Menu/Cheats/CheatValidator.swift b/Delta/Pause Menu/Cheats/CheatValidator.swift index 86d4ca9..40f2827 100644 --- a/Delta/Pause Menu/Cheats/CheatValidator.swift +++ b/Delta/Pause Menu/Cheats/CheatValidator.swift @@ -12,13 +12,26 @@ import DeltaCore extension CheatValidator { - enum Error: Swift.Error + enum Error: LocalizedError { case invalidCode case invalidName case invalidGame case duplicateName case duplicateCode + case unknownCheatType + + var errorDescription: String? { + switch self + { + case .invalidCode: return NSLocalizedString("The cheat code isn't in the correct format.", comment: "") + case .invalidName: return NSLocalizedString("The name of this cheat is invalid.", comment: "") + case .invalidGame: return NSLocalizedString("There is no associated game with this cheat.", comment: "") + case .duplicateName: return NSLocalizedString("A cheat already exists with this name.", comment: "") + case .duplicateCode: return NSLocalizedString("A cheat already exists with this code.", comment: "") + case .unknownCheatType: return NSLocalizedString("Delta does not support this cheat type.", comment: "") + } + } } } diff --git a/Delta/Pause Menu/Cheats/CheatsViewController.swift b/Delta/Pause Menu/Cheats/CheatsViewController.swift index 2a76541..7df1aea 100644 --- a/Delta/Pause Menu/Cheats/CheatsViewController.swift +++ b/Delta/Pause Menu/Cheats/CheatsViewController.swift @@ -8,6 +8,7 @@ import UIKit import CoreData +import SwiftUI import DeltaCore @@ -61,6 +62,24 @@ extension CheatsViewController self.tableView.separatorEffect = vibrancyEffect self.registerForPreviewing(with: self, sourceView: self.tableView) + + if #available(iOS 14, *) + { + let addCheatMenu = UIMenu(children: [ + UIAction(title: NSLocalizedString("New Cheat Code", comment: ""), image: UIImage(systemName: "square.and.pencil")) { [weak self] _ in + self?.addCheat() + }, + + UIAction(title: NSLocalizedString("Search CheatBase", comment: ""), image: UIImage(systemName: "magnifyingglass")) { [weak self] _ in + self?.searchCheatBase() + }, + ]) + + self.navigationItem.rightBarButtonItem?.target = nil + self.navigationItem.rightBarButtonItem?.action = nil + + self.navigationItem.rightBarButtonItem?.menu = addCheatMenu + } } override func didReceiveMemoryWarning() @@ -103,6 +122,59 @@ private extension CheatsViewController editCheatViewController.presentWithPresentingViewController(self) } + @available(iOS 14, *) + func searchCheatBase() + { + var rootView = CheatBaseView(game: self.game) + rootView.cancellationHandler = { [weak self] in + self?.presentedViewController?.dismiss(animated: true) + } + + rootView.selectionHandler = { [weak self] cheatMetadata in + self?.saveCheatMetadata(cheatMetadata) + self?.presentedViewController?.dismiss(animated: true) + } + + let hostingController = UIHostingController(rootView: rootView) + self.present(hostingController, animated: true, completion: nil) + } + + func saveCheatMetadata(_ cheatMetadata: CheatMetadata) + { + DatabaseManager.shared.performBackgroundTask { context in + do + { + guard let cheatType = cheatMetadata.device.cheatType, let cheatFormat = cheatMetadata.device.cheatFormat else { throw CheatValidator.Error.unknownCheatType } + + let cheat = Cheat(context: context) + cheat.name = cheatMetadata.name + cheat.type = cheatType + cheat.isEnabled = true + + let sanitizedCode = cheatMetadata.code.components(separatedBy: .whitespacesAndNewlines).joined() + let formattedCode = sanitizedCode.formatted(with: cheatFormat) + cheat.code = formattedCode + + let game = context.object(with: self.game.objectID) as! Game + cheat.game = game + + let validator = CheatValidator(format: cheatFormat, managedObjectContext: context) + try validator.validate(cheat) + + self.delegate?.cheatsViewController(self, activateCheat: cheat) + + try context.save() + } + catch + { + DispatchQueue.main.async { + let alertController = UIAlertController(title: NSLocalizedString("Unable to Add Cheat", comment: ""), error: error) + self.present(alertController, animated: true, completion: nil) + } + } + } + } + func deleteCheat(_ cheat: Cheat) { self.delegate?.cheatsViewController(self, deactivateCheat: cheat)