GBA002/Delta/Emulation/EmulationViewController.swift
2016-06-10 11:57:15 -05:00

685 lines
26 KiB
Swift

//
// EmulationViewController.swift
// Delta
//
// Created by Riley Testut on 5/5/15.
// Copyright (c) 2015 Riley Testut. All rights reserved.
//
import UIKit
import DeltaCore
import Roxas
// Temporary wrapper around dispatch_semaphore_t until Swift 3 + modernized libdispatch
private struct DispatchSemaphore: Hashable
{
let semaphore: dispatch_semaphore_t
var hashValue: Int {
return semaphore.hash
}
init(value: Int)
{
self.semaphore = dispatch_semaphore_create(value)
}
}
private func ==(lhs: DispatchSemaphore, rhs: DispatchSemaphore) -> Bool
{
return lhs.semaphore.isEqual(rhs.semaphore)
}
class EmulationViewController: UIViewController
{
//MARK: - Properties -
/** Properties **/
/// Should only be set when preparing for segue. Otherwise, should be considered immutable
var game: Game! {
didSet
{
guard oldValue != game else { return }
self.emulatorCore = EmulatorCore(game: game)
}
}
private(set) var emulatorCore: EmulatorCore! {
didSet
{
// Cannot set directly, or else we're left with a strong reference cycle
//self.emulatorCore.updateHandler = emulatorCoreDidUpdate
self.emulatorCore.updateHandler = { [weak self] core in
self?.emulatorCoreDidUpdate(core)
}
self.preferredContentSize = self.emulatorCore.preferredRenderingSize
}
}
// If non-nil, will override the default preview action items returned in previewActionItems()
var overridePreviewActionItems: [UIPreviewActionItem]?
// Annoying iOS gotcha: if the previewingContext(_:viewControllerForLocation:) callback takes too long, the peek/preview starts, but fails to actually present the view controller
// To workaround, we have this closure to defer work for Peeking/Popping until the view controller appears
// Hacky, but works
var deferredPreparationHandler: (Void -> Void)?
//MARK: - Private Properties
private var pauseViewController: PauseViewController?
private var pausingGameController: GameControllerProtocol?
private var context = CIContext(options: [kCIContextWorkingColorSpace: NSNull()])
private var updateSemaphores = Set<DispatchSemaphore>()
private var sustainedInputs = [ObjectIdentifier: [InputType]]()
private var reactivateSustainInputsQueue: NSOperationQueue
private var choosingSustainedButtons = false
@IBOutlet private var controllerView: ControllerView!
@IBOutlet private var gameView: GameView!
@IBOutlet private var sustainButtonContentView: UIView!
@IBOutlet private var backgroundView: RSTBackgroundView!
@IBOutlet private var controllerViewHeightConstraint: NSLayoutConstraint!
//MARK: - Initializers -
/** Initializers **/
required init?(coder aDecoder: NSCoder)
{
self.reactivateSustainInputsQueue = NSOperationQueue()
self.reactivateSustainInputsQueue.maxConcurrentOperationCount = 1
super.init(coder: aDecoder)
NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(EmulationViewController.updateControllers), name: ExternalControllerDidConnectNotification, object: nil)
NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(EmulationViewController.updateControllers), name: ExternalControllerDidDisconnectNotification, object: nil)
NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(EmulationViewController.willResignActive(_:)), name: UIApplicationWillResignActiveNotification, object: UIApplication.sharedApplication())
NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(EmulationViewController.didBecomeActive(_:)), name: UIApplicationDidBecomeActiveNotification, object: UIApplication.sharedApplication())
}
deinit
{
// To ensure the emulation stops when cancelling a peek/preview gesture
self.emulatorCore.stopEmulation()
}
//MARK: - Overrides
/** Overrides **/
//MARK: - UIViewController
/// UIViewController
override func viewDidLoad()
{
super.viewDidLoad()
// Set this to 0 now and update it in viewDidLayoutSubviews to ensure there are never conflicting constraints
// (such as when peeking and popping)
self.controllerViewHeightConstraint.constant = 0
self.gameView.backgroundColor = UIColor.clearColor()
self.emulatorCore.addGameView(self.gameView)
self.backgroundView.textLabel.text = NSLocalizedString("Select Buttons to Sustain", comment: "")
self.backgroundView.detailTextLabel.text = NSLocalizedString("Press the Menu button when finished.", comment: "")
let controllerSkin = ControllerSkin.defaultControllerSkinForGameUTI(self.game.typeIdentifier)
self.controllerView.containerView = self.view
self.controllerView.controllerSkin = controllerSkin
self.updateControllers()
}
override func viewDidAppear(animated: Bool)
{
super.viewDidAppear(animated)
self.deferredPreparationHandler?()
self.deferredPreparationHandler = nil
// Yes, order DOES matter here, in order to prevent audio from being slightly delayed after peeking with 3D Touch (ugh so tired of that issue)
switch self.emulatorCore.state
{
case .Stopped:
self.emulatorCore.startEmulation()
self.updateCheats()
case .Running: break
case .Paused:
self.updateCheats()
self.resumeEmulation()
}
// Toggle audioManager.enabled to reset the audio buffer and ensure the audio isn't delayed from the beginning
// This is especially noticeable when peeking a game
self.emulatorCore.audioManager.enabled = false
self.emulatorCore.audioManager.enabled = true
}
override func viewDidLayoutSubviews()
{
super.viewDidLayoutSubviews()
if Settings.localControllerPlayerIndex != nil && self.controllerView.intrinsicContentSize() != CGSize(width: UIViewNoIntrinsicMetric, height: UIViewNoIntrinsicMetric) && !self.isPreviewing
{
let scale = self.view.bounds.width / self.controllerView.intrinsicContentSize().width
self.controllerViewHeightConstraint.constant = self.controllerView.intrinsicContentSize().height * scale
}
else
{
self.controllerViewHeightConstraint.constant = 0
}
self.controllerView.hidden = self.isPreviewing
}
override func prefersStatusBarHidden() -> Bool
{
return true
}
/// <UIContentContainer>
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)
{
super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
self.controllerView.beginAnimatingUpdateControllerSkin()
coordinator.animateAlongsideTransition({ _ in
if self.emulatorCore.state == .Paused
{
// We need to manually "refresh" the game screen, otherwise the system tries to cache the rendered image, but skews it incorrectly when rotating b/c of UIVisualEffectView
self.gameView.inputImage = self.gameView.outputImage
}
}, completion: { _ in
self.controllerView.finishAnimatingUpdateControllerSkin()
})
}
// MARK: - Navigation -
// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?)
{
self.pauseEmulation()
if segue.identifier == "pauseSegue"
{
guard let gameController = sender as? GameControllerProtocol else { fatalError("sender for pauseSegue must be the game controller that pressed the Menu button") }
self.pausingGameController = gameController
let pauseViewController = segue.destinationViewController as! PauseViewController
pauseViewController.pauseText = self.game.name
// Swift has a bug where using unowned references can lead to swift_abortRetainUnowned errors.
// Specifically, if you pause a game, open the save states menu, go back, return to menu, select a new game, then try to pause it, it will crash
// As a dirty workaround, we just use a weak reference, and force unwrap it if needed
let saveStateItem = PauseItem(image: UIImage(named: "SaveSaveState")!, text: NSLocalizedString("Save State", comment: ""), action: { [unowned self] _ in
pauseViewController.presentSaveStateViewControllerWithMode(.Saving, delegate: self)
})
let loadStateItem = PauseItem(image: UIImage(named: "LoadSaveState")!, text: NSLocalizedString("Load State", comment: ""), action: { [unowned self] _ in
pauseViewController.presentSaveStateViewControllerWithMode(.Loading, delegate: self)
})
let cheatCodesItem = PauseItem(image: UIImage(named: "SmallPause")!, text: NSLocalizedString("Cheat Codes", comment: ""), action: { [unowned self] _ in
pauseViewController.presentCheatsViewController(delegate: self)
})
var sustainButtonsItem = PauseItem(image: UIImage(named: "SmallPause")!, text: NSLocalizedString("Sustain Buttons", comment: ""), action: { [unowned self] item in
self.resetSustainedInputs(forGameController: gameController)
if item.selected
{
self.showSustainButtonView()
pauseViewController.dismiss()
}
})
sustainButtonsItem.selected = self.sustainedInputs[ObjectIdentifier(gameController)]?.count > 0
var fastForwardItem = PauseItem(image: UIImage(named: "FastForward")!, text: NSLocalizedString("Fast Forward", comment: ""), action: { [unowned self] item in
self.emulatorCore.rate = item.selected ? self.emulatorCore.supportedRates.end : self.emulatorCore.supportedRates.start
})
fastForwardItem.selected = self.emulatorCore.rate == self.emulatorCore.supportedRates.start ? false : true
pauseViewController.items = [saveStateItem, loadStateItem, cheatCodesItem, fastForwardItem, sustainButtonsItem]
self.pauseViewController = pauseViewController
}
}
@IBAction func unwindFromPauseViewController(segue: UIStoryboardSegue)
{
self.pauseViewController = nil
self.pausingGameController = nil
if self.resumeEmulation()
{
// Temporarily disable audioManager to prevent delayed audio bug when using 3D Touch Peek & Pop
self.emulatorCore.audioManager.enabled = false
// Re-enable after delay
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(0.1 * Double(NSEC_PER_SEC))), dispatch_get_main_queue()) {
self.emulatorCore.audioManager.enabled = true
}
}
}
//MARK: - 3D Touch -
/// 3D Touch
override func previewActionItems() -> [UIPreviewActionItem]
{
if let previewActionItems = self.overridePreviewActionItems
{
return previewActionItems
}
let presentingViewController = self.presentingViewController
let launchGameAction = UIPreviewAction(title: NSLocalizedString("Launch \(self.game.name)", comment: ""), style: .Default) { (action, viewController) in
// Delaying until next run loop prevents self from being dismissed immediately
dispatch_async(dispatch_get_main_queue()) {
presentingViewController?.presentViewController(viewController, animated: true, completion: nil)
}
}
return [launchGameAction]
}
}
//MARK: - Emulation -
/// Emulation
private extension EmulationViewController
{
func pause(sender sender: AnyObject?)
{
self.performSegueWithIdentifier("pauseSegue", sender: sender)
}
func pauseEmulation() -> Bool
{
return self.emulatorCore.pauseEmulation()
}
func resumeEmulation() -> Bool
{
guard !self.choosingSustainedButtons && self.pauseViewController == nil else { return false }
return self.emulatorCore.resumeEmulation()
}
func emulatorCoreDidUpdate(emulatorCore: EmulatorCore)
{
for semaphore in self.updateSemaphores
{
dispatch_semaphore_signal(semaphore.semaphore)
}
}
}
//MARK: - Controllers -
/// Controllers
private extension EmulationViewController
{
@objc func updateControllers()
{
self.emulatorCore.removeAllGameControllers()
if let index = Settings.localControllerPlayerIndex
{
self.controllerView.playerIndex = index
}
var controllers = [GameControllerProtocol]()
controllers.append(self.controllerView)
// We need to map each item as a GameControllerProtocol due to a Swift bug
controllers.appendContentsOf(ExternalControllerManager.sharedManager.connectedControllers.map { $0 as GameControllerProtocol })
for controller in controllers
{
if let index = controller.playerIndex
{
self.emulatorCore.setGameController(controller, atIndex: index)
controller.addReceiver(self)
}
else
{
controller.removeReceiver(self)
}
}
self.view.setNeedsLayout()
}
}
//MARK: - Sustain Button -
private extension EmulationViewController
{
func showSustainButtonView()
{
self.choosingSustainedButtons = true
self.sustainButtonContentView.hidden = false
}
func hideSustainButtonView()
{
self.choosingSustainedButtons = false
UIView.animateWithDuration(0.4, animations: {
self.sustainButtonContentView.alpha = 0.0
}) { (finished) in
self.sustainButtonContentView.hidden = true
self.sustainButtonContentView.alpha = 1.0
}
}
func resetSustainedInputs(forGameController gameController: GameControllerProtocol)
{
if let previousInputs = self.sustainedInputs[ObjectIdentifier(gameController)]
{
let receivers = gameController.receivers
receivers.forEach { gameController.removeReceiver($0) }
// Activate previousInputs without notifying anyone so we can then deactivate them
// We do this because deactivating an already deactivated input has no effect
previousInputs.forEach { gameController.activate($0) }
receivers.forEach { gameController.addReceiver($0) }
// Deactivate previously sustained inputs
previousInputs.forEach { gameController.deactivate($0) }
}
self.sustainedInputs[ObjectIdentifier(gameController)] = []
}
func addSustainedInput(input: InputType, gameController: GameControllerProtocol)
{
var inputs = self.sustainedInputs[ObjectIdentifier(gameController)] ?? []
guard !inputs.contains({ $0.isEqual(input) }) else { return }
inputs.append(input)
self.sustainedInputs[ObjectIdentifier(gameController)] = inputs
let receivers = gameController.receivers
receivers.forEach { gameController.removeReceiver($0) }
// Causes input to be considered deactivated, so gameController won't send a subsequent message to observers when user actually deactivates
// However, at this point the core still thinks it is activated, and is temporarily not a receiver, thus sustaining it
gameController.deactivate(input)
receivers.forEach { gameController.addReceiver($0) }
}
func reactivateSustainedInput(input: InputType, gameController: GameControllerProtocol)
{
// These MUST be performed serially, or else Bad Things Happen if multiple inputs are reactivated at once
self.reactivateSustainInputsQueue.addOperationWithBlock {
// The manual activations/deactivations here are hidden implementation details, so we won't notify ourselves about them
gameController.removeReceiver(self)
// Must deactivate first so core recognizes a secondary activation
gameController.deactivate(input)
let dispatchQueue = dispatch_queue_create("com.rileytestut.Delta.sustainButtonsQueue", DISPATCH_QUEUE_SERIAL)
dispatch_async(dispatchQueue) {
let semaphore = DispatchSemaphore(value: 0)
self.updateSemaphores.insert(semaphore)
// To ensure the emulator core recognizes us activating the input again, we need to wait at least two frames
// Unfortunately we cannot init DispatchSemaphore with value less than 0
// To compensate, we simply wait twice; once the first wait returns, we wait again
dispatch_semaphore_wait(semaphore.semaphore, DISPATCH_TIME_FOREVER)
dispatch_semaphore_wait(semaphore.semaphore, DISPATCH_TIME_FOREVER)
// These MUST be performed serially, or else Bad Things Happen if multiple inputs are reactivated at once
self.reactivateSustainInputsQueue.addOperationWithBlock {
self.updateSemaphores.remove(semaphore)
// Ensure we still are not a receiver (to prevent rare race conditions)
gameController.removeReceiver(self)
gameController.activate(input)
let receivers = gameController.receivers
receivers.forEach { gameController.removeReceiver($0) }
// Causes input to be considered deactivated, so gameController won't send a subsequent message to observers when user actually deactivates
// However, at this point the core still thinks it is activated, and is temporarily not a receiver, thus sustaining it
gameController.deactivate(input)
receivers.forEach { gameController.addReceiver($0) }
}
// More Bad Things Happen if we add self as observer before ALL reactivations have occurred (notable, infinite loops)
self.reactivateSustainInputsQueue.waitUntilAllOperationsAreFinished()
gameController.addReceiver(self)
}
}
}
}
//MARK: - Save States
/// Save States
extension EmulationViewController: SaveStatesViewControllerDelegate
{
func saveStatesViewControllerActiveEmulatorCore(saveStatesViewController: SaveStatesViewController) -> EmulatorCore
{
return self.emulatorCore
}
func saveStatesViewController(saveStatesViewController: SaveStatesViewController, updateSaveState saveState: SaveState)
{
guard let filepath = saveState.fileURL.path else { return }
var updatingExistingSaveState = true
self.emulatorCore.saveSaveState { temporarySaveState in
do
{
if NSFileManager.defaultManager().fileExistsAtPath(filepath)
{
try NSFileManager.defaultManager().replaceItemAtURL(saveState.fileURL, withItemAtURL: temporarySaveState.fileURL, backupItemName: nil, options: [], resultingItemURL: nil)
}
else
{
try NSFileManager.defaultManager().moveItemAtURL(temporarySaveState.fileURL, toURL: saveState.fileURL)
updatingExistingSaveState = false
}
}
catch let error as NSError
{
print(error)
}
}
if let outputImage = self.gameView.outputImage
{
let quartzImage = self.context.createCGImage(outputImage, fromRect: outputImage.extent)
let image = UIImage(CGImage: quartzImage)
UIImagePNGRepresentation(image)?.writeToURL(saveState.imageFileURL, atomically: true)
}
saveState.modifiedDate = NSDate()
// Dismiss if updating an existing save state.
// If creating a new one, don't dismiss.
if updatingExistingSaveState
{
self.pauseViewController?.dismiss()
}
}
func saveStatesViewController(saveStatesViewController: SaveStatesViewController, loadSaveState saveState: SaveStateType)
{
do
{
try self.emulatorCore.loadSaveState(saveState)
}
catch EmulatorCore.SaveStateError.doesNotExist
{
print("Save State \(saveState.name) does not exist.")
}
catch let error as NSError
{
print(error)
}
self.updateCheats()
self.pauseViewController?.dismiss()
}
}
//MARK: - Cheats
/// Cheats
extension EmulationViewController: CheatsViewControllerDelegate
{
func cheatsViewControllerActiveEmulatorCore(saveStatesViewController: CheatsViewController) -> EmulatorCore
{
return self.emulatorCore
}
func cheatsViewController(cheatsViewController: CheatsViewController, didActivateCheat cheat: Cheat) throws
{
try self.emulatorCore.activateCheat(cheat)
}
func cheatsViewController(cheatsViewController: CheatsViewController, didDeactivateCheat cheat: Cheat)
{
self.emulatorCore.deactivateCheat(cheat)
}
private func updateCheats()
{
let backgroundContext = DatabaseManager.sharedManager.backgroundManagedObjectContext()
backgroundContext.performBlockAndWait {
let running = (self.emulatorCore.state == .Running)
if running
{
// Core MUST be paused when activating cheats, or else race conditions could crash the core
self.pauseEmulation()
}
let predicate = NSPredicate(format: "%K == %@", Cheat.Attributes.game.rawValue, self.emulatorCore.game as! Game)
let cheats = Cheat.instancesWithPredicate(predicate, inManagedObjectContext: backgroundContext, type: Cheat.self)
for cheat in cheats
{
if cheat.enabled
{
do
{
try self.emulatorCore.activateCheat(cheat)
}
catch EmulatorCore.CheatError.invalid
{
print("Invalid cheat:", cheat.name, cheat.code)
}
catch let error as NSError
{
print("Unknown Cheat Error:", error, cheat.name, cheat.code)
}
}
else
{
self.emulatorCore.deactivateCheat(cheat)
}
}
if running
{
self.resumeEmulation()
}
}
}
}
//MARK: - App Lifecycle -
private extension EmulationViewController
{
@objc func willResignActive(notification: NSNotification)
{
self.pauseEmulation()
}
@objc func didBecomeActive(notification: NSNotification)
{
self.resumeEmulation()
}
}
//MARK: - <GameControllerReceiver> -
/// <GameControllerReceiver>
extension EmulationViewController: GameControllerReceiverProtocol
{
func gameController(gameController: GameControllerProtocol, didActivateInput input: InputType)
{
if gameController is ControllerView && UIDevice.currentDevice().supportsVibration
{
UIDevice.currentDevice().vibrate()
}
if let input = input as? ControllerInput
{
switch input
{
case ControllerInput.Menu:
if self.choosingSustainedButtons { self.hideSustainButtonView() }
self.pause(sender: gameController)
// Return now, because Menu cannot be sustained
return
}
}
if self.choosingSustainedButtons
{
self.addSustainedInput(input, gameController: gameController)
return
}
if let sustainedInputs = self.sustainedInputs[ObjectIdentifier(gameController)] where sustainedInputs.contains({ $0.isEqual(input) })
{
// Perform on next run loop
dispatch_async(dispatch_get_main_queue()) {
self.reactivateSustainedInput(input, gameController: gameController)
}
return
}
}
func gameController(gameController: GameControllerProtocol, didDeactivateInput input: InputType)
{
guard let input = input as? ControllerInput else { return }
print("Deactivated \(input)")
}
}