Integrates CheatBase to browse and easily add cheats for recognized games

Limited to DS games right now.
This commit is contained in:
Riley Testut 2023-01-25 17:28:25 -06:00
parent d204ea35bd
commit a135ea236d
11 changed files with 746 additions and 15 deletions

View File

@ -169,7 +169,13 @@
D524F4A3273DE9C000D500B2 /* ProcessInfo+JIT.swift in Sources */ = {isa = PBXBuildFile; fileRef = D524F4A2273DE9C000D500B2 /* ProcessInfo+JIT.swift */; }; 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 */; }; 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 */; }; 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 */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -366,7 +372,13 @@
D524F4A2273DE9C000D500B2 /* ProcessInfo+JIT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+JIT.swift"; sourceTree = "<group>"; }; D524F4A2273DE9C000D500B2 /* ProcessInfo+JIT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+JIT.swift"; sourceTree = "<group>"; };
D524F4A4273DEBB400D500B2 /* ServerManager+Delta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ServerManager+Delta.swift"; sourceTree = "<group>"; }; D524F4A4273DEBB400D500B2 /* ServerManager+Delta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ServerManager+Delta.swift"; sourceTree = "<group>"; };
D5011C47281B6E8B00A0760B /* CharacterSet+Filename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CharacterSet+Filename.swift"; sourceTree = "<group>"; }; D5011C47281B6E8B00A0760B /* CharacterSet+Filename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CharacterSet+Filename.swift"; sourceTree = "<group>"; };
D586496F297734280081477E /* CheatMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatMetadata.swift; sourceTree = "<group>"; };
D586497129774ABD0081477E /* CheatBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBase.swift; sourceTree = "<group>"; };
D5864977297756CE0081477E /* CheatBaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBaseView.swift; sourceTree = "<group>"; };
D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
D5AAF27629884F8600F21ACF /* CheatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatDevice.swift; sourceTree = "<group>"; };
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 = "<group>"; };
DC866E433B3BA9AE18ABA1EC /* libPods-Delta.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Delta.a"; sourceTree = BUILT_PRODUCTS_DIR; }; DC866E433B3BA9AE18ABA1EC /* libPods-Delta.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Delta.a"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -527,6 +539,7 @@
BF59426D1E09BC5D0051894B /* DatabaseManager.swift */, BF59426D1E09BC5D0051894B /* DatabaseManager.swift */,
BF5942711E09BC690051894B /* Model */, BF5942711E09BC690051894B /* Model */,
BF95E2751E49763D0030E7AD /* OpenVGDB */, BF95E2751E49763D0030E7AD /* OpenVGDB */,
D586496E297734060081477E /* Cheats */,
); );
path = Database; path = Database;
sourceTree = "<group>"; sourceTree = "<group>";
@ -781,6 +794,7 @@
children = ( children = (
BF6BB2451BB73FE800CCF94A /* Assets.xcassets */, BF6BB2451BB73FE800CCF94A /* Assets.xcassets */,
BF02D5D91DDEBB3000A5E131 /* openvgdb.sqlite */, BF02D5D91DDEBB3000A5E131 /* openvgdb.sqlite */,
D5B6A6462988651400223C5F /* cheatbase.sqlite */,
); );
path = Resources; path = Resources;
sourceTree = "<group>"; sourceTree = "<group>";
@ -891,6 +905,18 @@
path = Launch; path = Launch;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D586496E297734060081477E /* Cheats */ = {
isa = PBXGroup;
children = (
D586496F297734280081477E /* CheatMetadata.swift */,
D5AAF27629884F8600F21ACF /* CheatDevice.swift */,
D586497129774ABD0081477E /* CheatBase.swift */,
D5864977297756CE0081477E /* CheatBaseView.swift */,
D5F82FB72981D3AC00B229AF /* LegacySearchBar.swift */,
);
path = Cheats;
sourceTree = "<group>";
};
FD1E8AE87FA2DB8793F7B937 /* Pods */ = { FD1E8AE87FA2DB8793F7B937 /* Pods */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -999,6 +1025,7 @@
BF02D5DA1DDEBB3000A5E131 /* openvgdb.sqlite in Resources */, BF02D5DA1DDEBB3000A5E131 /* openvgdb.sqlite in Resources */,
BF71CF8A1FE904B1001F1613 /* GameTableViewCell.xib in Resources */, BF71CF8A1FE904B1001F1613 /* GameTableViewCell.xib in Resources */,
BFFC46461D59861000AF2CC6 /* LaunchScreen.storyboard in Resources */, BFFC46461D59861000AF2CC6 /* LaunchScreen.storyboard in Resources */,
D5B6A6472988651800223C5F /* cheatbase.sqlite in Resources */,
BF1F45AB21AF4B5800EF9895 /* SyncResultsViewController.storyboard in Resources */, BF1F45AB21AF4B5800EF9895 /* SyncResultsViewController.storyboard in Resources */,
BF353FF61C5D837600C1184C /* PauseMenu.storyboard in Resources */, BF353FF61C5D837600C1184C /* PauseMenu.storyboard in Resources */,
BF27CC8E1BC9FEA200A20D89 /* Assets.xcassets in Resources */, BF27CC8E1BC9FEA200A20D89 /* Assets.xcassets in Resources */,
@ -1158,6 +1185,7 @@
BF353FF91C5D870B00C1184C /* MenuItem.swift in Sources */, BF353FF91C5D870B00C1184C /* MenuItem.swift in Sources */,
BFDB3418219E4B1700595A62 /* SyncStatusViewController.swift in Sources */, BFDB3418219E4B1700595A62 /* SyncStatusViewController.swift in Sources */,
BF18B61F1E2985F900F70067 /* UIAlertController+Importing.swift in Sources */, BF18B61F1E2985F900F70067 /* UIAlertController+Importing.swift in Sources */,
D586497229774ABD0081477E /* CheatBase.swift in Sources */,
BFDD04F11D5E2C27002D450E /* GameCollectionViewController.swift in Sources */, BFDD04F11D5E2C27002D450E /* GameCollectionViewController.swift in Sources */,
BFE4269E1D9C68E600DC913F /* SaveStatesStoryboardSegue.swift in Sources */, BFE4269E1D9C68E600DC913F /* SaveStatesStoryboardSegue.swift in Sources */,
BFFA71DD1AAC406100EE9DD1 /* AppDelegate.swift in Sources */, BFFA71DD1AAC406100EE9DD1 /* AppDelegate.swift in Sources */,
@ -1168,6 +1196,7 @@
BF5942891E09BC8B0051894B /* _GameCollection.swift in Sources */, BF5942891E09BC8B0051894B /* _GameCollection.swift in Sources */,
BF34FA111CF1899D006624C7 /* CheatTextView.swift in Sources */, BF34FA111CF1899D006624C7 /* CheatTextView.swift in Sources */,
D524F4A5273DEBB400D500B2 /* ServerManager+Delta.swift in Sources */, D524F4A5273DEBB400D500B2 /* ServerManager+Delta.swift in Sources */,
D5AAF27729884F8600F21ACF /* CheatDevice.swift in Sources */,
BF1F45AD21AF57BA00EF9895 /* HarmonyMetadataKey+Keys.swift in Sources */, BF1F45AD21AF57BA00EF9895 /* HarmonyMetadataKey+Keys.swift in Sources */,
BFD1EF402336BD8800D197CF /* UIDevice+Processor.swift in Sources */, BFD1EF402336BD8800D197CF /* UIDevice+Processor.swift in Sources */,
BF71CF871FE90006001F1613 /* AppIconShortcutsViewController.swift in Sources */, BF71CF871FE90006001F1613 /* AppIconShortcutsViewController.swift in Sources */,
@ -1176,6 +1205,7 @@
BF6BF31C1EB821A0008E83CD /* GamesDatabaseImportOption.swift in Sources */, BF6BF31C1EB821A0008E83CD /* GamesDatabaseImportOption.swift in Sources */,
BFFA4C091E8A24D600D87934 /* GameTableViewCell.swift in Sources */, BFFA4C091E8A24D600D87934 /* GameTableViewCell.swift in Sources */,
BFFC46231D5984A000AF2CC6 /* LaunchViewController.swift in Sources */, BFFC46231D5984A000AF2CC6 /* LaunchViewController.swift in Sources */,
D5864970297734280081477E /* CheatMetadata.swift in Sources */,
BF8DDD241F4F6C880088A21B /* InputCalloutView.swift in Sources */, BF8DDD241F4F6C880088A21B /* InputCalloutView.swift in Sources */,
BF5942701E09BC5D0051894B /* GamesDatabase.swift in Sources */, BF5942701E09BC5D0051894B /* GamesDatabase.swift in Sources */,
BF34FA071CF0F510006624C7 /* EditCheatViewController.swift in Sources */, BF34FA071CF0F510006624C7 /* EditCheatViewController.swift in Sources */,
@ -1187,6 +1217,7 @@
BFFC461F1D59823500AF2CC6 /* GamesStoryboardSegue.swift in Sources */, BFFC461F1D59823500AF2CC6 /* GamesStoryboardSegue.swift in Sources */,
BF2B98E61C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift in Sources */, BF2B98E61C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift in Sources */,
BFC9B7391CEFCD34008629BB /* CheatsViewController.swift in Sources */, BFC9B7391CEFCD34008629BB /* CheatsViewController.swift in Sources */,
D5864978297756CE0081477E /* CheatBaseView.swift in Sources */,
BF353FFF1C5DA3C500C1184C /* PausePresentationController.swift in Sources */, BF353FFF1C5DA3C500C1184C /* PausePresentationController.swift in Sources */,
BFFC464C1D5998D600AF2CC6 /* CheatTableViewCell.swift in Sources */, BFFC464C1D5998D600AF2CC6 /* CheatTableViewCell.swift in Sources */,
BF5942941E09BD1A0051894B /* NSManagedObject+Conveniences.swift in Sources */, BF5942941E09BD1A0051894B /* NSManagedObject+Conveniences.swift in Sources */,
@ -1205,6 +1236,7 @@
BFD097211D3A01B8005A44C2 /* SaveStatesViewController.swift in Sources */, BFD097211D3A01B8005A44C2 /* SaveStatesViewController.swift in Sources */,
BF6EE5EB1F7C5F8F0051AD6C /* GameControllerInputMapping.swift in Sources */, BF6EE5EB1F7C5F8F0051AD6C /* GameControllerInputMapping.swift in Sources */,
BF8A333421A484A000A42FD4 /* BadgedTableViewCell.swift in Sources */, BF8A333421A484A000A42FD4 /* BadgedTableViewCell.swift in Sources */,
D5F82FB82981D3AC00B229AF /* LegacySearchBar.swift in Sources */,
BF3540021C5DA3D500C1184C /* PauseStoryboardSegue.swift in Sources */, BF3540021C5DA3D500C1184C /* PauseStoryboardSegue.swift in Sources */,
BF107EC41BF413F000E0C32C /* GamesViewController.swift in Sources */, BF107EC41BF413F000E0C32C /* GamesViewController.swift in Sources */,
BF3D6C512202865F0083E05A /* Delta2ToDelta3.xcmappingmodel in Sources */, BF3D6C512202865F0083E05A /* Delta2ToDelta3.xcmappingmodel in Sources */,

View File

@ -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<Int> {
return SQLite.Expression<Int>("cheatID")
}
static var cheatName: SQLite.Expression<String> {
return SQLite.Expression<String>("cheatName")
}
static var cheatDescription: SQLite.Expression<String?> {
return SQLite.Expression<String?>("cheatDescription")
}
static var cheatCode: SQLite.Expression<String> {
return SQLite.Expression<String>("cheatCode")
}
static var cheatDeviceID: SQLite.Expression<Int> {
return SQLite.Expression<Int>("cheatDeviceID")
}
static var cheatActivation: SQLite.Expression<String?> {
return SQLite.Expression<String?>("cheatActivation")
}
static var cheatCategoryID: SQLite.Expression<Int> {
return SQLite.Expression<Int>("cheatCategoryID")
}
static var cheatCategoryName: SQLite.Expression<String> {
return SQLite.Expression<String>("cheatCategory")
}
static var cheatCategoryDescription: SQLite.Expression<String> {
return SQLite.Expression<String>("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<Any>.cheatID
let cheatName = Expression<Any>.cheatName
let cheatCode = Expression<Any>.cheatCode
let cheatDescription = Expression<Any>.cheatDescription
let cheatActivation = Expression<Any>.cheatActivation
let cheatDeviceID = Expression<Any>.cheatDeviceID
let categoryID = Expression<Any>.cheatCategoryID
let categoryName = Expression<Any>.cheatCategoryName
let categoryDescription = Expression<Any>.cheatCategoryDescription
let romID = Expression<Any>.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
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<String>)
{
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)
}
}

View File

@ -261,6 +261,12 @@ private extension DatabaseManager
try FileManager.default.copyItem(at: bundleURL, to: DatabaseManager.gamesDatabaseURL, shouldReplace: true) 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() self.gamesDatabase = try GamesDatabase()
} }
catch catch
@ -618,6 +624,12 @@ extension DatabaseManager
let gamesDatabaseURL = self.defaultDirectoryURL().appendingPathComponent("openvgdb.sqlite") let gamesDatabaseURL = self.defaultDirectoryURL().appendingPathComponent("openvgdb.sqlite")
return gamesDatabaseURL return gamesDatabaseURL
} }
class var cheatBaseURL: URL
{
let gamesDatabaseURL = self.defaultDirectoryURL().appendingPathComponent("cheatbase.sqlite")
return gamesDatabaseURL
}
class var gamesDirectoryURL: URL class var gamesDirectoryURL: URL
{ {

View File

@ -11,15 +11,17 @@ import Foundation
// Must be an NSObject subclass so it can be used with RSTCellContentDataSource. // Must be an NSObject subclass so it can be used with RSTCellContentDataSource.
class GameMetadata: NSObject class GameMetadata: NSObject
{ {
let identifier: Int let releaseID: Int
let romID: Int
let name: String? let name: String?
let artworkURL: URL? 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.name = name
self.identifier = identifier
self.artworkURL = artworkURL self.artworkURL = artworkURL
} }
} }
@ -27,13 +29,13 @@ class GameMetadata: NSObject
extension GameMetadata extension GameMetadata
{ {
override var hash: Int { override var hash: Int {
return self.identifier.hashValue return self.releaseID.hashValue ^ self.romID.hashValue
} }
override func isEqual(_ object: Any?) -> Bool override func isEqual(_ object: Any?) -> Bool
{ {
guard let metadata = object as? GameMetadata else { return false } guard let metadata = object as? GameMetadata else { return false }
return self.identifier == metadata.identifier return self.releaseID == metadata.releaseID && self.romID == metadata.romID
} }
} }

View File

@ -60,13 +60,12 @@ extension GamesDatabase
enum Error: Swift.Error enum Error: Swift.Error
{ {
case doesNotExist case doesNotExist
case connection(Swift.Error)
} }
} }
class GamesDatabase class GamesDatabase
{ {
static let version = 2 static let version = 3
static var previousVersion: Int? { static var previousVersion: Int? {
return UserDefaults.standard.previousGamesDatabaseVersion return UserDefaults.standard.previousGamesDatabaseVersion
} }
@ -83,7 +82,7 @@ class GamesDatabase
} }
catch catch
{ {
throw Error.connection(error) throw error
} }
self.invalidateVirtualTableIfNeeded() self.invalidateVirtualTableIfNeeded()
@ -92,10 +91,11 @@ class GamesDatabase
func metadataResults(forGameName gameName: String) -> [GameMetadata] func metadataResults(forGameName gameName: String) -> [GameMetadata]
{ {
let releaseID = Expression<Any>.releaseID let releaseID = Expression<Any>.releaseID
let romID = Expression<Any>.romID
let name = Expression<Any>.name let name = Expression<Any>.name
let artworkAddress = Expression<Any>.artworkAddress let artworkAddress = Expression<Any>.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 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 return metadata
} }
@ -148,7 +148,7 @@ class GamesDatabase
let romID = Expression<Any>.romID let romID = Expression<Any>.romID
let gameHash = game.identifier.uppercased() 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 do
{ {
@ -164,7 +164,7 @@ class GamesDatabase
artworkURL = nil 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 return metadata
} }
} }
@ -200,12 +200,13 @@ private extension GamesDatabase
let name = Expression<Any>.name let name = Expression<Any>.name
let artworkAddress = Expression<Any>.artworkAddress let artworkAddress = Expression<Any>.artworkAddress
let releaseID = Expression<Any>.releaseID let releaseID = Expression<Any>.releaseID
let romID = Expression<Any>.romID
do 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) _ = try self.connection.run(update)
} }
catch catch

View File

@ -12,13 +12,26 @@ import DeltaCore
extension CheatValidator extension CheatValidator
{ {
enum Error: Swift.Error enum Error: LocalizedError
{ {
case invalidCode case invalidCode
case invalidName case invalidName
case invalidGame case invalidGame
case duplicateName case duplicateName
case duplicateCode 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: "")
}
}
} }
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import CoreData import CoreData
import SwiftUI
import DeltaCore import DeltaCore
@ -61,6 +62,24 @@ extension CheatsViewController
self.tableView.separatorEffect = vibrancyEffect self.tableView.separatorEffect = vibrancyEffect
self.registerForPreviewing(with: self, sourceView: self.tableView) 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() override func didReceiveMemoryWarning()
@ -103,6 +122,59 @@ private extension CheatsViewController
editCheatViewController.presentWithPresentingViewController(self) 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) func deleteCheat(_ cheat: Cheat)
{ {
self.delegate?.cheatsViewController(self, deactivateCheat: cheat) self.delegate?.cheatsViewController(self, deactivateCheat: cheat)