├── .gitignore ├── LICENSE ├── README.md ├── app ├── SwiftBoy.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ ├── Swift Boy Release.xcscheme │ │ └── Swift Boy.xcscheme └── SwiftBoy │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── AppIcon1024x1024.png │ │ ├── AppIcon20x20.png │ │ ├── AppIcon20x20@2x-1.png │ │ ├── AppIcon20x20@2x.png │ │ ├── AppIcon20x20@3x.png │ │ ├── AppIcon29x29.png │ │ ├── AppIcon29x29@2x-1.png │ │ ├── AppIcon29x29@2x.png │ │ ├── AppIcon29x29@3x.png │ │ ├── AppIcon40x40.png │ │ ├── AppIcon40x40@2x-1.png │ │ ├── AppIcon40x40@2x.png │ │ ├── AppIcon40x40@3x.png │ │ ├── AppIcon60x60@2x.png │ │ ├── AppIcon60x60@3x.png │ │ ├── AppIcon76x76.png │ │ ├── AppIcon76x76@2x.png │ │ ├── AppIcon83.5x83.5@2x.png │ │ └── Contents.json │ ├── Contents.json │ └── LaunchScreenColor.colorset │ │ └── Contents.json │ ├── Base.lproj │ └── Main.storyboard │ ├── Emulator │ ├── APU.swift │ ├── CPU.swift │ ├── Cartridge.swift │ ├── Clock.swift │ ├── Common.swift │ ├── FileSystem.swift │ ├── GameLibraryManager.swift │ ├── Instructions.swift │ ├── Joypad.swift │ ├── MMU.swift │ ├── PPU.swift │ ├── Timer.swift │ └── UI.swift │ ├── Info.plist │ ├── ROMs │ └── cpu_instrs.gb │ ├── SceneDelegate.swift │ └── ViewController.swift ├── assets ├── icloud-drive-portrait.png ├── icon-rounded.png ├── icon.png ├── icon.sketch ├── icon.svg ├── import-menu-landscape.png ├── import-menu-portrait.gif ├── import-menu-portrait.mov ├── import-menu-portrait.png ├── super-marioland-gameplay-landscape.gif ├── super-marioland-gameplay-landscape.mov ├── super-marioland-gameplay-landscape.png ├── super-marioland-gameplay-portrait.gif ├── super-marioland-gameplay-portrait.mov ├── super-marioland-gameplay-portrait.png ├── super-marioland-main-landscape.png ├── super-marioland-main-portrait.png ├── tetris-gameplay-landscape.png ├── tetris-gameplay-portrait.gif ├── tetris-gameplay-portrait.mov ├── tetris-main-landscape.png ├── tetris-main-portrait.png ├── tetris-music-landscape.png └── tetris-music-portrait.png ├── blog-posts └── part-one.md └── gb-cpu-code-generator ├── format.js ├── generate.js ├── package-lock.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/swift 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift 4 | 5 | ### Swift ### 6 | # Xcode 7 | # 8 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 9 | 10 | ## User settings 11 | xcuserdata/ 12 | 13 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 14 | *.xcscmblueprint 15 | *.xccheckout 16 | 17 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 18 | build/ 19 | DerivedData/ 20 | *.moved-aside 21 | *.pbxuser 22 | !default.pbxuser 23 | *.mode1v3 24 | !default.mode1v3 25 | *.mode2v3 26 | !default.mode2v3 27 | *.perspectivev3 28 | !default.perspectivev3 29 | 30 | ## Obj-C/Swift specific 31 | *.hmap 32 | 33 | ## App packaging 34 | *.ipa 35 | *.dSYM.zip 36 | *.dSYM 37 | 38 | ## Playgrounds 39 | timeline.xctimeline 40 | playground.xcworkspace 41 | 42 | # Swift Package Manager 43 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 44 | # Packages/ 45 | # Package.pins 46 | # Package.resolved 47 | # *.xcodeproj 48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 49 | # hence it is not needed unless you have added a package configuration file to your project 50 | # .swiftpm 51 | 52 | .build/ 53 | 54 | # CocoaPods 55 | # We recommend against adding the Pods directory to your .gitignore. However 56 | # you should judge for yourself, the pros and cons are mentioned at: 57 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 58 | # Pods/ 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 64 | # Carthage/Checkouts 65 | 66 | Carthage/Build/ 67 | 68 | # Accio dependency management 69 | Dependencies/ 70 | .accio/ 71 | 72 | # fastlane 73 | # It is recommended to not store the screenshots in the git repo. 74 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 75 | # For more information about the recommended setup visit: 76 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 77 | 78 | fastlane/report.xml 79 | fastlane/Preview.html 80 | fastlane/screenshots/**/*.png 81 | fastlane/test_output 82 | 83 | # Code Injection 84 | # After new code Injection tools there's a generated folder /iOSInjectionProject 85 | # https://github.com/johnno1962/injectionforxcode 86 | 87 | iOSInjectionProject/ 88 | 89 | # End of https://www.toptal.com/developers/gitignore/api/swift 90 | 91 | # Custom additions 92 | 93 | node_modules/ 94 | .expo/* 95 | npm-debug.* 96 | *.jks 97 | *.p8 98 | *.p12 99 | *.key 100 | *.mobileprovision 101 | *.orig.* 102 | web-build/ 103 | /gb-cpu-code-generator/generated.swift 104 | /gb-cpu-code-generator/formatted.swift 105 | *.DS_Store 106 | deadeus.gb 107 | super-mario-land.gb 108 | tetris.gb 109 | 110 | # End of custom additions 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Boris Berak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Swift Boy 3 |

4 | 5 | # Swift Boy 6 | 7 | A Game Boy emulator for iOS 📱 8 | 9 | ## Getting Started 10 | 11 | 1. Clone the repo: `git clone https://github.com/bberak/swift-boy.git` 12 | 2. Open `app/SwiftBoy.xcodeproc` with XCode 13 | 3. Select `Swift Boy Release` scheme and target device 14 | 4. Build, run and enjoy some nostalgic gaming 🎉 15 | 16 | ## Importing Games 17 | 18 | You can import your own legally-obtained Game Boy ROMs (.gb files) by clicking the title above the *LCD* screen and clicking the big **IMPORT GAME** button at the bottom of the modal 👍. 19 | 20 |

21 | 22 | 23 | 24 |

25 | 26 | ## Links and Resources 27 | 28 | - [Game Boy CPU Instructions Database](https://gist.github.com/bberak/ca001281bb8431d2706afd31401e802b) 29 | - [Blargg's Test ROMs](https://github.com/retrio/gb-test-roms) 30 | - [Mooneye Test Suite](https://github.com/Gekkio/mooneye-test-suite) 31 | - [Game Boy Complete Technical Reference](https://github.com/Gekkio/gb-ctr) 32 | - [Pan Docs](https://gbdev.io/pandocs/) 33 | 34 | ## TODOs 35 | 36 | - Better MBC support (currently only partially supports MBC0 and MBC1) 37 | - The sound synthesizer is not quite right yet 38 | - Handle transparent pixels and sprite priority 39 | - A bunch of other stuff 40 | 41 | ## License 42 | 43 | MIT License 44 | 45 | Copyright (c) 2022 Boris Berak 46 | 47 | Permission is hereby granted, free of charge, to any person obtaining a copy 48 | of this software and associated documentation files (the "Software"), to deal 49 | in the Software without restriction, including without limitation the rights 50 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 51 | copies of the Software, and to permit persons to whom the Software is 52 | furnished to do so, subject to the following conditions: 53 | 54 | The above copyright notice and this permission notice shall be included in all 55 | copies or substantial portions of the Software. 56 | 57 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 58 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 59 | FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE 60 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 61 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 62 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 63 | SOFTWARE. -------------------------------------------------------------------------------- /app/SwiftBoy.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A11768D127E55F2600DC4751 /* AudioKit in Frameworks */ = {isa = PBXBuildFile; productRef = A11768D027E55F2600DC4751 /* AudioKit */; }; 11 | A11768D427E55F4100DC4751 /* SoundpipeAudioKit in Frameworks */ = {isa = PBXBuildFile; productRef = A11768D327E55F4100DC4751 /* SoundpipeAudioKit */; }; 12 | A12B15FA2774851C00418291 /* Joypad.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B15F92774851C00418291 /* Joypad.swift */; }; 13 | A13BADBA284613F7001B9FF6 /* FileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A13BADB9284613F7001B9FF6 /* FileSystem.swift */; }; 14 | A13BADBC284636EE001B9FF6 /* UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A13BADBB284636EE001B9FF6 /* UI.swift */; }; 15 | A1779F0728669826005E6C8C /* GameLibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1779F0628669826005E6C8C /* GameLibraryManager.swift */; }; 16 | A1785E70283BA1D3006CD7AC /* cpu_instrs.gb in Resources */ = {isa = PBXBuildFile; fileRef = A1785E6A283BA1D3006CD7AC /* cpu_instrs.gb */; }; 17 | A1B4011226EB5937002F4512 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4011126EB5937002F4512 /* AppDelegate.swift */; }; 18 | A1B4011426EB5937002F4512 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4011326EB5937002F4512 /* SceneDelegate.swift */; }; 19 | A1B4011626EB5937002F4512 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4011526EB5937002F4512 /* ViewController.swift */; }; 20 | A1B4011926EB5937002F4512 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A1B4011726EB5937002F4512 /* Main.storyboard */; }; 21 | A1B4011B26EB593A002F4512 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A1B4011A26EB593A002F4512 /* Assets.xcassets */; }; 22 | A1B4014726EB5DB3002F4512 /* Clock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4013F26EB5DB3002F4512 /* Clock.swift */; }; 23 | A1B4014826EB5DB3002F4512 /* Cartridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4014026EB5DB3002F4512 /* Cartridge.swift */; }; 24 | A1B4014926EB5DB3002F4512 /* Instructions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4014126EB5DB3002F4512 /* Instructions.swift */; }; 25 | A1B4014B26EB5DB3002F4512 /* PPU.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4014326EB5DB3002F4512 /* PPU.swift */; }; 26 | A1B4014C26EB5DB3002F4512 /* MMU.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4014426EB5DB3002F4512 /* MMU.swift */; }; 27 | A1B4014D26EB5DB3002F4512 /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4014526EB5DB3002F4512 /* Common.swift */; }; 28 | A1B4014E26EB5DB3002F4512 /* CPU.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B4014626EB5DB3002F4512 /* CPU.swift */; }; 29 | A1DCD2D52709696B001CF043 /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1DCD2D42709696B001CF043 /* Timer.swift */; }; 30 | A1F3754D2789881C00EDEC23 /* APU.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F3754C2789881C00EDEC23 /* APU.swift */; }; 31 | /* End PBXBuildFile section */ 32 | 33 | /* Begin PBXFileReference section */ 34 | A12B15F92774851C00418291 /* Joypad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Joypad.swift; sourceTree = ""; }; 35 | A13BADB9284613F7001B9FF6 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = ""; }; 36 | A13BADBB284636EE001B9FF6 /* UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UI.swift; sourceTree = ""; }; 37 | A1779F0628669826005E6C8C /* GameLibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameLibraryManager.swift; sourceTree = ""; }; 38 | A1785E6A283BA1D3006CD7AC /* cpu_instrs.gb */ = {isa = PBXFileReference; lastKnownFileType = file; path = cpu_instrs.gb; sourceTree = ""; }; 39 | A1B4010E26EB5937002F4512 /* SwiftBoy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftBoy.app; sourceTree = BUILT_PRODUCTS_DIR; }; 40 | A1B4011126EB5937002F4512 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 41 | A1B4011326EB5937002F4512 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 42 | A1B4011526EB5937002F4512 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 43 | A1B4011826EB5937002F4512 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 44 | A1B4011A26EB593A002F4512 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 45 | A1B4011F26EB593A002F4512 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 46 | A1B4013F26EB5DB3002F4512 /* Clock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Clock.swift; sourceTree = ""; }; 47 | A1B4014026EB5DB3002F4512 /* Cartridge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cartridge.swift; sourceTree = ""; }; 48 | A1B4014126EB5DB3002F4512 /* Instructions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Instructions.swift; sourceTree = ""; }; 49 | A1B4014326EB5DB3002F4512 /* PPU.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PPU.swift; sourceTree = ""; }; 50 | A1B4014426EB5DB3002F4512 /* MMU.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MMU.swift; sourceTree = ""; }; 51 | A1B4014526EB5DB3002F4512 /* Common.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = ""; }; 52 | A1B4014626EB5DB3002F4512 /* CPU.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CPU.swift; sourceTree = ""; }; 53 | A1DCD2D42709696B001CF043 /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = ""; }; 54 | A1F3754C2789881C00EDEC23 /* APU.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APU.swift; sourceTree = ""; }; 55 | /* End PBXFileReference section */ 56 | 57 | /* Begin PBXFrameworksBuildPhase section */ 58 | A1B4010B26EB5937002F4512 /* Frameworks */ = { 59 | isa = PBXFrameworksBuildPhase; 60 | buildActionMask = 2147483647; 61 | files = ( 62 | A11768D427E55F4100DC4751 /* SoundpipeAudioKit in Frameworks */, 63 | A11768D127E55F2600DC4751 /* AudioKit in Frameworks */, 64 | ); 65 | runOnlyForDeploymentPostprocessing = 0; 66 | }; 67 | /* End PBXFrameworksBuildPhase section */ 68 | 69 | /* Begin PBXGroup section */ 70 | A1B4010526EB5937002F4512 = { 71 | isa = PBXGroup; 72 | children = ( 73 | A1B4011026EB5937002F4512 /* SwiftBoy */, 74 | A1B4010F26EB5937002F4512 /* Products */, 75 | ); 76 | sourceTree = ""; 77 | }; 78 | A1B4010F26EB5937002F4512 /* Products */ = { 79 | isa = PBXGroup; 80 | children = ( 81 | A1B4010E26EB5937002F4512 /* SwiftBoy.app */, 82 | ); 83 | name = Products; 84 | sourceTree = ""; 85 | }; 86 | A1B4011026EB5937002F4512 /* SwiftBoy */ = { 87 | isa = PBXGroup; 88 | children = ( 89 | A1B4014F26EB5DCE002F4512 /* ROMs */, 90 | A1B4013E26EB5DB3002F4512 /* Emulator */, 91 | A1B4011126EB5937002F4512 /* AppDelegate.swift */, 92 | A1B4011326EB5937002F4512 /* SceneDelegate.swift */, 93 | A1B4011526EB5937002F4512 /* ViewController.swift */, 94 | A1B4011726EB5937002F4512 /* Main.storyboard */, 95 | A1B4011A26EB593A002F4512 /* Assets.xcassets */, 96 | A1B4011F26EB593A002F4512 /* Info.plist */, 97 | ); 98 | path = SwiftBoy; 99 | sourceTree = ""; 100 | }; 101 | A1B4013E26EB5DB3002F4512 /* Emulator */ = { 102 | isa = PBXGroup; 103 | children = ( 104 | A1B4013F26EB5DB3002F4512 /* Clock.swift */, 105 | A1B4014026EB5DB3002F4512 /* Cartridge.swift */, 106 | A1B4014126EB5DB3002F4512 /* Instructions.swift */, 107 | A1B4014326EB5DB3002F4512 /* PPU.swift */, 108 | A1B4014426EB5DB3002F4512 /* MMU.swift */, 109 | A1B4014526EB5DB3002F4512 /* Common.swift */, 110 | A1B4014626EB5DB3002F4512 /* CPU.swift */, 111 | A1DCD2D42709696B001CF043 /* Timer.swift */, 112 | A12B15F92774851C00418291 /* Joypad.swift */, 113 | A1F3754C2789881C00EDEC23 /* APU.swift */, 114 | A13BADB9284613F7001B9FF6 /* FileSystem.swift */, 115 | A13BADBB284636EE001B9FF6 /* UI.swift */, 116 | A1779F0628669826005E6C8C /* GameLibraryManager.swift */, 117 | ); 118 | path = Emulator; 119 | sourceTree = ""; 120 | }; 121 | A1B4014F26EB5DCE002F4512 /* ROMs */ = { 122 | isa = PBXGroup; 123 | children = ( 124 | A1785E6A283BA1D3006CD7AC /* cpu_instrs.gb */, 125 | ); 126 | path = ROMs; 127 | sourceTree = ""; 128 | }; 129 | /* End PBXGroup section */ 130 | 131 | /* Begin PBXNativeTarget section */ 132 | A1B4010D26EB5937002F4512 /* SwiftBoy */ = { 133 | isa = PBXNativeTarget; 134 | buildConfigurationList = A1B4012226EB593A002F4512 /* Build configuration list for PBXNativeTarget "SwiftBoy" */; 135 | buildPhases = ( 136 | A1B4010A26EB5937002F4512 /* Sources */, 137 | A1B4010B26EB5937002F4512 /* Frameworks */, 138 | A1B4010C26EB5937002F4512 /* Resources */, 139 | ); 140 | buildRules = ( 141 | ); 142 | dependencies = ( 143 | ); 144 | name = SwiftBoy; 145 | packageProductDependencies = ( 146 | A11768D027E55F2600DC4751 /* AudioKit */, 147 | A11768D327E55F4100DC4751 /* SoundpipeAudioKit */, 148 | ); 149 | productName = "Swift Boy"; 150 | productReference = A1B4010E26EB5937002F4512 /* SwiftBoy.app */; 151 | productType = "com.apple.product-type.application"; 152 | }; 153 | /* End PBXNativeTarget section */ 154 | 155 | /* Begin PBXProject section */ 156 | A1B4010626EB5937002F4512 /* Project object */ = { 157 | isa = PBXProject; 158 | attributes = { 159 | LastSwiftUpdateCheck = 1130; 160 | LastUpgradeCheck = 1310; 161 | ORGANIZATIONNAME = "Boris Berak"; 162 | TargetAttributes = { 163 | A1B4010D26EB5937002F4512 = { 164 | CreatedOnToolsVersion = 11.3; 165 | }; 166 | }; 167 | }; 168 | buildConfigurationList = A1B4010926EB5937002F4512 /* Build configuration list for PBXProject "SwiftBoy" */; 169 | compatibilityVersion = "Xcode 9.3"; 170 | developmentRegion = en; 171 | hasScannedForEncodings = 0; 172 | knownRegions = ( 173 | en, 174 | Base, 175 | ); 176 | mainGroup = A1B4010526EB5937002F4512; 177 | packageReferences = ( 178 | A11768CF27E55F2600DC4751 /* XCRemoteSwiftPackageReference "AudioKit" */, 179 | A11768D227E55F4100DC4751 /* XCRemoteSwiftPackageReference "SoundpipeAudioKit" */, 180 | ); 181 | productRefGroup = A1B4010F26EB5937002F4512 /* Products */; 182 | projectDirPath = ""; 183 | projectRoot = ""; 184 | targets = ( 185 | A1B4010D26EB5937002F4512 /* SwiftBoy */, 186 | ); 187 | }; 188 | /* End PBXProject section */ 189 | 190 | /* Begin PBXResourcesBuildPhase section */ 191 | A1B4010C26EB5937002F4512 /* Resources */ = { 192 | isa = PBXResourcesBuildPhase; 193 | buildActionMask = 2147483647; 194 | files = ( 195 | A1B4011B26EB593A002F4512 /* Assets.xcassets in Resources */, 196 | A1785E70283BA1D3006CD7AC /* cpu_instrs.gb in Resources */, 197 | A1B4011926EB5937002F4512 /* Main.storyboard in Resources */, 198 | ); 199 | runOnlyForDeploymentPostprocessing = 0; 200 | }; 201 | /* End PBXResourcesBuildPhase section */ 202 | 203 | /* Begin PBXSourcesBuildPhase section */ 204 | A1B4010A26EB5937002F4512 /* Sources */ = { 205 | isa = PBXSourcesBuildPhase; 206 | buildActionMask = 2147483647; 207 | files = ( 208 | A1B4011626EB5937002F4512 /* ViewController.swift in Sources */, 209 | A12B15FA2774851C00418291 /* Joypad.swift in Sources */, 210 | A1B4014B26EB5DB3002F4512 /* PPU.swift in Sources */, 211 | A1B4011226EB5937002F4512 /* AppDelegate.swift in Sources */, 212 | A1B4014E26EB5DB3002F4512 /* CPU.swift in Sources */, 213 | A13BADBA284613F7001B9FF6 /* FileSystem.swift in Sources */, 214 | A1B4014726EB5DB3002F4512 /* Clock.swift in Sources */, 215 | A1779F0728669826005E6C8C /* GameLibraryManager.swift in Sources */, 216 | A1B4014D26EB5DB3002F4512 /* Common.swift in Sources */, 217 | A13BADBC284636EE001B9FF6 /* UI.swift in Sources */, 218 | A1F3754D2789881C00EDEC23 /* APU.swift in Sources */, 219 | A1B4011426EB5937002F4512 /* SceneDelegate.swift in Sources */, 220 | A1B4014926EB5DB3002F4512 /* Instructions.swift in Sources */, 221 | A1B4014C26EB5DB3002F4512 /* MMU.swift in Sources */, 222 | A1DCD2D52709696B001CF043 /* Timer.swift in Sources */, 223 | A1B4014826EB5DB3002F4512 /* Cartridge.swift in Sources */, 224 | ); 225 | runOnlyForDeploymentPostprocessing = 0; 226 | }; 227 | /* End PBXSourcesBuildPhase section */ 228 | 229 | /* Begin PBXVariantGroup section */ 230 | A1B4011726EB5937002F4512 /* Main.storyboard */ = { 231 | isa = PBXVariantGroup; 232 | children = ( 233 | A1B4011826EB5937002F4512 /* Base */, 234 | ); 235 | name = Main.storyboard; 236 | sourceTree = ""; 237 | }; 238 | /* End PBXVariantGroup section */ 239 | 240 | /* Begin XCBuildConfiguration section */ 241 | A1B4012026EB593A002F4512 /* Debug */ = { 242 | isa = XCBuildConfiguration; 243 | buildSettings = { 244 | ALWAYS_SEARCH_USER_PATHS = NO; 245 | CLANG_ANALYZER_NONNULL = YES; 246 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 247 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 248 | CLANG_CXX_LIBRARY = "libc++"; 249 | CLANG_ENABLE_MODULES = YES; 250 | CLANG_ENABLE_OBJC_ARC = YES; 251 | CLANG_ENABLE_OBJC_WEAK = YES; 252 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 253 | CLANG_WARN_BOOL_CONVERSION = YES; 254 | CLANG_WARN_COMMA = YES; 255 | CLANG_WARN_CONSTANT_CONVERSION = YES; 256 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 257 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 258 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 259 | CLANG_WARN_EMPTY_BODY = YES; 260 | CLANG_WARN_ENUM_CONVERSION = YES; 261 | CLANG_WARN_INFINITE_RECURSION = YES; 262 | CLANG_WARN_INT_CONVERSION = YES; 263 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 264 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 265 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 266 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 267 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 268 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 269 | CLANG_WARN_STRICT_PROTOTYPES = YES; 270 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 271 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 272 | CLANG_WARN_UNREACHABLE_CODE = YES; 273 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 274 | CODE_SIGN_IDENTITY = "Apple Development"; 275 | COPY_PHASE_STRIP = NO; 276 | DEBUG_INFORMATION_FORMAT = dwarf; 277 | ENABLE_HARDENED_RUNTIME = NO; 278 | ENABLE_STRICT_OBJC_MSGSEND = YES; 279 | ENABLE_TESTABILITY = YES; 280 | GCC_C_LANGUAGE_STANDARD = gnu11; 281 | GCC_DYNAMIC_NO_PIC = NO; 282 | GCC_NO_COMMON_BLOCKS = YES; 283 | GCC_OPTIMIZATION_LEVEL = 0; 284 | GCC_PREPROCESSOR_DEFINITIONS = ( 285 | "DEBUG=1", 286 | "$(inherited)", 287 | ); 288 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 289 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 290 | GCC_WARN_UNDECLARED_SELECTOR = YES; 291 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 292 | GCC_WARN_UNUSED_FUNCTION = YES; 293 | GCC_WARN_UNUSED_VARIABLE = YES; 294 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 295 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 296 | MTL_FAST_MATH = YES; 297 | ONLY_ACTIVE_ARCH = YES; 298 | OTHER_CODE_SIGN_FLAGS = ""; 299 | SDKROOT = iphoneos; 300 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 301 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 302 | }; 303 | name = Debug; 304 | }; 305 | A1B4012126EB593A002F4512 /* Release */ = { 306 | isa = XCBuildConfiguration; 307 | buildSettings = { 308 | ALWAYS_SEARCH_USER_PATHS = NO; 309 | CLANG_ANALYZER_NONNULL = YES; 310 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 311 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 312 | CLANG_CXX_LIBRARY = "libc++"; 313 | CLANG_ENABLE_MODULES = YES; 314 | CLANG_ENABLE_OBJC_ARC = YES; 315 | CLANG_ENABLE_OBJC_WEAK = YES; 316 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 317 | CLANG_WARN_BOOL_CONVERSION = YES; 318 | CLANG_WARN_COMMA = YES; 319 | CLANG_WARN_CONSTANT_CONVERSION = YES; 320 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 321 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 322 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 323 | CLANG_WARN_EMPTY_BODY = YES; 324 | CLANG_WARN_ENUM_CONVERSION = YES; 325 | CLANG_WARN_INFINITE_RECURSION = YES; 326 | CLANG_WARN_INT_CONVERSION = YES; 327 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 328 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 329 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 330 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 331 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 332 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 333 | CLANG_WARN_STRICT_PROTOTYPES = YES; 334 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 335 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 336 | CLANG_WARN_UNREACHABLE_CODE = YES; 337 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 338 | CODE_SIGN_IDENTITY = "Apple Development"; 339 | COPY_PHASE_STRIP = NO; 340 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 341 | ENABLE_HARDENED_RUNTIME = NO; 342 | ENABLE_NS_ASSERTIONS = NO; 343 | ENABLE_STRICT_OBJC_MSGSEND = YES; 344 | GCC_C_LANGUAGE_STANDARD = gnu11; 345 | GCC_NO_COMMON_BLOCKS = YES; 346 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 347 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 348 | GCC_WARN_UNDECLARED_SELECTOR = YES; 349 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 350 | GCC_WARN_UNUSED_FUNCTION = YES; 351 | GCC_WARN_UNUSED_VARIABLE = YES; 352 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 353 | MTL_ENABLE_DEBUG_INFO = NO; 354 | MTL_FAST_MATH = YES; 355 | OTHER_CODE_SIGN_FLAGS = ""; 356 | SDKROOT = iphoneos; 357 | SWIFT_COMPILATION_MODE = wholemodule; 358 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 359 | VALIDATE_PRODUCT = YES; 360 | }; 361 | name = Release; 362 | }; 363 | A1B4012326EB593A002F4512 /* Debug */ = { 364 | isa = XCBuildConfiguration; 365 | buildSettings = { 366 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 367 | CODE_SIGN_IDENTITY = "Apple Development"; 368 | CODE_SIGN_STYLE = Automatic; 369 | DEVELOPMENT_TEAM = QN6STV2X25; 370 | ENABLE_HARDENED_RUNTIME = NO; 371 | INFOPLIST_FILE = SwiftBoy/Info.plist; 372 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 373 | LD_RUNPATH_SEARCH_PATHS = ( 374 | "$(inherited)", 375 | "@executable_path/Frameworks", 376 | ); 377 | OTHER_CODE_SIGN_FLAGS = ""; 378 | PRODUCT_BUNDLE_IDENTIFIER = com.borisBerak.SwiftBoy; 379 | PRODUCT_NAME = "$(TARGET_NAME)"; 380 | PROVISIONING_PROFILE_SPECIFIER = ""; 381 | SWIFT_ENFORCE_EXCLUSIVE_ACCESS = off; 382 | SWIFT_VERSION = 5.0; 383 | TARGETED_DEVICE_FAMILY = "1,2"; 384 | }; 385 | name = Debug; 386 | }; 387 | A1B4012426EB593A002F4512 /* Release */ = { 388 | isa = XCBuildConfiguration; 389 | buildSettings = { 390 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 391 | CODE_SIGN_IDENTITY = "Apple Development"; 392 | CODE_SIGN_STYLE = Automatic; 393 | DEVELOPMENT_TEAM = QN6STV2X25; 394 | ENABLE_HARDENED_RUNTIME = NO; 395 | INFOPLIST_FILE = SwiftBoy/Info.plist; 396 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 397 | LD_RUNPATH_SEARCH_PATHS = ( 398 | "$(inherited)", 399 | "@executable_path/Frameworks", 400 | ); 401 | OTHER_CODE_SIGN_FLAGS = ""; 402 | PRODUCT_BUNDLE_IDENTIFIER = com.borisBerak.SwiftBoy; 403 | PRODUCT_NAME = "$(TARGET_NAME)"; 404 | PROVISIONING_PROFILE_SPECIFIER = ""; 405 | SWIFT_ENFORCE_EXCLUSIVE_ACCESS = off; 406 | SWIFT_VERSION = 5.0; 407 | TARGETED_DEVICE_FAMILY = "1,2"; 408 | }; 409 | name = Release; 410 | }; 411 | /* End XCBuildConfiguration section */ 412 | 413 | /* Begin XCConfigurationList section */ 414 | A1B4010926EB5937002F4512 /* Build configuration list for PBXProject "SwiftBoy" */ = { 415 | isa = XCConfigurationList; 416 | buildConfigurations = ( 417 | A1B4012026EB593A002F4512 /* Debug */, 418 | A1B4012126EB593A002F4512 /* Release */, 419 | ); 420 | defaultConfigurationIsVisible = 0; 421 | defaultConfigurationName = Release; 422 | }; 423 | A1B4012226EB593A002F4512 /* Build configuration list for PBXNativeTarget "SwiftBoy" */ = { 424 | isa = XCConfigurationList; 425 | buildConfigurations = ( 426 | A1B4012326EB593A002F4512 /* Debug */, 427 | A1B4012426EB593A002F4512 /* Release */, 428 | ); 429 | defaultConfigurationIsVisible = 0; 430 | defaultConfigurationName = Release; 431 | }; 432 | /* End XCConfigurationList section */ 433 | 434 | /* Begin XCRemoteSwiftPackageReference section */ 435 | A11768CF27E55F2600DC4751 /* XCRemoteSwiftPackageReference "AudioKit" */ = { 436 | isa = XCRemoteSwiftPackageReference; 437 | repositoryURL = "https://github.com/AudioKit/AudioKit.git"; 438 | requirement = { 439 | kind = upToNextMajorVersion; 440 | minimumVersion = 5.0.0; 441 | }; 442 | }; 443 | A11768D227E55F4100DC4751 /* XCRemoteSwiftPackageReference "SoundpipeAudioKit" */ = { 444 | isa = XCRemoteSwiftPackageReference; 445 | repositoryURL = "https://github.com/AudioKit/SoundpipeAudioKit.git"; 446 | requirement = { 447 | kind = upToNextMajorVersion; 448 | minimumVersion = 5.0.0; 449 | }; 450 | }; 451 | /* End XCRemoteSwiftPackageReference section */ 452 | 453 | /* Begin XCSwiftPackageProductDependency section */ 454 | A11768D027E55F2600DC4751 /* AudioKit */ = { 455 | isa = XCSwiftPackageProductDependency; 456 | package = A11768CF27E55F2600DC4751 /* XCRemoteSwiftPackageReference "AudioKit" */; 457 | productName = AudioKit; 458 | }; 459 | A11768D327E55F4100DC4751 /* SoundpipeAudioKit */ = { 460 | isa = XCSwiftPackageProductDependency; 461 | package = A11768D227E55F4100DC4751 /* XCRemoteSwiftPackageReference "SoundpipeAudioKit" */; 462 | productName = SoundpipeAudioKit; 463 | }; 464 | /* End XCSwiftPackageProductDependency section */ 465 | }; 466 | rootObject = A1B4010626EB5937002F4512 /* Project object */; 467 | } 468 | -------------------------------------------------------------------------------- /app/SwiftBoy.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/SwiftBoy.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/SwiftBoy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "audiokit", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/AudioKit/AudioKit.git", 7 | "state" : { 8 | "revision" : "cb50a6878535735fd1f10c8b8e5e96864630836c", 9 | "version" : "5.5.4" 10 | } 11 | }, 12 | { 13 | "identity" : "audiokitex", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/AudioKit/AudioKitEX", 16 | "state" : { 17 | "revision" : "22183bcf7e8c6baf15e59c1126a4c541e044cec5", 18 | "version" : "5.5.2" 19 | } 20 | }, 21 | { 22 | "identity" : "kissfft", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/AudioKit/KissFFT", 25 | "state" : { 26 | "revision" : "dd0636e151724b8ba2e0908eba4d99a6ff24d00c", 27 | "version" : "1.0.0" 28 | } 29 | }, 30 | { 31 | "identity" : "soundpipeaudiokit", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/AudioKit/SoundpipeAudioKit.git", 34 | "state" : { 35 | "revision" : "c9349accd3c9e102a35962cf40c998eacb6108d5", 36 | "version" : "5.5.3" 37 | } 38 | } 39 | ], 40 | "version" : 2 41 | } 42 | -------------------------------------------------------------------------------- /app/SwiftBoy.xcodeproj/xcshareddata/xcschemes/Swift Boy Release.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /app/SwiftBoy.xcodeproj/xcshareddata/xcschemes/Swift Boy.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /app/SwiftBoy/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 6 | // Override point for customization after application launch. 7 | return true 8 | } 9 | 10 | // MARK: UISceneSession Lifecycle 11 | 12 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 13 | // Called when a new scene session is being created. 14 | // Use this method to select a configuration to create the new scene with. 15 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 16 | } 17 | 18 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 19 | // Called when the user discards a scene session. 20 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 21 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon1024x1024.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon20x20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon20x20.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon20x20@2x-1.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon20x20@2x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon20x20@3x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon29x29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon29x29.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x-1.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@3x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon40x40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon40x40.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x-1.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@3x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@2x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@3x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon76x76.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon76x76@2x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/AppIcon83.5x83.5@2x.png -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon20x20@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "AppIcon20x20@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "AppIcon29x29@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "AppIcon29x29@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "AppIcon40x40@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "AppIcon40x40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "AppIcon60x60@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "AppIcon60x60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "AppIcon20x20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "AppIcon20x20@2x-1.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "AppIcon29x29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "AppIcon29x29@2x-1.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "AppIcon40x40.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "AppIcon40x40@2x-1.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "AppIcon76x76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "AppIcon76x76@2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "AppIcon83.5x83.5@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "AppIcon1024x1024.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /app/SwiftBoy/Assets.xcassets/LaunchScreenColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/SwiftBoy/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/APU.swift: -------------------------------------------------------------------------------- 1 | // TODO: Figure out a good default for master volume 🤔 2 | // TODO: A weird noise can be heard as soon as the GB is turned on 3 | // TODO: Tetris main menu and Type-B music is not sounding correct 4 | // TODO: Still need to implement the noise voice properly 5 | 6 | import Foundation 7 | import AudioKit 8 | import SoundpipeAudioKit 9 | 10 | typealias AKAmplitudeEnvelope = SoundpipeAudioKit.AmplitudeEnvelope 11 | 12 | func bitsToFrequency(bits: UInt16) -> Float { 13 | return 131072 / (2048 - Float(bits)) 14 | } 15 | 16 | func frequencyToBits(frequency: Float) -> UInt16 { 17 | if frequency == 0 { 18 | return 0 19 | } 20 | 21 | return UInt16(2048 - (131072 / frequency)) 22 | } 23 | 24 | func convertToSweepTime(byte: UInt8) -> Float { 25 | switch (byte) { 26 | case 0b000: return 0 27 | case 0b001: return 0.0078 28 | case 0b010: return 0.0156 29 | case 0b011: return 0.0234 30 | case 0b100: return 0.0313 31 | case 0b101: return 0.0391 32 | case 0b110: return 0.0469 33 | case 0b111: return 0.0547 34 | default: return -1 35 | } 36 | } 37 | 38 | func convertToPulseWidth(byte: UInt8) -> Float { 39 | switch (byte) { 40 | case 0b00000000: return 0.125 41 | case 0b01000000: return 0.25 42 | case 0b10000000: return 0.5 43 | case 0b11000000: return 0.75 44 | default: return -1 45 | } 46 | } 47 | 48 | protocol OscillatorNode: Node { 49 | var frequency: Float { get set } 50 | var amplitude: Float { get set } 51 | func rampFrequency(to: Float, duration: Float) 52 | func rampAmplitude(to: Float, duration: Float) 53 | } 54 | 55 | protocol Envelope: AnyObject { 56 | var voice: Voice? { get set } 57 | func update(seconds: Float) -> Void 58 | func onTriggered() -> Void 59 | } 60 | 61 | class Voice { 62 | let oscillator: OscillatorNode 63 | let envelopes: [Envelope] 64 | 65 | var dacEnabled: Bool = true 66 | var enabled: Bool = true 67 | var amplitude: Float = 0 68 | var frequency: Float = 0 69 | 70 | var triggered: Bool = false { 71 | didSet { 72 | onTriggered() 73 | } 74 | } 75 | 76 | init(oscillator: OscillatorNode, envelopes: [Envelope]) { 77 | self.oscillator = oscillator 78 | self.oscillator.start() 79 | self.oscillator.amplitude = 0 80 | self.oscillator.frequency = 0 81 | self.envelopes = envelopes 82 | self.envelopes.forEach { $0.voice = self } 83 | } 84 | 85 | func onTriggered() { 86 | enabled = true 87 | envelopes.forEach { $0.onTriggered() } 88 | } 89 | 90 | func update(seconds: Float) -> Bool { 91 | envelopes.forEach { $0.update(seconds: seconds) } 92 | 93 | oscillator.rampFrequency(to: frequency, duration: 0.01) 94 | 95 | if !dacEnabled { 96 | oscillator.rampAmplitude(to: 0, duration: 0.01) 97 | } else if !enabled { 98 | oscillator.rampAmplitude(to: 0, duration: 0.01) 99 | } else { 100 | oscillator.rampAmplitude(to: amplitude, duration: 0.01) 101 | } 102 | 103 | return dacEnabled && enabled 104 | } 105 | } 106 | 107 | class Synthesizer { 108 | let engine: AudioEngine 109 | let channelsLeft: [AKAmplitudeEnvelope] 110 | let channelsRight: [AKAmplitudeEnvelope] 111 | let mixerLeft: Mixer 112 | let mixerRight: Mixer 113 | let mixerMain: Mixer 114 | 115 | public var volume: Float { 116 | get { 117 | self.mixerMain.volume 118 | } 119 | set { 120 | self.mixerMain.volume = newValue 121 | } 122 | } 123 | 124 | var enabled = false { 125 | didSet { 126 | if enabled && !engine.avEngine.isRunning { 127 | try? self.engine.start() 128 | } 129 | 130 | if !enabled && engine.avEngine.isRunning { 131 | self.engine.pause() 132 | } 133 | } 134 | } 135 | 136 | init(volume: Float = 0.5, voices: [Voice]) { 137 | self.channelsLeft = voices.map { AKAmplitudeEnvelope($0.oscillator) } 138 | self.channelsLeft.forEach { $0.openGate() } 139 | self.channelsRight = voices.map { AKAmplitudeEnvelope($0.oscillator) } 140 | self.channelsRight.forEach { $0.openGate() } 141 | self.mixerLeft = Mixer(self.channelsLeft) 142 | self.mixerLeft.pan = -1 143 | self.mixerRight = Mixer(self.channelsRight) 144 | self.mixerRight.pan = 1 145 | self.mixerMain = Mixer(self.mixerLeft, self.mixerRight) 146 | self.mixerMain.volume = volume 147 | self.engine = AudioEngine() 148 | self.engine.output = self.mixerMain 149 | } 150 | 151 | func setLeftChannelVolume(_ val: Float) { 152 | self.mixerLeft.volume = val 153 | } 154 | 155 | func setRightChannelVolume(_ val: Float) { 156 | self.mixerRight.volume = val 157 | } 158 | 159 | func enableChannels(index: Int, left: Bool, right: Bool) { 160 | let channelLeft = self.channelsLeft[index] 161 | let channelRight = self.channelsRight[index] 162 | 163 | left ? channelLeft.openGate() : channelLeft.closeGate() 164 | right ? channelRight.openGate() : channelRight.closeGate() 165 | } 166 | } 167 | 168 | class LengthEnvelope: Envelope { 169 | var voice: Voice? 170 | var maxDuration: Float = 0 171 | var enabled = false 172 | var duration: Float = 0 173 | 174 | init(voice: Voice? = nil) { 175 | self.voice = voice 176 | } 177 | 178 | func update(seconds: Float) { 179 | if !enabled { 180 | return 181 | } 182 | 183 | if duration > 0 { 184 | duration -= seconds 185 | 186 | if duration <= 0 { 187 | voice?.enabled = false 188 | } 189 | } 190 | } 191 | 192 | func onTriggered() { 193 | if duration <= 0 { 194 | duration = maxDuration 195 | } 196 | } 197 | } 198 | 199 | class AmplitudeEnvelope: Envelope { 200 | private var elapsedTime: Float = 0 201 | 202 | var voice: Voice? 203 | var startStep: Int = 0 204 | var increasing = false 205 | var stepDuration: Float = 0 206 | 207 | init(_ voice: Voice? = nil) { 208 | self.voice = voice 209 | } 210 | 211 | func update(seconds: Float) { 212 | if stepDuration == 0 { 213 | return 214 | } 215 | 216 | elapsedTime += seconds 217 | 218 | let deltaSteps = Int(elapsedTime / stepDuration) * (increasing ? 1 : -1) 219 | let currentStep = (startStep + deltaSteps).clamp(min: 0, max: 0x0F) 220 | 221 | voice?.amplitude = Float(currentStep) / 0x0F 222 | } 223 | 224 | func onTriggered() { 225 | elapsedTime = 0 226 | voice?.amplitude = Float(startStep) / 0x0F 227 | } 228 | } 229 | 230 | class FrequencySweepEnvelope: Envelope { 231 | private var elapsedTime: Float = 0 232 | private var adjustedFrequency: Float = 0 { 233 | didSet { 234 | voice?.frequency = adjustedFrequency 235 | } 236 | } 237 | 238 | var voice: Voice? 239 | var startFrequency: Float = 0 240 | var sweepIncreasing = false 241 | var sweepShifts: UInt8 = 0 242 | var sweepTime: Float = 0 243 | 244 | init(_ voice: Voice? = nil) { 245 | self.voice = voice 246 | } 247 | 248 | func update(seconds: Float) { 249 | if sweepTime == 0 { 250 | return 251 | } 252 | 253 | if sweepShifts == 0 { 254 | return 255 | } 256 | 257 | elapsedTime += seconds 258 | 259 | let sweeps = Int(elapsedTime / sweepTime) 260 | let totalShifts = sweeps > 0 ? sweeps * Int(sweepShifts) : 0 261 | let shiftedValue = sweepIncreasing ? frequencyToBits(frequency: startFrequency) << totalShifts : frequencyToBits(frequency: startFrequency) >> totalShifts 262 | 263 | if shiftedValue == 0 { 264 | return 265 | } 266 | 267 | if shiftedValue > 2047 { 268 | return 269 | } 270 | 271 | adjustedFrequency = bitsToFrequency(bits: shiftedValue) 272 | } 273 | 274 | func onTriggered() { 275 | elapsedTime = 0 276 | adjustedFrequency = startFrequency 277 | } 278 | } 279 | 280 | extension PWMOscillator: OscillatorNode { 281 | func rampFrequency(to: Float, duration: Float) { 282 | $frequency.ramp(to: to, duration: duration) 283 | } 284 | 285 | func rampAmplitude(to: Float, duration: Float) { 286 | $amplitude.ramp(to: to, duration: duration) 287 | } 288 | } 289 | 290 | class Pulse: Voice { 291 | let lengthEnvelope = LengthEnvelope() 292 | let amplitudeEnvelope = AmplitudeEnvelope() 293 | 294 | var pulseWidth: Float = 0 { 295 | didSet { 296 | if pulseWidth != oldValue { 297 | if let pwm = oscillator as? PWMOscillator { 298 | pwm.pulseWidth = pulseWidth 299 | } 300 | } 301 | } 302 | } 303 | 304 | init(maxDuration: Float) { 305 | super.init(oscillator: PWMOscillator(), envelopes: [self.lengthEnvelope, self.amplitudeEnvelope]) 306 | 307 | self.lengthEnvelope.maxDuration = maxDuration 308 | } 309 | } 310 | 311 | class PulseWithSweep: Voice { 312 | let lengthEnvelope = LengthEnvelope() 313 | let amplitudeEnvelope = AmplitudeEnvelope() 314 | let frequencySweepEnvelope = FrequencySweepEnvelope() 315 | 316 | var pulseWidth: Float = 0 { 317 | didSet { 318 | if pulseWidth != oldValue { 319 | if let pwm = oscillator as? PWMOscillator { 320 | pwm.pulseWidth = pulseWidth 321 | } 322 | } 323 | } 324 | } 325 | 326 | init(maxDuration: Float) { 327 | super.init(oscillator: PWMOscillator(), envelopes: [self.lengthEnvelope, self.amplitudeEnvelope, self.frequencySweepEnvelope]) 328 | 329 | self.lengthEnvelope.maxDuration = maxDuration 330 | } 331 | } 332 | 333 | extension DynamicOscillator: OscillatorNode { 334 | func rampFrequency(to: Float, duration: Float) { 335 | $frequency.ramp(to: to, duration: duration) 336 | } 337 | 338 | func rampAmplitude(to: Float, duration: Float) { 339 | $amplitude.ramp(to: to, duration: duration) 340 | } 341 | } 342 | 343 | class CustomWave: Voice { 344 | let lengthEnvelope = LengthEnvelope() 345 | 346 | var data = [Float]() { 347 | didSet { 348 | if data != oldValue { 349 | if let dyn = oscillator as? DynamicOscillator { 350 | dyn.setWaveform(Table(data)) 351 | } 352 | } 353 | } 354 | } 355 | 356 | init(maxDuration: Float) { 357 | super.init(oscillator: DynamicOscillator(waveform: Table(.sine)), envelopes: [self.lengthEnvelope]) 358 | 359 | self.lengthEnvelope.maxDuration = maxDuration 360 | } 361 | } 362 | 363 | extension WhiteNoise: OscillatorNode { 364 | var frequency: Float { 365 | get { 0 } 366 | set { } 367 | } 368 | 369 | func rampFrequency(to: Float, duration: Float) { } 370 | 371 | func rampAmplitude(to: Float, duration: Float) { 372 | $amplitude.ramp(to: to, duration: duration) 373 | } 374 | } 375 | 376 | class Noise: Voice { 377 | let lengthEnvelope = LengthEnvelope() 378 | let amplitudeEnvelope = AmplitudeEnvelope() 379 | 380 | init(maxDuration: Float) { 381 | super.init(oscillator: WhiteNoise(), envelopes: [self.lengthEnvelope, self.amplitudeEnvelope]) 382 | 383 | self.lengthEnvelope.maxDuration = maxDuration 384 | } 385 | } 386 | 387 | public class APU { 388 | private let mmu: MMU 389 | private let master: Synthesizer 390 | private let pulseA: PulseWithSweep 391 | private let pulseB: Pulse 392 | private let customWave: CustomWave 393 | private let noise: Noise 394 | private var waveformDataMemo = Memo<[Float]>() 395 | 396 | init(_ mmu: MMU) { 397 | self.mmu = mmu 398 | self.pulseA = PulseWithSweep(maxDuration: 0.25) 399 | self.pulseB = Pulse(maxDuration: 0.25) 400 | self.customWave = CustomWave(maxDuration: 1.0) 401 | self.noise = Noise(maxDuration: 0.25) 402 | self.master = Synthesizer(voices: [self.pulseA, self.pulseB, self.customWave, self.noise]) 403 | self.master.volume = 0.125 404 | 405 | self.wireUpPulseA() 406 | self.wireUpPulseB() 407 | self.wireUpCustomWave() 408 | self.wireUpNoise() 409 | self.wireUpMaster() 410 | } 411 | 412 | func wireUpPulseA() { 413 | self.mmu.nr10.subscribe { nr10 in 414 | let sweepShifts = nr10 & 0b00000111 415 | let sweepIncreasing = nr10.bit(3) 416 | let sweepTime = convertToSweepTime(byte: (nr10 & 0b01110000) >> 4) 417 | 418 | self.pulseA.frequencySweepEnvelope.sweepShifts = sweepShifts 419 | self.pulseA.frequencySweepEnvelope.sweepIncreasing = sweepIncreasing 420 | self.pulseA.frequencySweepEnvelope.sweepTime = sweepTime 421 | } 422 | 423 | self.mmu.nr11.subscribe { nr11 in 424 | let pulseWidth = convertToPulseWidth(byte: nr11 & 0b11000000) 425 | let lengthEnvelopDuration = (64 - Float(nr11 & 0b00111111)) * (1 / 256) 426 | 427 | self.pulseA.pulseWidth = pulseWidth 428 | self.pulseA.lengthEnvelope.duration = lengthEnvelopDuration 429 | } 430 | 431 | self.mmu.nr12.subscribe { nr12 in 432 | let amplitudeEnvelopeStartStep = Int((nr12 & 0b11110000) >> 4) 433 | let amplitudeEnvelopeStepDuration = Float(nr12 & 0b00000111) * (1 / 64) 434 | let amplitudeEnvelopeIncreasing = nr12.bit(3) 435 | let dacEnabled = (nr12 & 0b11111000) != 0 436 | 437 | self.pulseA.dacEnabled = dacEnabled 438 | self.pulseA.amplitudeEnvelope.startStep = amplitudeEnvelopeStartStep 439 | self.pulseA.amplitudeEnvelope.stepDuration = amplitudeEnvelopeStepDuration 440 | self.pulseA.amplitudeEnvelope.increasing = amplitudeEnvelopeIncreasing 441 | } 442 | 443 | self.mmu.nr13.subscribe { nr13 in 444 | let bits = frequencyToBits(frequency: self.pulseA.frequencySweepEnvelope.startFrequency) 445 | let lsb = UInt16(nr13) 446 | let msb = bits & 0xFF00 447 | let frequency = bitsToFrequency(bits: lsb + msb) 448 | 449 | self.pulseA.frequencySweepEnvelope.startFrequency = frequency 450 | } 451 | 452 | self.mmu.nr14.subscribe { nr14 in 453 | let bits = frequencyToBits(frequency: self.pulseA.frequencySweepEnvelope.startFrequency) 454 | let lsb = bits & 0x00FF 455 | let msb = UInt16(nr14 & 0b00000111) << 8 456 | let frequency = bitsToFrequency(bits: lsb + msb) 457 | let lengthEnvelopEnabled = nr14.bit(6) 458 | let triggered = nr14.bit(7) 459 | 460 | self.pulseA.frequencySweepEnvelope.startFrequency = frequency 461 | self.pulseA.triggered = triggered 462 | self.pulseA.lengthEnvelope.enabled = lengthEnvelopEnabled 463 | } 464 | } 465 | 466 | func wireUpPulseB() { 467 | self.mmu.nr21.subscribe { nr21 in 468 | let pulseWidth = convertToPulseWidth(byte: nr21 & 0b11000000) 469 | let lengthEnvelopeDuration = (64 - Float(nr21 & 0b00111111)) * (1 / 256) 470 | 471 | self.pulseB.pulseWidth = pulseWidth 472 | self.pulseB.lengthEnvelope.duration = lengthEnvelopeDuration 473 | } 474 | 475 | self.mmu.nr22.subscribe { nr22 in 476 | let amplitudeEnvelopeStartStep = Int((nr22 & 0b11110000) >> 4) 477 | let amplitudeEnvelopeStepDuration = Float(nr22 & 0b00000111) * (1 / 64) 478 | let amplitudeEnvelopeIncreasing = nr22.bit(3) 479 | let dacEnabled = (nr22 & 0b11111000) != 0 480 | 481 | self.pulseB.dacEnabled = dacEnabled 482 | self.pulseB.amplitudeEnvelope.startStep = amplitudeEnvelopeStartStep 483 | self.pulseB.amplitudeEnvelope.stepDuration = amplitudeEnvelopeStepDuration 484 | self.pulseB.amplitudeEnvelope.increasing = amplitudeEnvelopeIncreasing 485 | } 486 | 487 | self.mmu.nr23.subscribe { nr23 in 488 | let bits = frequencyToBits(frequency: self.pulseB.frequency) 489 | let lsb = UInt16(nr23) 490 | let msb = bits & 0xFF00 491 | let frequency = bitsToFrequency(bits: lsb + msb) 492 | 493 | self.pulseB.frequency = frequency 494 | } 495 | 496 | self.mmu.nr24.subscribe { nr24 in 497 | let bits = frequencyToBits(frequency: self.pulseB.frequency) 498 | let lsb = bits & 0x00FF 499 | let msb = UInt16(nr24 & 0b00000111) << 8 500 | let frequency = bitsToFrequency(bits: lsb + msb) 501 | let lengthEnvelopeEnabled = nr24.bit(6) 502 | let triggered = nr24.bit(7) 503 | 504 | self.pulseB.triggered = triggered 505 | self.pulseB.frequency = frequency 506 | self.pulseB.lengthEnvelope.enabled = lengthEnvelopeEnabled 507 | } 508 | } 509 | 510 | func wireUpCustomWave() { 511 | self.mmu.nr30.subscribe { nr30 in 512 | let dacEnabled = nr30.bit(7) 513 | 514 | self.customWave.dacEnabled = dacEnabled 515 | } 516 | 517 | self.mmu.nr31.subscribe { nr31 in 518 | let lengthEnvelopeDuration = (256 - Float(nr31)) * (1 / 256) 519 | 520 | self.customWave.lengthEnvelope.duration = lengthEnvelopeDuration 521 | } 522 | 523 | self.mmu.nr33.subscribe { nr33 in 524 | let bits = frequencyToBits(frequency: self.pulseB.frequency) 525 | let lsb = UInt16(nr33) 526 | let msb = bits & 0xFF00 527 | let frequency = bitsToFrequency(bits: lsb + msb) 528 | 529 | self.customWave.frequency = frequency 530 | } 531 | 532 | self.mmu.nr34.subscribe { nr34 in 533 | let bits = frequencyToBits(frequency: self.pulseB.frequency) 534 | let lsb = bits & 0x00FF 535 | let msb = UInt16(nr34 & 0b00000111) << 8 536 | let frequency = bitsToFrequency(bits: lsb + msb) 537 | let lengthEnvelopEnabled = nr34.bit(6) 538 | let triggered = nr34.bit(7) 539 | 540 | self.customWave.triggered = triggered 541 | self.customWave.frequency = frequency 542 | self.customWave.lengthEnvelope.enabled = lengthEnvelopEnabled 543 | } 544 | } 545 | 546 | func wireUpNoise() { 547 | self.mmu.nr41.subscribe { nr41 in 548 | let lengthEnvelopeDuration = (64 - Float(nr41 & 0b00111111)) * (1 / 256) 549 | 550 | self.noise.lengthEnvelope.duration = lengthEnvelopeDuration 551 | } 552 | 553 | self.mmu.nr42.subscribe { nr42 in 554 | let amplitudeEnvelopeStartStep = Int((nr42 & 0b11110000) >> 4) 555 | let amplitudeEnvelopeStepDuration = Float(nr42 & 0b00000111) * 1 / 64 556 | let amplitudeEnvelopeIncreasing = nr42.bit(3) 557 | let dacEnabled = (nr42 & 0b11111000) != 0 558 | 559 | self.noise.dacEnabled = dacEnabled 560 | self.noise.amplitudeEnvelope.startStep = amplitudeEnvelopeStartStep 561 | self.noise.amplitudeEnvelope.stepDuration = amplitudeEnvelopeStepDuration 562 | self.noise.amplitudeEnvelope.increasing = amplitudeEnvelopeIncreasing 563 | } 564 | 565 | self.mmu.nr43.subscribe { nr43 in 566 | let temp = nr43 & 0b00000111 567 | let r = temp == 0 ? Float(0.5) : Float(temp) 568 | let s = Float(nr43 & 0b11100000) 569 | let frequency = Float(524288) / r / powf(2, s + 1.0) 570 | 571 | self.noise.frequency = frequency 572 | } 573 | 574 | self.mmu.nr44.subscribe { nr44 in 575 | let lengthEnvelopeEnabled = nr44.bit(6) 576 | let triggered = nr44.bit(7) 577 | 578 | self.noise.triggered = triggered 579 | self.noise.lengthEnvelope.enabled = lengthEnvelopeEnabled 580 | } 581 | } 582 | 583 | func wireUpMaster() { 584 | // Set left and right channel volumes 585 | self.mmu.nr50.subscribe { nr50 in 586 | self.master.setLeftChannelVolume(Float((nr50 & 0b01110000) >> 4) / 7.0) 587 | self.master.setRightChannelVolume(Float(nr50 & 0b00000111) / 7.0) 588 | } 589 | 590 | // Enabled left or right channel output for each voice 591 | self.mmu.nr51.subscribe { nr51 in 592 | self.master.enableChannels(index: 0, left: nr51.bit(4), right: nr51.bit(0)) 593 | self.master.enableChannels(index: 1, left: nr51.bit(5), right: nr51.bit(1)) 594 | self.master.enableChannels(index: 2, left: nr51.bit(6), right: nr51.bit(2)) 595 | self.master.enableChannels(index: 3, left: nr51.bit(7), right: nr51.bit(3)) 596 | } 597 | 598 | // Master sound output 599 | self.mmu.nr52.subscribe { nr52 in 600 | self.master.enabled = nr52.bit(7) 601 | } 602 | } 603 | 604 | func updateWaveformSamples() { 605 | let nr32 = self.mmu.nr32.read() 606 | let outputLevel = (nr32 & 0b01100000) >> 5 607 | let waveformData = self.waveformDataMemo.get(deps: [self.mmu.waveformRam.version, outputLevel]) { 608 | let buffer = self.mmu.waveformRam.buffer 609 | return buffer.flatMap({ [Float($0.nibble(1) >> outputLevel) / 0b1111, Float($0.nibble(0) >> outputLevel) / 0b1111 ] }).map({ $0 * 2 - 1 }) 610 | } 611 | 612 | self.customWave.data = waveformData 613 | self.customWave.amplitude = 1 614 | } 615 | 616 | public func run(seconds: Float) throws { 617 | if !self.master.enabled { 618 | return 619 | } 620 | 621 | // Update custom waveform samples 622 | self.updateWaveformSamples() 623 | 624 | // Update all voices 625 | var nr52 = self.mmu.nr52.read() 626 | 627 | nr52[0] = self.pulseA.update(seconds: seconds) 628 | nr52[1] = self.pulseB.update(seconds: seconds) 629 | nr52[2] = self.customWave.update(seconds: seconds) 630 | nr52[3] = self.noise.update(seconds: seconds) 631 | 632 | // Snippet to determine which voices are producing sound 633 | // print(nr52[0] && self.pulseA.amplitude > 0, nr52[1] && self.pulseB.amplitude > 0, nr52[2] && self.customWave.amplitude > 0, nr52[3] && self.noise.amplitude > 0) 634 | 635 | // Write nr52 back into RAM 636 | self.mmu.nr52.write(nr52, publish: false) 637 | } 638 | 639 | public func reset() { 640 | waveformDataMemo.invalidate() 641 | } 642 | } 643 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/CPU.swift: -------------------------------------------------------------------------------- 1 | // TODO: Only execute a cmd if you have accumulated enough cycles? 2 | // TODO: if cmd.cycles > cycles { 3 | // TODO: queue.insert(cmd, at: 0) 4 | // TODO: return 5 | // TODO: } 6 | 7 | struct Instruction { 8 | internal var toCommand: (CPU) throws -> Command 9 | 10 | init(toCommand: @escaping (CPU) throws -> Command) { 11 | self.toCommand = toCommand 12 | } 13 | 14 | static func atomic(cycles: UInt16, command: @escaping (CPU) throws -> Void) -> Instruction { 15 | return Instruction { cpu in 16 | return Command(cycles: cycles) { 17 | try command(cpu) 18 | return nil 19 | } 20 | } 21 | } 22 | } 23 | 24 | class Flags { 25 | internal var zero = false 26 | internal var subtract = false 27 | internal var halfCarry = false 28 | internal var carry = false 29 | 30 | func clear() { 31 | zero = false 32 | subtract = false 33 | halfCarry = false 34 | carry = false 35 | } 36 | 37 | func set(byte: UInt8) { 38 | zero = (byte & 0b10000000) != 0 39 | subtract = (byte & 0b01000000) != 0 40 | halfCarry = (byte & 0b00100000) != 0 41 | carry = (byte & 0b00010000) != 0 42 | } 43 | 44 | func toUInt8() -> UInt8 { 45 | var result: UInt8 = 0; 46 | let arr = [zero, subtract, halfCarry, carry] 47 | 48 | arr.forEach { 49 | if $0 { 50 | result+=1 51 | } 52 | 53 | result<<=1 54 | } 55 | 56 | result<<=3 57 | 58 | return result 59 | } 60 | } 61 | 62 | enum OpCode: Hashable { 63 | case byte(UInt8) 64 | case word(UInt8) 65 | } 66 | 67 | enum CPUError: Error { 68 | case instructionNotFound(OpCode) 69 | case instructionNotImplemented(OpCode) 70 | } 71 | 72 | public struct Interrupts { 73 | static let vBlank = (bit: UInt8(0), address: UInt16(0x0040)) 74 | static let lcdStat = (bit: UInt8(1), address: UInt16(0x0048)) 75 | static let timer = (bit: UInt8(2), address: UInt16(0x0050)) 76 | static let serial = (bit: UInt8(3), address: UInt16(0x0058)) 77 | static let joypad = (bit: UInt8(4), address: UInt16(0x0060)) 78 | static let prioritized = [Interrupts.vBlank, Interrupts.lcdStat, Interrupts.timer, Interrupts.serial, Interrupts.joypad] 79 | } 80 | 81 | public class CPU { 82 | internal let mmu: MMU 83 | internal let flags: Flags = Flags() 84 | internal var a: UInt8 = 0 85 | internal var b: UInt8 = 0 86 | internal var c: UInt8 = 0 87 | internal var d: UInt8 = 0 88 | internal var e: UInt8 = 0 89 | internal var h: UInt8 = 0 90 | internal var l: UInt8 = 0 91 | internal var sp: UInt16 = 0x0000 92 | internal var pc: UInt16 = 0x0000 93 | internal var ime: Bool = false 94 | private var queue: [Command] = [] 95 | private var cycles: Int16 = 0 96 | public var printOpcodes = false 97 | public var enabled = true 98 | 99 | internal var af: UInt16 { 100 | get { 101 | return [flags.toUInt8(), a].toWord() 102 | } 103 | 104 | set { 105 | let bytes = newValue.toBytes() 106 | a = bytes[1] 107 | flags.set(byte: bytes[0]) 108 | } 109 | } 110 | 111 | internal var bc: UInt16 { 112 | get { 113 | return [c, b].toWord() 114 | } 115 | 116 | set { 117 | let bytes = newValue.toBytes() 118 | b = bytes[1] 119 | c = bytes[0] 120 | } 121 | } 122 | 123 | internal var de: UInt16 { 124 | get { 125 | return [e, d].toWord() 126 | } 127 | 128 | set { 129 | let bytes = newValue.toBytes() 130 | d = bytes[1] 131 | e = bytes[0] 132 | } 133 | } 134 | 135 | internal var hl: UInt16 { 136 | get { 137 | return [l, h].toWord() 138 | } 139 | 140 | set { 141 | let bytes = newValue.toBytes() 142 | h = bytes[1] 143 | l = bytes[0] 144 | } 145 | } 146 | 147 | public init(_ mmu: MMU) { 148 | self.mmu = mmu 149 | 150 | self.mmu.interruptFlags.subscribe { flags in 151 | if flags > 0 && self.enabled == false { 152 | self.enabled = flags & self.mmu.interruptsEnabled.read() > 0 153 | } 154 | } 155 | } 156 | 157 | func readNextByte() throws -> UInt8 { 158 | let byte = try mmu.readByte(address: pc) 159 | pc = pc &+ 1 160 | 161 | return byte 162 | } 163 | 164 | func readNextWord() throws -> UInt16 { 165 | return [try readNextByte(), try readNextByte()].toWord() 166 | } 167 | 168 | func readNextOpCode() throws -> OpCode { 169 | var value = try readNextByte() 170 | 171 | if value == 0xCB { 172 | value = try readNextByte() 173 | 174 | return OpCode.word(value) 175 | } 176 | 177 | return OpCode.byte(value) 178 | } 179 | 180 | func popByteOffStack() throws -> UInt8 { 181 | let byte = try mmu.readByte(address: sp) 182 | sp = sp &+ 1 183 | return byte 184 | } 185 | 186 | func popWordOffStack() throws -> UInt16 { 187 | let word = try mmu.readWord(address: sp) 188 | sp = sp &+ 2 189 | return word 190 | } 191 | 192 | func pushByteOnStack(byte: UInt8) throws -> Void { 193 | sp = sp &- 1 194 | try mmu.writeByte(address: sp, byte: byte) 195 | } 196 | 197 | func pushWordOnStack(word: UInt16) throws -> Void { 198 | sp = sp &- 2 199 | try mmu.writeWord(address: sp, word: word) 200 | } 201 | 202 | func fetchNextInstruction() throws -> Instruction { 203 | let opCode = try readNextOpCode() 204 | let instruction = instructions[opCode] 205 | 206 | if instruction == nil { 207 | throw CPUError.instructionNotFound(opCode) 208 | } 209 | 210 | if printOpcodes { 211 | print(opCode) 212 | } 213 | 214 | return instruction! 215 | } 216 | 217 | func handleInterrupts() throws { 218 | if ime == false { 219 | return 220 | } 221 | 222 | let enabled = mmu.interruptsEnabled.read() 223 | 224 | if enabled == 0x00 { 225 | return 226 | } 227 | 228 | let flags = mmu.interruptFlags.read() 229 | 230 | if flags == 0x00 { 231 | return 232 | } 233 | 234 | for interrupt in Interrupts.prioritized { 235 | if enabled.bit(interrupt.bit) && flags.bit(interrupt.bit) { 236 | try pushWordOnStack(word: pc) 237 | mmu.interruptFlags.write(flags.reset(interrupt.bit)) 238 | ime = false 239 | pc = interrupt.address 240 | cycles = cycles - 5 241 | 242 | return 243 | } 244 | } 245 | } 246 | 247 | public func run(cpuCycles: Int16) throws { 248 | cycles = cycles + (enabled ? cpuCycles : 0) 249 | 250 | while cycles > 0 && enabled { 251 | if queue.count > 0 { 252 | let cmd = queue.removeFirst() 253 | let next = try cmd.run() 254 | 255 | cycles = cycles - Int16(cmd.cycles) 256 | 257 | if next != nil { 258 | queue.insert(next!, at: 0) 259 | } 260 | } else { 261 | try handleInterrupts() 262 | queue.insert(try fetchNextInstruction().toCommand(self), at: 0) 263 | } 264 | } 265 | } 266 | 267 | public func reset() { 268 | cycles = 0 269 | queue.removeAll() 270 | flags.clear() 271 | a = 0 272 | b = 0 273 | c = 0 274 | d = 0 275 | e = 0 276 | h = 0 277 | l = 0 278 | sp = 0 279 | pc = 0 280 | ime = false 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/Cartridge.swift: -------------------------------------------------------------------------------- 1 | // References: 2 | // https://github.com/juchi/gameboy.js/blob/2eeed5eb5fdc497b47584e2719c18fe8aa13c1ea/src/mbc.js#L10 3 | // https://retrocomputing.stackexchange.com/questions/11732/how-does-the-gameboys-memory-bank-switching-work 4 | // https://b13rg.github.io/Gameboy-MBC-Analysis/ 5 | 6 | // TODO: Support more MBC types 7 | // TODO: Make sure memory bank switching is working properly 8 | // TODO: Make sure catridge RAM restoration is working properly 9 | 10 | import Foundation 11 | 12 | enum MBCType { 13 | case zero 14 | case one 15 | case one_ram 16 | case one_ram_battery 17 | case five 18 | case five_ram 19 | case five_ram_battery 20 | case five_rumble 21 | case five_rumble_ram 22 | case five_rumble_ram_battery 23 | case unsupported 24 | 25 | init(rawValue: UInt8) { 26 | switch rawValue { 27 | case 0x00: self = .zero 28 | case 0x01: self = .one 29 | case 0x02: self = .one_ram 30 | case 0x03: self = .one_ram_battery 31 | case 0x19: self = .five 32 | case 0x1A: self = .five_ram 33 | case 0x1B: self = .five_ram_battery 34 | case 0x1C: self = .five_rumble 35 | case 0x1D: self = .five_rumble_ram 36 | case 0x1E: self = .five_rumble_ram_battery 37 | default: self = .unsupported 38 | } 39 | } 40 | } 41 | 42 | struct MBC { 43 | let memory: MemoryAccessArray 44 | let extractRam: () -> [UInt8] 45 | } 46 | 47 | func getRamSize(rom: Data) -> Int { 48 | let eightKB = 8192 49 | 50 | switch rom[0x0149] { 51 | case 0x00,0x01: 52 | return eightKB 53 | case 0x02: 54 | return eightKB 55 | case 0x03: 56 | return eightKB * 4 57 | case 0x04: 58 | return eightKB * 16 59 | case 0x05: 60 | return eightKB * 8 61 | default: 62 | return eightKB * 4 63 | } 64 | } 65 | 66 | func mbcZero(rom: Data, ram: Data) -> MBC { 67 | let romBlock = MemoryBlock(range: 0x0000...0x7FFF, buffer: rom.map { $0 }, readOnly: true, enabled: true) 68 | let ramSize = getRamSize(rom: rom) 69 | let ramBlock = MemoryBlock(range: 0xA000...0xBFFF, buffer: ram.extractFrom(0).fillUntil(count: ramSize, with: 0xFF), readOnly: false, enabled: true) 70 | 71 | return MBC(memory: MemoryAccessArray([romBlock, ramBlock])) { 72 | return ramBlock.buffer 73 | } 74 | } 75 | 76 | func mbcOne(rom: Data, ram: Data) -> MBC { 77 | let romBank1 = MemoryBlockBanked(range: 0x0000...0x3FFF, buffer: rom.extractFrom(0x0000), readOnly: true, enabled: true) 78 | let romBank2 = MemoryBlockBanked(range: 0x4000...0x7FFF, buffer: rom.extractFrom(0x4000), readOnly: true, enabled: true) 79 | let ramSize = getRamSize(rom: rom) 80 | let ramBank = MemoryBlockBanked(range: 0xA000...0xBFFF, buffer: ram.extractFrom(0).fillUntil(count: ramSize, with: 0xFF), readOnly: false, enabled: false) 81 | let memory = MemoryAccessArray([romBank1, romBank2, ramBank]) 82 | 83 | var ramBankingEnabled = false 84 | var currentRomBank: UInt8 = 1 { 85 | didSet { 86 | romBank2.bankIndex = UInt16(currentRomBank) - 1 87 | } 88 | } 89 | 90 | memory.subscribe({ addr, _ in addr <= 0x1FFF }) { _, byte in 91 | ramBank.enabled = (byte & 0x0F == 0x0A) 92 | } 93 | 94 | memory.subscribe({ addr, _ in addr >= 0x2000 && addr <= 0x3FFF }) { _, byte in 95 | currentRomBank = (currentRomBank & 0x60) | (byte & 0x1F) 96 | 97 | if byte & 0x1F == 0x00 { 98 | currentRomBank += 1 99 | } 100 | } 101 | 102 | memory.subscribe({ addr, _ in addr >= 0x4000 && addr <= 0x5FFF }) { _, byte in 103 | if ramBankingEnabled { 104 | ramBank.bankIndex = UInt16(byte & 0x03) 105 | } else { 106 | currentRomBank = (currentRomBank & 0x1F) | ((byte & 0x03) << 5) 107 | } 108 | } 109 | 110 | memory.subscribe({ addr, _ in addr >= 0x6000 && addr <= 0x7FFF }) { _, byte in 111 | ramBankingEnabled = (byte & 0x01 == 0x01) 112 | 113 | if !ramBankingEnabled { 114 | ramBank.bankIndex = 0 115 | } 116 | } 117 | 118 | return MBC(memory: memory) { 119 | return ramBank.banks.reduce([]) { agg, arr in 120 | return agg + arr 121 | } 122 | } 123 | } 124 | 125 | func mbcFive(rom: Data, ram: Data) -> MBC { 126 | let rom0 = MemoryBlock(range: 0x0000...0x3FFF, buffer: rom.extractFrom(0x0000...0x3FFF), readOnly: true, enabled: true) 127 | let romBank = MemoryBlockBanked(range: 0x4000...0x7FFF, buffer: rom.extractFrom(0x4000), readOnly: true, enabled: true) 128 | let ramSize = getRamSize(rom: rom) 129 | let ramBank = ramSize > 0 ? 130 | MemoryBlockBanked(range: 0xA000...0xBFFF, buffer: ram.extractFrom(0).fillUntil(count: ramSize, with: 0xFF), readOnly: false, enabled: true) : 131 | MemoryBlockBanked(range: 0xA000...0xBFFF, readOnly: false, enabled: true) 132 | let memory = MemoryAccessArray([rom0, romBank, ramBank]) 133 | 134 | romBank.bankIndex = 1 135 | 136 | memory.subscribe({ addr, _ in addr <= 0x1FFF }) { _, byte in 137 | ramBank.enabled = (byte & 0x0A) == 0x0A 138 | } 139 | 140 | memory.subscribe({ addr, _ in addr >= 0x2000 && addr <= 0x2FFF }) { _, byte in 141 | var bank = UInt16(0) 142 | var upper = UInt16(0) 143 | var lower = UInt16(0) 144 | 145 | upper = romBank.bankIndex & 0b100000000 146 | lower = UInt16(byte) 147 | bank = upper | lower 148 | 149 | romBank.bankIndex = bank 150 | } 151 | 152 | memory.subscribe({ addr, _ in addr >= 0x3000 && addr <= 0x3FFF }) { _, byte in 153 | var bank = UInt16(0) 154 | var upper = UInt16(0) 155 | var lower = UInt16(0) 156 | 157 | upper = UInt16(byte & 0b00000001) << 8 158 | lower = romBank.bankIndex & 0b11111111 159 | bank = upper | lower 160 | 161 | romBank.bankIndex = bank 162 | } 163 | 164 | memory.subscribe({ addr, _ in addr >= 0x4000 && addr <= 0x5FFF }) { _, byte in 165 | ramBank.bankIndex = UInt16(byte & 0x0F) 166 | } 167 | 168 | return MBC(memory: memory) { 169 | return ramBank.banks.reduce([]) { agg, arr in 170 | return agg + arr 171 | } 172 | } 173 | } 174 | 175 | func mbcUnsupported(rom: Data) -> MBC { 176 | let titleBuffer = (0x0134...0x0143).map { rom[$0] } 177 | let romWithTitleOnly = MemoryBlock(range: 0x0134...0x0143, buffer: titleBuffer, readOnly: true, enabled: true) 178 | 179 | return MBC(memory: MemoryAccessArray([romWithTitleOnly])) { 180 | return [] 181 | } 182 | } 183 | 184 | public class Cartridge: MemoryAccessArray, Identifiable { 185 | let type: MBCType 186 | let romPath: URL 187 | let ramPath: URL 188 | 189 | private var extractRam: (() -> [UInt8])? 190 | 191 | public var title: String { 192 | get { 193 | let bytes = (0x0134...0x0143).map { try! self.readByte(address: $0) } 194 | return String(bytes: bytes, encoding: .utf8) ?? romPath.lastPathComponent.replacingOccurrences(of: ".gb", with: "") 195 | } 196 | } 197 | 198 | public var canBeDeleted: Bool { 199 | get { 200 | return !romPath.absoluteString.contains(Bundle.main.bundleURL.absoluteString) 201 | } 202 | } 203 | 204 | public init(romPath: URL, ramPath: URL) { 205 | let rom = FileSystem.readItem(at: romPath) 206 | let ram = FileSystem.readItem(at: ramPath) 207 | 208 | self.type = MBCType(rawValue: rom[0x0147]) 209 | self.romPath = romPath 210 | self.ramPath = ramPath 211 | 212 | super.init() 213 | 214 | switch type { 215 | case .zero: 216 | let mbc = mbcZero(rom: rom, ram: ram) 217 | super.copy(other: mbc.memory) 218 | self.extractRam = mbc.extractRam 219 | case .one, .one_ram, .one_ram_battery: 220 | let mbc = mbcOne(rom: rom, ram: ram) 221 | super.copy(other: mbc.memory) 222 | self.extractRam = mbc.extractRam 223 | case .five, .five_ram, .five_ram_battery, .five_rumble, .five_rumble_ram, .five_rumble_ram_battery: 224 | let mbc = mbcFive(rom: rom, ram: ram) 225 | super.copy(other: mbc.memory) 226 | self.extractRam = mbc.extractRam 227 | case .unsupported: 228 | let mbc = mbcUnsupported(rom: rom) 229 | super.copy(other: mbc.memory) 230 | self.extractRam = mbc.extractRam 231 | } 232 | } 233 | 234 | public func saveRam() { 235 | if let extractRam = self.extractRam { 236 | let bytes = extractRam() 237 | FileSystem.writeItem(at: ramPath, data: Data(bytes)) 238 | } 239 | } 240 | } 241 | 242 | public extension Array where Element == Cartridge { 243 | func sortedByDeletablilityAndTitle() -> [Cartridge] { 244 | return self 245 | .sorted(by: { a, b in 246 | if a.canBeDeleted && b.canBeDeleted { 247 | return a.title.lowercased() < b.title.lowercased() 248 | } else if a.canBeDeleted { 249 | return false 250 | } else { 251 | return true 252 | } 253 | }) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/Clock.swift: -------------------------------------------------------------------------------- 1 | // TODO: Refactor cycle calculations in the frame() function. Need to be more explicit and maybe encapsulated into their respective components 2 | 3 | import Foundation 4 | 5 | public class Clock { 6 | private let mmu: MMU 7 | private let ppu: PPU 8 | private let cpu: CPU 9 | private let apu: APU 10 | private let timer: Timer 11 | private let fps: Double 12 | private let frameTime: Double 13 | private var syncTasks: [(MMU, CPU, PPU, APU, Timer) -> Void] = [] 14 | public var printFrameDuration = false 15 | 16 | public init(_ mmu: MMU, _ ppu: PPU, _ cpu: CPU, _ apu: APU, _ timer: Timer) { 17 | self.mmu = mmu 18 | self.ppu = ppu 19 | self.cpu = cpu 20 | self.apu = apu 21 | self.timer = timer 22 | self.fps = 60 23 | self.frameTime = 1 / fps 24 | } 25 | 26 | public func sync(task: @escaping (MMU, CPU, PPU, APU, Timer) -> Void) { 27 | self.syncTasks.append(task) 28 | } 29 | 30 | public func start(_ current: DispatchTime = .now()) { 31 | var next = current + frameTime 32 | 33 | DispatchQueue.global(qos: .userInteractive).async { 34 | if self.syncTasks.isNotEmpty { 35 | self.syncTasks.forEach { $0(self.mmu, self.cpu, self.ppu, self.apu, self.timer) } 36 | self.syncTasks.removeAll() 37 | } 38 | 39 | try! self.frame() 40 | 41 | let now = DispatchTime.now() 42 | 43 | if now > next { 44 | next = now 45 | } 46 | 47 | DispatchQueue.main.asyncAfter(deadline: next, execute: { 48 | self.start(next) 49 | }) 50 | } 51 | } 52 | 53 | // 70224 clock cycles = 1 frame 54 | // 456 clock cycles = 1 scanline 55 | // Clock runs at 4 MHz 56 | public func frame() throws { 57 | var total: Int = 0 58 | let cycles: Int16 = 456 // Or 48 if we want to get more granular 59 | let seconds = Float(cycles) / 4000000 60 | 61 | StopWatch.global.start("frame") 62 | 63 | while total < 70224 { 64 | StopWatch.global.start("cpu") 65 | try cpu.run(cpuCycles: cycles / 4) // 1 MHz 66 | StopWatch.global.stop("cpu") 67 | 68 | StopWatch.global.start("mmu") 69 | try mmu.run(mmuCycles: cycles / 4) // 1 MHZ 70 | StopWatch.global.stop("mmu") 71 | 72 | StopWatch.global.start("ppu") 73 | try ppu.run(ppuCycles: cycles / 2) // 2 MHz 74 | StopWatch.global.stop("ppu") 75 | 76 | StopWatch.global.start("apu") 77 | try apu.run(seconds: seconds) 78 | StopWatch.global.stop("apu") 79 | 80 | StopWatch.global.start("timer") 81 | try timer.run(timerCycles: cycles / 16) // 250 KHz 82 | StopWatch.global.stop("timer") 83 | 84 | total = total + Int(cycles) 85 | } 86 | 87 | StopWatch.global.stop("frame") 88 | 89 | maybe { 90 | StopWatch.global.printAll() 91 | } 92 | 93 | StopWatch.global.resetAll() 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/Common.swift: -------------------------------------------------------------------------------- 1 | // TODO: Rename UInt8.swap() function to swapNibbles() 2 | 3 | import Foundation 4 | import SwiftUI 5 | 6 | public extension Data { 7 | func extractFrom(_ range: ClosedRange) -> [UInt8] { 8 | if self.count == 0 { 9 | return [] 10 | } 11 | 12 | if range.lowerBound == range.upperBound { 13 | return [self[range.lowerBound]] 14 | } 15 | 16 | return range.map({ self[$0] }) 17 | } 18 | 19 | func extractFrom(_ start: Int) -> [UInt8] { 20 | let end = self.count == 0 ? 0 : self.count - 1 21 | return self.extractFrom(start...end) 22 | } 23 | } 24 | 25 | extension Array { 26 | func chunked(into size: Int) -> [[Element]] { 27 | return stride(from: 0, to: count, by: size).map { 28 | Array(self[$0 ..< Swift.min($0 + size, count)]) 29 | } 30 | } 31 | 32 | func extractFrom(_ range: ClosedRange) -> [Element] { 33 | if self.count == 0 { 34 | return [] 35 | } 36 | 37 | if range.lowerBound == range.upperBound { 38 | return [self[range.lowerBound]] 39 | } 40 | 41 | return range.map({ self[$0] }) 42 | } 43 | 44 | func extractFrom(_ start: Int) -> [Element] { 45 | let end = self.count == 0 ? 0 : self.count - 1 46 | return self.extractFrom(start...end) 47 | } 48 | 49 | func fillUntil(count: Int, with value: Element) -> [Element] { 50 | if self.count < count { 51 | return self + [Element](repeating: value, count: count - self.count) 52 | } 53 | 54 | return self 55 | } 56 | } 57 | 58 | public extension Array where Element == UInt8 { 59 | func toWord() -> UInt16 { 60 | let hb = self[1] 61 | let lb = self[0] 62 | return UInt16(hb) << 8 + UInt16(lb) 63 | } 64 | } 65 | 66 | public extension UInt8 { 67 | func toHexString() -> String { 68 | return String(format:"%02X", self) 69 | } 70 | 71 | func bit(_ bit: UInt8) -> Bool { 72 | let mask = UInt8(0x01 << bit) 73 | return (self & mask) == mask 74 | } 75 | 76 | func crumb(_ crumb: UInt8) -> UInt8 { 77 | let mask = UInt8(0b00000011) 78 | return (self >> (crumb * 2)) & mask 79 | } 80 | 81 | func nibble(_ nibble: UInt8) -> UInt8 { 82 | let mask = UInt8(0b00001111) 83 | return (self >> (nibble * 4)) & mask 84 | } 85 | 86 | func reset(_ bit: UInt8) -> UInt8 { 87 | let mask = ~UInt8(0x01 << bit) 88 | return self & mask 89 | } 90 | 91 | func set(_ bit: UInt8) -> UInt8 { 92 | let mask = UInt8(0x01 << bit) 93 | return self | mask 94 | } 95 | 96 | func swap() -> UInt8 { 97 | let hb = self << 4 98 | let lb = self >> 4 99 | return hb + lb 100 | } 101 | 102 | func toInt8() -> Int8 { 103 | return Int8(bitPattern: self) 104 | } 105 | 106 | func isBetween(_ lower: UInt8, _ upper: UInt8) -> Bool { 107 | return self >= lower && self <= upper 108 | } 109 | 110 | subscript(index: UInt8) -> Bool { 111 | get { 112 | return bit(index) 113 | } 114 | set { 115 | if newValue { 116 | self = set(index) 117 | } else { 118 | self = reset(index) 119 | } 120 | } 121 | } 122 | } 123 | 124 | public extension UInt16 { 125 | func toBytes() -> [UInt8] { 126 | return [UInt8(0x00FF & self), UInt8((0xFF00 & self) >> 8)] 127 | } 128 | 129 | func toHexString() -> String { 130 | let bytes = toBytes() 131 | return String(format:"%02X", bytes[1]) + String(format:"%02X", bytes[0]) 132 | } 133 | 134 | func bit(_ pos: UInt8) -> Bool { 135 | let mask = UInt16(0x0001 << pos) 136 | return (self & mask) == mask 137 | } 138 | 139 | func isBetween(_ lower: UInt16, _ upper: UInt16) -> Bool { 140 | return self >= lower && self <= upper 141 | } 142 | } 143 | 144 | public extension UInt64 { 145 | func inMs() -> UInt64 { 146 | return self / 1000000 147 | } 148 | } 149 | 150 | public extension Int8 { 151 | func toUInt16() -> UInt16 { 152 | return UInt16(bitPattern: Int16(self)) 153 | } 154 | } 155 | 156 | public extension Int16 { 157 | func toUInt16() -> UInt16 { 158 | return UInt16(bitPattern: self) 159 | } 160 | 161 | func isBetween(_ lower: Int, _ upper: Int) -> Bool { 162 | return self >= lower && self <= upper 163 | } 164 | } 165 | 166 | public extension Int { 167 | func clamp(min: Int, max: Int) -> Int { 168 | if (self > max) { return max } 169 | if (self < min) { return min } 170 | 171 | return self 172 | } 173 | } 174 | 175 | public extension Float { 176 | func clamp(min: Float, max: Float) -> Float { 177 | if (self > max) { return max } 178 | if (self < min) { return min } 179 | 180 | return self 181 | } 182 | 183 | func lerp(min: Float, max: Float, targetMin: Float, targetMax: Float) -> Float { 184 | let scale = (targetMax - targetMin) / (max - min) 185 | let normalized = self - min 186 | let value = normalized * scale 187 | 188 | return value.clamp(min: targetMin, max: targetMax) 189 | } 190 | } 191 | 192 | public extension CGFloat { 193 | func clamp(min: CGFloat, max: CGFloat) -> CGFloat { 194 | if (self > max) { return max } 195 | if (self < min) { return min } 196 | 197 | return self 198 | } 199 | } 200 | 201 | public struct ByteOp { 202 | public var value: UInt8 203 | public var halfCarry: Bool 204 | public var carry: Bool 205 | public var subtract: Bool 206 | public var zero: Bool { 207 | return value == 0 208 | } 209 | } 210 | 211 | public func add(_ num1: UInt8, _ num2: UInt8, carry: Bool = false) -> ByteOp { 212 | let cy: UInt8 = carry ? 1 : 0 213 | let value: UInt8 = num1 &+ num2 &+ cy 214 | let halfCarry = (num1 & 0x0F) + (num2 & 0x0F) + cy > 0x0F 215 | let carry = UInt16(num1) + UInt16(num2) + UInt16(cy) > 0xFF 216 | 217 | return ByteOp(value: value, halfCarry: halfCarry, carry: carry, subtract: false) 218 | } 219 | 220 | public func sub(_ num1: UInt8, _ num2: UInt8, carry: Bool = false) -> ByteOp { 221 | let cy: UInt8 = carry ? 1 : 0 222 | let value: UInt8 = num1 &- num2 &- cy 223 | let halfCarry = ((num1 & 0x0F) &- (num2 & 0x0F) &- cy) & 0x10 != 0x00 224 | let carry = UInt16(num1) < UInt16(num2) + UInt16(cy) 225 | 226 | return ByteOp(value: value, halfCarry: halfCarry, carry: carry, subtract: true) 227 | } 228 | 229 | public struct WordOp { 230 | public var value: UInt16 231 | public var halfCarry: Bool 232 | public var carry: Bool 233 | public var subtract: Bool 234 | public var zero: Bool { 235 | return value == 0 236 | } 237 | } 238 | 239 | public func checkCarry(_ num1: UInt16, _ num2: UInt16, carryBit: UInt8) -> Bool { 240 | let mask = UInt16(0xFFFF) >> (15 - carryBit) 241 | return (num1 & mask) + (num2 & mask) > mask 242 | } 243 | 244 | public func add(_ num1: UInt16, _ num2: UInt16, carryBit: UInt8) -> WordOp { 245 | let value: UInt16 = num1 &+ num2 246 | let halfCarry = checkCarry(num1, num2, carryBit: carryBit) 247 | let carry = num1 > 0xFFFF - num2 248 | 249 | return WordOp(value: value, halfCarry: halfCarry, carry: carry, subtract: false) 250 | } 251 | 252 | public struct Command { 253 | public let cycles: UInt16 254 | public let run: () throws -> Command? 255 | 256 | public init(cycles: UInt16, run: @escaping () throws -> Command?) { 257 | self.cycles = cycles; 258 | self.run = run; 259 | } 260 | } 261 | 262 | func maybe(doThis: () -> Void) { 263 | if Int.random(in: 0...100) == 7 { 264 | doThis() 265 | } 266 | } 267 | 268 | func rarely(doThis: () -> Void) { 269 | if Int.random(in: 0...10000) == 7 { 270 | doThis() 271 | } 272 | } 273 | 274 | class StopWatch { 275 | static let global = StopWatch() 276 | 277 | private var timers: [String: DispatchTime] = [:] 278 | private var ledger: [String: UInt64] = [:] 279 | 280 | func start(_ label: String) { 281 | timers[label] = DispatchTime.now(); 282 | } 283 | 284 | func stop(_ label: String) { 285 | if let start = timers[label] { 286 | let end = DispatchTime.now(); 287 | let duration = end.uptimeNanoseconds - start.uptimeNanoseconds 288 | let current = ledger[label] ?? 0 289 | ledger[label] = current + duration 290 | } 291 | } 292 | 293 | func reset(_ label: String) { 294 | timers[label] = nil 295 | ledger[label] = nil 296 | } 297 | 298 | func check(_ label: String) -> UInt64 { 299 | return ledger[label] ?? 0 300 | } 301 | 302 | func resetAll() { 303 | timers.removeAll(keepingCapacity: false) 304 | ledger.removeAll(keepingCapacity: false) 305 | } 306 | 307 | func printAll() { 308 | let all = ledger.sorted(by: { $0.value > $1.value }).map( { (k,v) in 309 | return "\(k): \(v.inMs())ms" 310 | }) 311 | 312 | print(all.joined(separator: ", ")) 313 | } 314 | } 315 | 316 | class Memo { 317 | private var value: T? 318 | private var deps: [AnyHashable] = [] 319 | 320 | func get(deps: [AnyHashable], _ getter: @escaping () -> T) -> T { 321 | if value == nil || self.deps != deps { 322 | value = getter() 323 | self.deps = deps 324 | } 325 | 326 | return value! 327 | } 328 | 329 | func invalidate() -> Void { 330 | self.deps = [Int32.random(in: 0...1000000000)] 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/FileSystem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct FileSystem { 4 | static var documentsDirectory: String { 5 | let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) 6 | return paths[0].path 7 | } 8 | 9 | static func listAbsoluteURLs(inDirectory: String, suffix: String = "") -> [URL] { 10 | if let files = try? FileManager.default.contentsOfDirectory(atPath: inDirectory) { 11 | return files 12 | .filter { $0.hasSuffix(suffix) } 13 | .map { URL(fileURLWithPath: "\(inDirectory)/\($0)") } 14 | } 15 | 16 | return [] 17 | } 18 | 19 | static func removeItem(at: URL) throws { 20 | try FileManager.default.removeItem(atPath: at.path) 21 | } 22 | 23 | static func readItem(at: URL) -> Data { 24 | do { 25 | let handle = try FileHandle(forReadingFrom: at) 26 | let bytes = handle.readDataToEndOfFile() 27 | 28 | try handle.close() 29 | 30 | return bytes 31 | } catch { 32 | print("Unexpected error: \(error).") 33 | } 34 | 35 | return Data() 36 | } 37 | 38 | static func writeItem(at: URL, data: Data) { 39 | do { 40 | try data.write(to: at) 41 | } catch { 42 | print("Unexpected error: \(error).") 43 | } 44 | } 45 | 46 | static func copyItem(at: URL, to: URL) throws { 47 | try FileManager.default.copyItem(at: at, to: to) 48 | } 49 | 50 | static func moveItem(at: URL, to: URL) throws { 51 | try FileManager.default.moveItem(at: at, to: to) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/GameLibraryManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class GameLibraryManager: ObservableObject { 4 | @Published private(set) var library: [Cartridge] 5 | @Published private(set) var inserted: Cartridge 6 | 7 | let clock: Clock 8 | 9 | init(_ clock: Clock) { 10 | let bundledGames = FileSystem.listAbsoluteURLs(inDirectory: Bundle.main.bundlePath, suffix: ".gb") 11 | let importedGames = FileSystem.listAbsoluteURLs(inDirectory: FileSystem.documentsDirectory, suffix: ".gb") 12 | 13 | let bundledCarts = bundledGames.map { romPath -> Cartridge in 14 | let ramPath = URL(fileURLWithPath: "\(FileSystem.documentsDirectory)/\(romPath.lastPathComponent).ram") 15 | return Cartridge(romPath: romPath, ramPath: ramPath) 16 | } 17 | let importedCarts = importedGames.map { romPath -> Cartridge in 18 | return Cartridge(romPath: romPath, ramPath: romPath.appendingPathExtension("ram")) 19 | } 20 | let allCarts = bundledCarts + importedCarts 21 | 22 | self.library = allCarts 23 | self.inserted = allCarts.first! 24 | self.clock = clock 25 | self.insertCartridge(self.inserted) 26 | } 27 | 28 | func deleteCartridge(_ discarded: Cartridge) { 29 | if discarded === inserted { 30 | let discardedIndex = library.firstIndex(where: { $0 === discarded})! 31 | let nextIndex = discardedIndex == library.count - 1 ? discardedIndex - 1 : discardedIndex + 1 32 | let next = library[nextIndex] 33 | insertCartridge(next) 34 | } 35 | 36 | library = library.filter { $0 !== discarded } 37 | 38 | try? FileSystem.removeItem(at: discarded.romPath) 39 | try? FileSystem.removeItem(at: discarded.ramPath) 40 | } 41 | 42 | func insertCartridge(_ next: Cartridge) { 43 | let previous = self.inserted 44 | 45 | self.inserted = next 46 | 47 | self.clock.sync { mmu, cpu, ppu, apu, timer in 48 | previous.saveRam() 49 | mmu.insertCartridge(next) 50 | mmu.reset() 51 | cpu.reset() 52 | ppu.reset() 53 | apu.reset() 54 | timer.reset() 55 | } 56 | } 57 | 58 | func importURLs(urls: [URL]) { 59 | library.append(contentsOf: urls.map { src in 60 | let romPath = URL(fileURLWithPath: "\(FileSystem.documentsDirectory)/\(src.lastPathComponent)") 61 | let ramPath = romPath.appendingPathExtension("ram") 62 | try! FileSystem.moveItem(at: src, to: romPath) 63 | return Cartridge(romPath: romPath, ramPath: ramPath) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/Joypad.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | class Buttons: ObservableObject { 5 | @Published var up = false 6 | @Published var down = false 7 | @Published var left = false 8 | @Published var right = false 9 | @Published var a = false 10 | @Published var b = false 11 | @Published var start = false 12 | @Published var select = false 13 | } 14 | 15 | public class Joypad { 16 | private let mmu: MMU 17 | 18 | let buttons: Buttons 19 | 20 | init(_ mmu: MMU) { 21 | self.mmu = mmu 22 | self.buttons = Buttons() 23 | 24 | self.mmu.joypad.subscribe { input in 25 | var result = input 26 | 27 | if !result[4] { 28 | result[0] = !self.buttons.right 29 | result[1] = !self.buttons.left 30 | result[2] = !self.buttons.up 31 | result[3] = !self.buttons.down 32 | 33 | if input > result { 34 | var flags = self.mmu.interruptFlags.read() 35 | flags[Interrupts.joypad.bit] = true 36 | self.mmu.interruptFlags.write(flags) 37 | } 38 | } else if !result[5] { 39 | result[0] = !self.buttons.a 40 | result[1] = !self.buttons.b 41 | result[2] = !self.buttons.select 42 | result[3] = !self.buttons.start 43 | 44 | if input > result { 45 | var flags = self.mmu.interruptFlags.read() 46 | flags[Interrupts.joypad.bit] = true 47 | self.mmu.interruptFlags.write(flags) 48 | } 49 | } else { 50 | result = 0xFF 51 | } 52 | 53 | self.mmu.joypad.write(result, publish: false) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/MMU.swift: -------------------------------------------------------------------------------- 1 | // TODO: Make sure only HRAM is accessible to the CPU during the DMA transfer process 2 | // TODO: Should we still publish the write if the byte was not actually committed? Can happen if MemoryBlock is disabled.. 3 | 4 | import Foundation 5 | 6 | let biosProgram: [UInt8] = [ 7 | 0x31, 0xFE, 0xFF, 0xAF, 0x21, 0xFF, 0x9F, 0x32, 0xCB, 0x7C, 0x20, 0xFB, 0x21, 0x26, 0xFF, 0x0E, 8 | 0x11, 0x3E, 0x80, 0x32, 0xE2, 0x0C, 0x3E, 0xF3, 0xE2, 0x32, 0x3E, 0x77, 0x77, 0x3E, 0xFC, 0xE0, 9 | 0x47, 0x11, 0x04, 0x01, 0x21, 0x10, 0x80, 0x1A, 0xCD, 0x95, 0x00, 0xCD, 0x96, 0x00, 0x13, 0x7B, 10 | 0xFE, 0x34, 0x20, 0xF3, 0x11, 0xD8, 0x00, 0x06, 0x08, 0x1A, 0x13, 0x22, 0x23, 0x05, 0x20, 0xF9, 11 | 0x3E, 0x19, 0xEA, 0x10, 0x99, 0x21, 0x2F, 0x99, 0x0E, 0x0C, 0x3D, 0x28, 0x08, 0x32, 0x0D, 0x20, 12 | 0xF9, 0x2E, 0x0F, 0x18, 0xF3, 0x67, 0x3E, 0x64, 0x57, 0xE0, 0x42, 0x3E, 0x91, 0xE0, 0x40, 0x04, 13 | 0x1E, 0x02, 0x0E, 0x0C, 0xF0, 0x44, 0xFE, 0x90, 0x20, 0xFA, 0x0D, 0x20, 0xF7, 0x1D, 0x20, 0xF2, 14 | 0x0E, 0x13, 0x24, 0x7C, 0x1E, 0x83, 0xFE, 0x62, 0x28, 0x06, 0x1E, 0xC1, 0xFE, 0x64, 0x20, 0x06, 15 | 0x7B, 0xE2, 0x0C, 0x3E, 0x87, 0xE2, 0xF0, 0x42, 0x90, 0xE0, 0x42, 0x15, 0x20, 0xD2, 0x05, 0x20, 16 | 0x4F, 0x16, 0x20, 0x18, 0xCB, 0x4F, 0x06, 0x04, 0xC5, 0xCB, 0x11, 0x17, 0xC1, 0xCB, 0x11, 0x17, 17 | 0x05, 0x20, 0xF5, 0x22, 0x23, 0x22, 0x23, 0xC9, 0xCE, 0xED, 0x66, 0x66, 0xCC, 0x0D, 0x00, 0x0B, 18 | 0x03, 0x73, 0x00, 0x83, 0x00, 0x0C, 0x00, 0x0D, 0x00, 0x08, 0x11, 0x1F, 0x88, 0x89, 0x00, 0x0E, 19 | 0xDC, 0xCC, 0x6E, 0xE6, 0xDD, 0xDD, 0xD9, 0x99, 0xBB, 0xBB, 0x67, 0x63, 0x6E, 0x0E, 0xEC, 0xCC, 20 | 0xDD, 0xDC, 0x99, 0x9F, 0xBB, 0xB9, 0x33, 0x3E, 0x3C, 0x42, 0xB9, 0xA5, 0xB9, 0xA5, 0x42, 0x3C, 21 | 0x21, 0x04, 0x01, 0x11, 0xA8, 0x00, 0x1A, 0x13, 0xBE, 0x20, 0xFE, 0x23, 0x7D, 0xFE, 0x34, 0x20, 22 | 0xF5, 0x06, 0x19, 0x78, 0x86, 0x23, 0x05, 0x20, 0xFB, 0x86, 0x20, 0xFE, 0x3E, 0x01, 0xE0, 0x50 23 | ] 24 | 25 | enum MemoryAccessError: Error { 26 | case addressOutOfRange 27 | } 28 | 29 | protocol MemoryAccess: AnyObject { 30 | var version: UInt64 { get } 31 | func contains(address: UInt16) -> Bool 32 | func readByte(address: UInt16) throws -> UInt8 33 | func writeByte(address: UInt16, byte: UInt8) throws -> Void 34 | } 35 | 36 | extension MemoryAccess { 37 | func readWord(address: UInt16) throws -> UInt16 { 38 | let bytes = [try readByte(address: address), try readByte(address: address + 1)] 39 | return bytes.toWord() 40 | } 41 | 42 | func writeWord(address: UInt16, word: UInt16) throws -> Void { 43 | let bytes = word.toBytes() 44 | try writeByte(address: address, byte: bytes[0]) 45 | try writeByte(address: address + 1, byte: bytes[1]) 46 | } 47 | } 48 | 49 | class MemoryBlock: MemoryAccess { 50 | private let range: ClosedRange 51 | private var readOnly: Bool 52 | private(set) var version: UInt64 = 0 53 | var buffer: [UInt8] 54 | var enabled: Bool 55 | var offset: Int = 0 56 | 57 | init(range: ClosedRange, buffer: [UInt8], readOnly: Bool, enabled: Bool) { 58 | self.range = range 59 | self.buffer = buffer 60 | self.readOnly = readOnly 61 | self.enabled = enabled 62 | } 63 | 64 | convenience init(range: ClosedRange, readOnly: Bool, enabled: Bool) { 65 | self.init(range: range, buffer: [UInt8](repeating: 0xFF, count: range.count), readOnly: readOnly, enabled: enabled) 66 | } 67 | 68 | convenience init(range: ClosedRange, block: MemoryBlock) { 69 | self.init(range: range, buffer: block.buffer, readOnly: block.readOnly, enabled: block.enabled) 70 | } 71 | 72 | func reset() { 73 | self.buffer = [UInt8](repeating: 0xFF, count: range.count) 74 | } 75 | 76 | func contains(address: UInt16)-> Bool { 77 | return range.contains(address) 78 | } 79 | 80 | func readByte(address: UInt16) throws -> UInt8 { 81 | if range.contains(address) == false { 82 | throw MemoryAccessError.addressOutOfRange 83 | } 84 | 85 | if enabled == false { 86 | return 0xFF 87 | } 88 | 89 | let index = Int(address - range.lowerBound) + offset 90 | 91 | return buffer[index % buffer.count] 92 | } 93 | 94 | func readBytes(address start: UInt16, count: UInt16) throws -> [UInt8] { 95 | let end = start &+ count - 1 96 | 97 | if range.contains(start) == false { 98 | throw MemoryAccessError.addressOutOfRange 99 | } 100 | 101 | if range.contains(end) == false { 102 | throw MemoryAccessError.addressOutOfRange 103 | } 104 | 105 | if enabled == false { 106 | return [UInt8](repeating: 0xFF, count: Int(count)) 107 | } 108 | 109 | return (start...end).map { address in 110 | let index = Int(address - range.lowerBound) + offset 111 | return buffer[index % buffer.count] 112 | } 113 | } 114 | 115 | func writeByte(address: UInt16, byte: UInt8) throws { 116 | if range.contains(address) == false { 117 | throw MemoryAccessError.addressOutOfRange 118 | } 119 | 120 | if readOnly { 121 | return 122 | } 123 | 124 | if enabled == false { 125 | return 126 | } 127 | 128 | let index = Int(address - range.lowerBound) + offset 129 | 130 | buffer[index % buffer.count] = byte 131 | version = version &+ 1 132 | } 133 | } 134 | 135 | class MemoryBlockBanked: MemoryBlock { 136 | var banks: [[UInt8]] 137 | var bankIndex: UInt16 = 0 { 138 | didSet { 139 | super.buffer = banks[Int(bankIndex)] 140 | } 141 | } 142 | 143 | init(range: ClosedRange, banks: [[UInt8]], readOnly: Bool, enabled: Bool) { 144 | self.banks = banks 145 | super.init(range: range, buffer: banks[0], readOnly: readOnly, enabled: enabled) 146 | } 147 | 148 | convenience override init(range: ClosedRange, buffer: [UInt8], readOnly: Bool, enabled: Bool) { 149 | self.init(range: range, banks: buffer.chunked(into: range.count), readOnly: readOnly, enabled: enabled) 150 | } 151 | 152 | convenience init(range: ClosedRange, readOnly: Bool, enabled: Bool) { 153 | self.init(range: range, buffer: [UInt8](repeating: 0xFF, count: range.count), readOnly: readOnly, enabled: enabled) 154 | } 155 | } 156 | 157 | struct Subscriber { 158 | let predicate: (UInt16, UInt8) -> Bool 159 | let handler: (UInt16, UInt8) -> Void 160 | } 161 | 162 | public class MemoryAccessArray: MemoryAccess { 163 | private var arr: [MemoryAccess] 164 | private var subscribers: [Subscriber] = [] 165 | private(set) var version: UInt64 = 0 166 | 167 | var count: Int { 168 | get { arr.count } 169 | } 170 | 171 | init(_ arr: [MemoryAccess] = []) { 172 | self.arr = arr 173 | } 174 | 175 | func copy(other: MemoryAccessArray) { 176 | self.arr = other.arr; 177 | self.subscribers = other.subscribers 178 | } 179 | 180 | func find(address: UInt16) -> MemoryAccess? { 181 | return arr.first { $0.contains(address: address) } 182 | } 183 | 184 | func find(type: T.Type) -> MemoryAccess? { 185 | return arr.first { $0 is T } 186 | } 187 | 188 | func remove(item: MemoryAccess) { 189 | let index = arr.firstIndex { x in 190 | return x === item 191 | } 192 | 193 | if index != nil { 194 | arr.remove(at: index!) 195 | } 196 | } 197 | 198 | func insert(item: MemoryAccess, index: Int = 0) { 199 | arr.insert(item, at: index) 200 | } 201 | 202 | func contains(address: UInt16) -> Bool { 203 | let block = find(address: address) 204 | return block != nil ? true : false 205 | } 206 | 207 | func readByte(address: UInt16) throws -> UInt8 { 208 | if let block = find(address: address) { 209 | return try block.readByte(address: address) 210 | } 211 | 212 | throw MemoryAccessError.addressOutOfRange 213 | } 214 | 215 | func writeByte(address: UInt16, byte: UInt8) throws { 216 | try writeByte(address: address, byte: byte, publish: true) 217 | } 218 | 219 | func writeByte(address: UInt16, byte: UInt8, publish: Bool) throws { 220 | if let block = find(address: address) { 221 | try block.writeByte(address: address, byte: byte) 222 | 223 | if publish { 224 | let subs = subscribers.filter({ $0.predicate(address, byte) }) 225 | subs.forEach { $0.handler(address, byte) } 226 | } 227 | 228 | version = version &+ 1 229 | 230 | return 231 | } 232 | 233 | throw MemoryAccessError.addressOutOfRange 234 | } 235 | 236 | internal func subscribe(_ predicate: @escaping (UInt16, UInt8) -> Bool, handler: @escaping (UInt16, UInt8) -> Void) { 237 | subscribers.append(Subscriber(predicate: predicate, handler: handler)) 238 | } 239 | 240 | internal func subscribe(address: UInt16, handler: @escaping (UInt8) -> Void) { 241 | subscribe({ (a, b) in a == address }, handler: { _, byte in handler(byte) }) 242 | } 243 | } 244 | 245 | public struct Address { 246 | let address: UInt16 247 | let arr: MemoryAccessArray 248 | 249 | init(_ address: UInt16, _ arr: MemoryAccessArray) { 250 | self.address = address 251 | self.arr = arr 252 | } 253 | 254 | func read() -> UInt8 { 255 | return try! arr.readByte(address: address) 256 | } 257 | 258 | func write(_ byte: UInt8, publish: Bool = true) { 259 | try! arr.writeByte(address: address, byte: byte, publish: publish) 260 | } 261 | 262 | func writeBit(_ bit: UInt8, as value: Bool) { 263 | var byte = read() 264 | byte[bit] = value 265 | write(byte) 266 | } 267 | 268 | func subscribe(handler: @escaping (UInt8) -> Void) { 269 | arr.subscribe(address: address, handler: handler); 270 | } 271 | 272 | func subscribe(_ predicate: @escaping (UInt8) -> Bool, handler: @escaping (UInt8) -> Void) { 273 | arr.subscribe({ (a, b) in a == self.address && predicate(b) }, handler: { _, byte in handler(byte) }) 274 | } 275 | } 276 | 277 | public class MMU: MemoryAccessArray { 278 | private var queue: [Command] = [] 279 | private var cycles: Int16 = 0 280 | 281 | var bios: MemoryBlock 282 | var vramTileData: MemoryBlock 283 | var vramTileMaps: MemoryBlock 284 | var oam: MemoryBlock 285 | var waveformRam: MemoryBlock 286 | var wram: MemoryBlock 287 | var echo: MemoryBlock 288 | var hram: MemoryBlock 289 | var rest: MemoryBlock 290 | 291 | lazy var serialDataTransfer = Address(0xFF01, self) 292 | lazy var serialDataControl = Address(0xFF02, self) 293 | lazy var dividerRegister = Address(0xFF04, self) 294 | lazy var timerCounter = Address(0xFF05, self) 295 | lazy var timerModulo = Address(0xFF06, self) 296 | lazy var timerControl = Address(0xFF07, self) 297 | lazy var interruptFlags = Address(0xFF0F, self) 298 | lazy var lcdControl = Address(0xFF40, self) 299 | lazy var lcdStatus = Address(0xFF41, self) 300 | lazy var scrollY = Address(0xFF42, self) 301 | lazy var scrollX = Address(0xFF43, self) 302 | lazy var lcdY = Address(0xFF44, self) 303 | lazy var lcdYCompare = Address(0xFF45, self) 304 | lazy var dmaTransfer = Address(0xFF46, self) 305 | lazy var bgPalette = Address(0xFF47, self) 306 | lazy var obj0Palette = Address(0xFF48, self) 307 | lazy var obj1Palette = Address(0xFF49, self) 308 | lazy var biosRegister = Address(0xFF50, self) 309 | lazy var interruptsEnabled = Address(0xFFFF, self) 310 | lazy var joypad = Address(0xFF00, self) 311 | lazy var windowY = Address(0xFF4A, self) 312 | lazy var windowX = Address(0xFF4B, self) 313 | 314 | lazy var nr10 = Address(0xFF10, self) 315 | lazy var nr11 = Address(0xFF11, self) 316 | lazy var nr12 = Address(0xFF12, self) 317 | lazy var nr13 = Address(0xFF13, self) 318 | lazy var nr14 = Address(0xFF14, self) 319 | lazy var nr21 = Address(0xFF16, self) 320 | lazy var nr22 = Address(0xFF17, self) 321 | lazy var nr23 = Address(0xFF18, self) 322 | lazy var nr24 = Address(0xFF19, self) 323 | lazy var nr30 = Address(0xFF1A, self) 324 | lazy var nr31 = Address(0xFF1B, self) 325 | lazy var nr32 = Address(0xFF1C, self) 326 | lazy var nr33 = Address(0xFF1D, self) 327 | lazy var nr34 = Address(0xFF1E, self) 328 | lazy var nr41 = Address(0xFF20, self) 329 | lazy var nr42 = Address(0xFF21, self) 330 | lazy var nr43 = Address(0xFF22, self) 331 | lazy var nr44 = Address(0xFF23, self) 332 | lazy var nr50 = Address(0xFF24, self) 333 | lazy var nr51 = Address(0xFF25, self) 334 | lazy var nr52 = Address(0xFF26, self) 335 | 336 | public init() { 337 | self.bios = MemoryBlock(range: 0x0000...0x00FF, buffer: biosProgram, readOnly: true, enabled: true) 338 | self.vramTileData = MemoryBlock(range: 0x8000...0x97FF, readOnly: false, enabled: true) 339 | self.vramTileMaps = MemoryBlock(range: 0x9800...0x9FFF, readOnly: false, enabled: true) 340 | self.oam = MemoryBlock(range: 0xFE00...0xFE9F, readOnly: false, enabled: true) 341 | self.waveformRam = MemoryBlock(range: 0xFF30...0xFF3F, readOnly: false, enabled: true) 342 | self.wram = MemoryBlock(range: 0xC000...0xCFFF, readOnly: false, enabled: true) 343 | self.echo = MemoryBlock(range: 0xE000...0xFDFF, block: wram) 344 | self.hram = MemoryBlock(range: 0xFF80...0xFFFE, readOnly: false, enabled: true) 345 | self.rest = MemoryBlock(range: 0x0000...0xFFFF, readOnly: false, enabled: true) 346 | 347 | super.init([ 348 | bios, 349 | vramTileData, 350 | vramTileMaps, 351 | wram, 352 | echo, 353 | oam, 354 | waveformRam, 355 | hram, 356 | rest 357 | ]) 358 | 359 | self.biosRegister.subscribe { byte in 360 | if byte == 1 { 361 | self.remove(item: self.bios) 362 | } 363 | } 364 | 365 | self.dmaTransfer.subscribe { byte in 366 | self.startDMATransfer(byte: byte) 367 | } 368 | } 369 | 370 | func insertCartridge(_ cart: Cartridge) { 371 | self.remove(item: bios) 372 | 373 | if let found = self.find(type: Cartridge.self){ 374 | self.remove(item: found) 375 | } 376 | 377 | self.insert(item: bios, index: 0) 378 | self.insert(item: cart, index: 1) // Always insert cartridge after bios 379 | } 380 | 381 | func startDMATransfer(byte: UInt8) { 382 | let start = UInt16(byte) << 8 383 | for offset in 0..<0xA0 { 384 | queue.append(Command(cycles: 1) { 385 | let data = try self.readByte(address: start + UInt16(offset)) 386 | try self.writeByte(address: 0xFE00 + UInt16(offset), byte: data) 387 | return nil 388 | }) 389 | } 390 | } 391 | 392 | public func run(mmuCycles: Int16) throws { 393 | if queue.count > 0 { 394 | cycles = cycles + mmuCycles 395 | 396 | while cycles > 0 && queue.count > 0 { 397 | let cmd = queue.removeFirst() 398 | let next = try cmd.run() 399 | 400 | cycles = cycles - Int16(cmd.cycles) 401 | 402 | if next != nil { 403 | queue.insert(next!, at: 0) 404 | } 405 | } 406 | 407 | // Don't want the MMU to be constanly accumulating cycles since it doesn't have regular work to perform.. 408 | if cycles > 0 { 409 | cycles = 0 410 | } 411 | } 412 | } 413 | 414 | public func reset() { 415 | cycles = 0 416 | queue.removeAll() 417 | vramTileData.reset() 418 | vramTileMaps.reset() 419 | oam.reset() 420 | waveformRam.reset() 421 | wram.reset() 422 | echo.reset() 423 | hram.reset() 424 | rest.reset() 425 | } 426 | } 427 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/PPU.swift: -------------------------------------------------------------------------------- 1 | // TODO: self.objectsEnabled is not currently being used anywhere 2 | // TODO: Handle sprite priority: https://youtu.be/HyzD8pNlpwI?t=2179 3 | // TODO: Handle transparent pixel: https://youtu.be/HyzD8pNlpwI?t=3308 4 | // TODO: Double-break code in pixelTransfer() function is horrible - even for my standards.. 5 | // TODO: Need a helper function like: let pixels = fill(pallete, lsb: [0,1,2,3], hsb: [0,1,2,3]) 6 | // TODO: And another: let pixels = mix(base: pixels1, with: pixels2, from: someIndex) 7 | 8 | import Foundation 9 | import UIKit 10 | import SwiftUI 11 | 12 | public struct Pixel { 13 | public var r, g, b, a: UInt8 14 | 15 | public init(r: UInt8, g: UInt8, b: UInt8, a: UInt8 = 255) { 16 | self.r = r 17 | self.g = g 18 | self.b = b 19 | self.a = a 20 | } 21 | 22 | public static let white = Pixel(r: 255, g: 255, b: 255) 23 | public static let lightGray = Pixel(r: 192, g: 192, b: 192) 24 | public static let darkGray = Pixel(r: 96, g: 96, b: 96) 25 | public static let black = Pixel(r: 0, g: 0, b: 0) 26 | public static let transparent = Pixel(r: 0, g: 0, b: 0, a: 0) 27 | 28 | public static func random() -> Pixel { 29 | return Pixel(r: UInt8.random(in: 0...255), g: UInt8.random(in: 0...255), b: UInt8.random(in: 0...255)) 30 | } 31 | } 32 | 33 | public struct Bitmap { 34 | public private(set) var pixels: [Pixel] 35 | public let width: Int 36 | public var height: Int { 37 | return pixels.count / width 38 | } 39 | 40 | public init(width: Int, pixels: [Pixel]) { 41 | self.width = width 42 | self.pixels = pixels 43 | } 44 | 45 | public init(width: Int, height: Int, pixel: Pixel) { 46 | self.pixels = Array(repeating: pixel, count: width * height) 47 | self.width = width 48 | } 49 | 50 | subscript(x: Int, y: Int) -> Pixel { 51 | get { return pixels[y * width + x] } 52 | set { pixels[y * width + x] = newValue } 53 | } 54 | } 55 | 56 | extension UIImage { 57 | convenience init?(bitmap: Bitmap) { 58 | let alphaInfo = CGImageAlphaInfo.premultipliedLast 59 | let bytesPerPixel = MemoryLayout.size 60 | let bytesPerRow = bitmap.width * bytesPerPixel 61 | 62 | guard let providerRef = CGDataProvider(data: Data( 63 | bytes: bitmap.pixels, count: bitmap.height * bytesPerRow 64 | ) as CFData) else { 65 | return nil 66 | } 67 | 68 | guard let cgImage = CGImage( 69 | width: bitmap.width, 70 | height: bitmap.height, 71 | bitsPerComponent: 8, 72 | bitsPerPixel: bytesPerPixel * 8, 73 | bytesPerRow: bytesPerRow, 74 | space: CGColorSpaceCreateDeviceRGB(), 75 | bitmapInfo: CGBitmapInfo(rawValue: alphaInfo.rawValue), 76 | provider: providerRef, 77 | decode: nil, 78 | shouldInterpolate: true, 79 | intent: .defaultIntent 80 | ) else { 81 | return nil 82 | } 83 | 84 | self.init(cgImage: cgImage) 85 | } 86 | } 87 | 88 | public class LCDBitmap: UIView { 89 | private let imageView = UIImageView() 90 | private var displayLink: CADisplayLink? 91 | internal var bitmap = Bitmap(width: 160, height: 144, pixel: Pixel(r: 0, g: 0, b: 0)) 92 | 93 | internal var enabled: Bool { 94 | get { return displayLink != nil ? displayLink!.isPaused == false : false } 95 | set { 96 | newValue ? on() : off() 97 | } 98 | } 99 | 100 | override init(frame: CGRect) { 101 | super.init(frame: frame) 102 | didLoad() 103 | } 104 | 105 | required init?(coder aDecoder: NSCoder) { 106 | super.init(coder: aDecoder) 107 | didLoad() 108 | } 109 | 110 | convenience init() { 111 | self.init(frame: CGRect.zero) 112 | } 113 | 114 | func didLoad() { 115 | addSubview(imageView) 116 | 117 | imageView.translatesAutoresizingMaskIntoConstraints = false 118 | imageView.topAnchor.constraint(equalTo: topAnchor).isActive = true 119 | imageView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true 120 | imageView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true 121 | imageView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true 122 | imageView.contentMode = .scaleAspectFit 123 | imageView.backgroundColor = .none 124 | imageView.layer.magnificationFilter = .nearest 125 | } 126 | 127 | private func on() { 128 | if displayLink == nil { 129 | displayLink = CADisplayLink(target: self, selector: #selector(refresh)) 130 | displayLink!.add(to: .main, forMode: .common) 131 | } 132 | 133 | displayLink!.isPaused = false 134 | } 135 | 136 | private func off() { 137 | if displayLink != nil { 138 | displayLink?.isPaused = true 139 | } 140 | } 141 | 142 | @objc private func refresh(_ displayLink: CADisplayLink) { 143 | imageView.image = UIImage(bitmap: bitmap) 144 | } 145 | } 146 | 147 | public struct LCDBitmapView: UIViewRepresentable { 148 | var child: LCDBitmap 149 | 150 | public func makeUIView(context: Context) -> UIView { 151 | child 152 | } 153 | 154 | public func updateUIView(_ uiView: UIView, context: Context) { } 155 | } 156 | 157 | struct Object { 158 | let x: UInt8 159 | let y: UInt8 160 | let index: UInt8 161 | let attributes: UInt8 162 | } 163 | 164 | let defaultPalette: [UInt8: Pixel] = [ 165 | 0: Pixel.white, 166 | 1: Pixel.lightGray, 167 | 2: Pixel.darkGray, 168 | 3: Pixel.black 169 | ] 170 | 171 | public class PPU { 172 | public let view: LCDBitmapView 173 | private let mmu: MMU 174 | private var queue: [Command] = [] 175 | private var cycles: Int16 = 0 176 | private var windowTileMap: UInt8 = 0 177 | private var windowEnabled = false 178 | private var backgroundTileSet: UInt8 = 0 179 | private var backgroundTileMap: UInt8 = 0 180 | private var backgroundEnabled = false 181 | private var bgPalette = defaultPalette 182 | private var obj0Palette = defaultPalette 183 | private var obj1Palette = defaultPalette 184 | private var objSize: [UInt8] = [8, 8] 185 | private var objectsEnabled = false 186 | private var objectsMemo = Memo<[Object]>() 187 | private var objectsTileDataMemo = Memo<[[UInt8]]>() 188 | 189 | public init(_ mmu: MMU) { 190 | self.view = LCDBitmapView(child: LCDBitmap()) 191 | self.mmu = mmu 192 | 193 | self.mmu.lcdControl.subscribe { byte in 194 | self.view.child.enabled = byte.bit(7) 195 | self.windowTileMap = byte.bit(6) ? 1 : 0 196 | self.windowEnabled = byte.bit(5) 197 | self.backgroundTileSet = byte.bit(4) ? 1 : 0 198 | self.backgroundTileMap = byte.bit(3) ? 1 : 0 199 | self.objSize = byte.bit(2) ? [8, 16] : [8, 8] 200 | self.objectsEnabled = byte.bit(1) 201 | self.backgroundEnabled = byte.bit(0) 202 | } 203 | 204 | self.mmu.lcdY.subscribe { ly in 205 | let lyc = self.mmu.lcdYCompare.read() 206 | self.setLYEqualsLYC(ly == lyc) 207 | } 208 | 209 | self.mmu.lcdYCompare.subscribe { lyc in 210 | let ly = self.mmu.lcdY.read() 211 | self.setLYEqualsLYC(ly == lyc) 212 | } 213 | 214 | self.mmu.bgPalette.subscribe { byte in 215 | self.bgPalette[0] = defaultPalette[byte.crumb(0)] 216 | self.bgPalette[1] = defaultPalette[byte.crumb(1)] 217 | self.bgPalette[2] = defaultPalette[byte.crumb(2)] 218 | self.bgPalette[3] = defaultPalette[byte.crumb(3)] 219 | } 220 | 221 | self.mmu.obj0Palette.subscribe { byte in 222 | self.obj0Palette[0] = Pixel.transparent 223 | self.obj0Palette[1] = defaultPalette[byte.crumb(1)] 224 | self.obj0Palette[2] = defaultPalette[byte.crumb(2)] 225 | self.obj0Palette[3] = defaultPalette[byte.crumb(3)] 226 | } 227 | 228 | self.mmu.obj1Palette.subscribe { byte in 229 | self.obj1Palette[0] = Pixel.transparent 230 | self.obj1Palette[1] = defaultPalette[byte.crumb(1)] 231 | self.obj1Palette[2] = defaultPalette[byte.crumb(2)] 232 | self.obj1Palette[3] = defaultPalette[byte.crumb(3)] 233 | } 234 | 235 | self.mmu.serialDataControl.subscribe({ (b) in b == 0x81 }) { _ in 236 | let byte = self.mmu.serialDataTransfer.read() 237 | let scalar = UnicodeScalar(byte) 238 | let char = Character(scalar) 239 | print(char, terminator: "") 240 | } 241 | } 242 | 243 | func setLYEqualsLYC(_ equal: Bool) { 244 | var stat = mmu.lcdStatus.read() 245 | var flags = mmu.interruptFlags.read() 246 | 247 | defer { 248 | mmu.lcdStatus.write(stat) 249 | mmu.interruptFlags.write(flags) 250 | } 251 | 252 | stat[2] = equal 253 | 254 | if stat.bit(6) && stat.bit(2) { 255 | flags[Interrupts.lcdStat.bit] = true 256 | } 257 | } 258 | 259 | func setMode(_ mode: UInt8) { 260 | var stat = mmu.lcdStatus.read() 261 | var flags = mmu.interruptFlags.read() 262 | 263 | defer { 264 | mmu.lcdStatus.write(stat) 265 | mmu.interruptFlags.write(flags) 266 | } 267 | 268 | stat[0] = mode[0] 269 | stat[1] = mode[1] 270 | 271 | if mode == 1 { 272 | flags[Interrupts.vBlank.bit] = true 273 | } 274 | 275 | if stat.bit(3) && mode == 0 { 276 | flags[Interrupts.lcdStat.bit] = true 277 | } 278 | 279 | if stat.bit(4) && mode == 1 { 280 | flags[Interrupts.lcdStat.bit] = true 281 | } 282 | 283 | if stat.bit(5) && mode == 2 { 284 | flags[Interrupts.lcdStat.bit] = true 285 | } 286 | } 287 | 288 | struct OamScanData { 289 | let bgTileData: [UInt16] 290 | let winTileData: [UInt16]? 291 | let wy: UInt8 292 | let wx: UInt8 293 | let objectsWithTileData: [(object: Object, data: [UInt8])] 294 | let bgy: UInt8 295 | let objSizeY: Int 296 | } 297 | 298 | func oamScan(ly: UInt8, scx: UInt8, scy: UInt8, continuation: @escaping (OamScanData) -> Command) -> Command { 299 | return Command(cycles: 40) { 300 | self.setMode(2) 301 | 302 | let bgy = scy &+ ly 303 | let bgTileMapRow = Int16(bgy / 8) 304 | let bgTileMapStartIndex = UInt16(bgTileMapRow * 32) 305 | let bgTileMapPointer: UInt16 = self.backgroundTileMap == 1 ? 0x9C00 : 0x9800 306 | let bgTileIndices: [UInt8] = try self.mmu.vramTileMaps.readBytes(address: bgTileMapPointer &+ bgTileMapStartIndex, count: 32 ) 307 | let bgTileDataPointer: UInt16 = self.backgroundTileSet == 1 ? 0x8000 : 0x9000 308 | let bgTileData: [UInt16] = try bgTileIndices.map { idx in 309 | if bgTileDataPointer == 0x9000 { 310 | let delta = Int16(idx.toInt8()) * 16 + Int16(bgy % 8) * 2 311 | let address = bgTileDataPointer &+ delta.toUInt16() 312 | return try self.mmu.vramTileData.readWord(address: address) 313 | } else { 314 | let offset = UInt16(idx) * 16 + UInt16(bgy % 8) * 2 315 | let address = bgTileDataPointer &+ offset 316 | return try self.mmu.vramTileData.readWord(address: address) 317 | } 318 | } 319 | 320 | let wy = self.mmu.windowY.read() 321 | let wx = self.mmu.windowX.read() 322 | var winTileData: [UInt16]? = nil 323 | 324 | if self.windowEnabled && ly >= wy && wy.isBetween(0, 143) && wx.isBetween(0, 166) { 325 | let wly = ly - wy 326 | let winTileMapRow = Int16(wly / 8) 327 | let winTileMapStartIndex = UInt16(winTileMapRow * 20) 328 | let winTileMapPointer: UInt16 = self.windowTileMap == 1 ? 0x9C00 : 0x9800 329 | let winTileIndices: [UInt8] = try self.mmu.vramTileMaps.readBytes(address: winTileMapPointer &+ winTileMapStartIndex, count: 20 ) 330 | let winTileDataPointer: UInt16 = self.backgroundTileSet == 1 ? 0x8000 : 0x9000 331 | winTileData = try winTileIndices.map { idx in 332 | if winTileDataPointer == 0x9000 { 333 | let delta = Int16(idx.toInt8()) * 16 + Int16(wly % 8) * 2 334 | let address = winTileDataPointer &+ delta.toUInt16() 335 | return try self.mmu.vramTileData.readWord(address: address) 336 | } else { 337 | let offset = UInt16(idx) * 16 + UInt16(wly % 8) * 2 338 | let address = winTileDataPointer &+ offset 339 | return try self.mmu.vramTileData.readWord(address: address) 340 | } 341 | } 342 | } 343 | 344 | let allObjects = self.objectsMemo.get(deps: [self.mmu.oam.version]) { 345 | return try! self.mmu.oam.readBytes(address: 0xFE00, count: 160).chunked(into: 4).map { arr in 346 | return Object(x: arr[1], y: arr[0], index: arr[2], attributes: arr[3]) 347 | } 348 | } 349 | let objSizeY = Int(self.objSize[1]) 350 | let visibleObjects = allObjects.filter { (o: Object) -> Bool in 351 | let dy = Int16(o.y) - Int16(bgy) 352 | return (dy).isBetween(17 - objSizeY, 16) && o.x > 0 353 | }.prefix(10) 354 | var deps: [AnyHashable] = visibleObjects.map { $0.index } 355 | deps.append(objSizeY) 356 | deps.append(self.mmu.vramTileData.version) 357 | let objTileData = self.objectsTileDataMemo.get(deps: deps) { 358 | return visibleObjects.map ({ (o: Object) -> [UInt8] in 359 | let offset = UInt16(o.index) * 16 360 | let address: UInt16 = 0x8000 &+ offset 361 | let data = try! self.mmu.vramTileData.readBytes(address: address, count: UInt16(objSizeY) * 2) 362 | return data 363 | }) 364 | } 365 | let objectsWithTileData = Array(zip(visibleObjects, objTileData)) 366 | 367 | return continuation(OamScanData(bgTileData: bgTileData, winTileData: winTileData, wy: wy, wx: wx, objectsWithTileData: objectsWithTileData, bgy: bgy, objSizeY: objSizeY)) 368 | } 369 | } 370 | 371 | func pixelTransfer(ly: UInt8, scx: UInt8, data: OamScanData, continuation: @escaping () -> Command) -> Command { 372 | return Command(cycles: 144) { 373 | self.setMode(3) 374 | 375 | var pixels = self.backgroundEnabled ? [Pixel]() : [Pixel](repeating: Pixel.white, count: 256) 376 | 377 | if self.backgroundEnabled { 378 | for data in data.bgTileData { 379 | let arr = data.toBytes() 380 | let lsb = arr[0] 381 | let hsb = arr[1] 382 | 383 | for idx in (0...7).reversed() { 384 | let bit = UInt8(idx) // Bit 7 represents the most leftmost pixel (idx=0) 385 | let v1: UInt8 = lsb.bit(bit) ? 1 : 0 386 | let v2: UInt8 = hsb.bit(bit) ? 2 : 0 387 | 388 | pixels.append(self.bgPalette[v1 + v2]!) 389 | } 390 | } 391 | 392 | if data.winTileData != nil { 393 | var x = Int(data.wx) - 7 394 | 395 | for data in data.winTileData! { 396 | let arr = data.toBytes() 397 | let lsb = arr[0] 398 | let hsb = arr[1] 399 | 400 | for idx in (0...7).reversed() { 401 | let bit = UInt8(idx) // Bit 7 represents the most leftmost pixel (idx=0) 402 | let v1: UInt8 = lsb.bit(bit) ? 1 : 0 403 | let v2: UInt8 = hsb.bit(bit) ? 2 : 0 404 | 405 | pixels[(x + Int(scx)) % pixels.count] = self.bgPalette[v1 + v2]! 406 | x = x + 1 407 | 408 | if x >= pixels.count { 409 | break 410 | } 411 | } 412 | 413 | if x >= pixels.count { 414 | break 415 | } 416 | } 417 | } 418 | } 419 | 420 | for obj in data.objectsWithTileData { 421 | let palette = obj.object.attributes.bit(4) ? self.obj1Palette : self.obj0Palette 422 | let flipY = obj.object.attributes.bit(6) 423 | let flipX = obj.object.attributes.bit(5) 424 | let line = Int(data.bgy) - Int(obj.object.y) + 16 // Why does this work? 425 | let lineIndex = Int(flipY ? data.objSizeY - line - 1 : line) 426 | let lsb = obj.data[lineIndex * 2] 427 | let hsb = obj.data[lineIndex * 2 + 1] 428 | 429 | for idx in (0...7) { 430 | let x = idx + Int(obj.object.x) - 8 431 | 432 | if x >= 0 { 433 | let bit = UInt8(flipX ? idx : 7 - idx) // Bit 7 represents the most leftmost pixel (idx=0) 434 | let v1: UInt8 = lsb.bit(bit) ? 1 : 0 435 | let v2: UInt8 = hsb.bit(bit) ? 2 : 0 436 | let p = v1 + v2 437 | if p > 0 { 438 | pixels[(x + Int(scx)) % pixels.count] = palette[p]! 439 | } 440 | } 441 | } 442 | } 443 | 444 | for col in 0.. Command { 454 | return Command(cycles: 44) { 455 | self.setMode(0) 456 | 457 | // Increment ly at the end of the blanking period 458 | return Command(cycles: 0) { 459 | self.mmu.lcdY.write(ly + 1) 460 | return nil 461 | } 462 | } 463 | } 464 | 465 | func vBlank(ly: UInt8) -> Command { 466 | return Command(cycles: 228) { 467 | if ly == 144 { 468 | self.setMode(1) 469 | } 470 | 471 | // Increment or reset ly at the end of the blanking period 472 | return Command(cycles: 0) { 473 | self.mmu.lcdY.write(ly < 153 ? ly + 1 : 0) 474 | return nil 475 | } 476 | } 477 | } 478 | 479 | func fetchNextCommand() -> Command { 480 | let ly = mmu.lcdY.read() 481 | let scx = mmu.scrollX.read() 482 | let scy = mmu.scrollY.read() 483 | 484 | if ly < view.child.bitmap.height { 485 | return self.oamScan(ly: ly, scx: scx, scy: scy) { data in 486 | return self.pixelTransfer(ly: ly, scx: scx, data: data) { 487 | return self.hBlank(ly: ly); 488 | } 489 | } 490 | } else { 491 | return self.vBlank(ly: ly) 492 | } 493 | } 494 | 495 | public func run(ppuCycles: Int16) throws { 496 | if view.child.enabled { 497 | cycles = cycles + ppuCycles 498 | 499 | while cycles > 0 { 500 | let cmd = queue.count > 0 ? queue.removeFirst() : fetchNextCommand() 501 | let next = try cmd.run() 502 | 503 | cycles = cycles - Int16(cmd.cycles) 504 | 505 | if next != nil { 506 | queue.insert(next!, at: 0) 507 | } 508 | } 509 | } 510 | } 511 | 512 | public func reset() { 513 | cycles = 0 514 | queue.removeAll() 515 | objectsMemo.invalidate() 516 | objectsTileDataMemo.invalidate() 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/Timer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class Timer { 4 | private let mmu: MMU 5 | private let divTreshold: UInt = 16 6 | private var counterThreshold: UInt = 16 7 | private var enabled = false 8 | 9 | private var counterCycles: UInt = 0 { 10 | didSet { 11 | let result = counterCycles.quotientAndRemainder(dividingBy: counterThreshold) 12 | 13 | if result.quotient > 0 { 14 | let previousValue = mmu.timerCounter.read() 15 | let nextValue = previousValue &+ UInt8(result.quotient) 16 | 17 | if nextValue < previousValue { 18 | // Overflowed 19 | mmu.timerCounter.write(mmu.timerModulo.read()) 20 | mmu.interruptFlags.writeBit(Interrupts.timer.bit, as: true) 21 | } else { 22 | mmu.timerCounter.write(nextValue) 23 | } 24 | 25 | counterCycles = result.remainder 26 | } 27 | } 28 | } 29 | 30 | private var divCycles: UInt = 0 { 31 | didSet { 32 | let result = divCycles.quotientAndRemainder(dividingBy: divTreshold) 33 | 34 | if result.quotient > 0 { 35 | let previousValue = mmu.dividerRegister.read() 36 | let nextValue = previousValue &+ UInt8(result.quotient) 37 | mmu.dividerRegister.write(nextValue, publish: false) 38 | divCycles = result.remainder 39 | } 40 | } 41 | } 42 | 43 | public init(_ mmu: MMU) { 44 | self.mmu = mmu 45 | 46 | self.mmu.dividerRegister.subscribe { _ in 47 | self.mmu.dividerRegister.write(0, publish: false) 48 | } 49 | 50 | self.mmu.timerControl.subscribe { control in 51 | self.enabled = control.bit(2) 52 | 53 | let speed = control & 0b00000011 54 | 55 | if speed == 0 { 56 | self.counterThreshold = 64 57 | } else if speed == 1 { 58 | self.counterThreshold = 1 59 | } else if speed == 2 { 60 | self.counterThreshold = 4 61 | } else { 62 | self.counterThreshold = 16 63 | } 64 | } 65 | } 66 | 67 | public func run(timerCycles: Int16) throws { 68 | if enabled { 69 | counterCycles = counterCycles &+ UInt(timerCycles) 70 | } 71 | 72 | divCycles = divCycles &+ UInt(timerCycles) 73 | } 74 | 75 | public func reset() { 76 | divCycles = 0 77 | counterCycles = 0 78 | counterThreshold = 16 79 | enabled = false 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/SwiftBoy/Emulator/UI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import UniformTypeIdentifiers 4 | 5 | extension UTType { 6 | public static var gb: UTType { UTType(importedAs: "com.swiftboy.gameboyfile" )} 7 | } 8 | 9 | // Source: https://github.com/markrenaud/FilePicker/blob/main/Sources/FilePicker/FilePickerUIRepresentable.swift 10 | public struct FilePickerUIRepresentable: UIViewControllerRepresentable { 11 | public typealias UIViewControllerType = UIDocumentPickerViewController 12 | public typealias PickedURLsCompletionHandler = (_ urls: [URL]) -> Void 13 | 14 | @Environment(\.dismiss) var dismiss 15 | 16 | public let types: [UTType] 17 | public let allowMultiple: Bool 18 | public let pickedCompletionHandler: PickedURLsCompletionHandler 19 | 20 | public init(types: [UTType], allowMultiple: Bool, onPicked completionHandler: @escaping PickedURLsCompletionHandler) { 21 | self.types = types 22 | self.allowMultiple = allowMultiple 23 | self.pickedCompletionHandler = completionHandler 24 | } 25 | 26 | public func makeCoordinator() -> Coordinator { 27 | Coordinator(parent: self) 28 | } 29 | 30 | public func makeUIViewController(context: Context) -> UIDocumentPickerViewController { 31 | let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true) 32 | picker.delegate = context.coordinator 33 | picker.allowsMultipleSelection = allowMultiple 34 | return picker 35 | } 36 | 37 | public func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {} 38 | 39 | public class Coordinator: NSObject, UIDocumentPickerDelegate { 40 | var parent: FilePickerUIRepresentable 41 | 42 | init(parent: FilePickerUIRepresentable) { 43 | self.parent = parent 44 | } 45 | 46 | public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { 47 | parent.pickedCompletionHandler(urls) 48 | parent.dismiss() 49 | } 50 | } 51 | } 52 | 53 | struct PressableView : View { 54 | @Binding var pressedBinding: Bool 55 | @State private var pressed = false { 56 | didSet { 57 | pressedBinding = pressed 58 | haptics.impactOccurred(intensity: 0.5); 59 | if let cb = pressed ? onPressedCallback : onReleasedCallback { 60 | cb() 61 | } 62 | } 63 | } 64 | 65 | private let getChildView: (Bool) -> C 66 | private var haptics: UIImpactFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) 67 | private var onPressedCallback: (() -> Void)? 68 | private var onReleasedCallback: (() -> Void)? 69 | 70 | var body: some View { 71 | getChildView(pressed) 72 | .gesture(dragGesture) 73 | } 74 | 75 | private var dragGesture: some Gesture { 76 | DragGesture( minimumDistance: 0, coordinateSpace: .local) 77 | .onChanged { _ in 78 | if !pressed { 79 | pressed = true 80 | } 81 | } 82 | .onEnded { _ in 83 | if pressed { 84 | pressed = false 85 | } 86 | } 87 | } 88 | } 89 | 90 | extension PressableView { 91 | init (_ getChildView: @escaping (Bool) -> C) { 92 | self._pressedBinding = Binding.constant(false) 93 | self.getChildView = getChildView 94 | } 95 | 96 | init (_ pressed: Binding, _ getChildView: @escaping (Bool) -> C) { 97 | self._pressedBinding = pressed 98 | self.getChildView = getChildView 99 | } 100 | 101 | func impact(_ strength: UIImpactFeedbackGenerator.FeedbackStyle) -> Self { 102 | var next = self; 103 | next.haptics = UIImpactFeedbackGenerator(style: strength); 104 | 105 | return next; 106 | } 107 | 108 | func onPressed(_ onPressedCallback: @escaping () -> Void) -> Self { 109 | var next = self 110 | next.onPressedCallback = onPressedCallback 111 | 112 | return next; 113 | } 114 | 115 | func onReleased(_ onReleasedCallback: @escaping () -> Void) -> Self { 116 | var next = self 117 | next.onReleasedCallback = onReleasedCallback 118 | 119 | return next; 120 | } 121 | } 122 | 123 | struct DraggableView : View { 124 | @Binding var dragOffsetBinding: CGFloat 125 | @Binding var draggingBinding: Bool 126 | 127 | @State private var dragging = false 128 | @State private var prevDragTranslation = CGSize.zero 129 | @State private var dragOffset = CGFloat(0) { 130 | didSet { 131 | dragOffsetBinding = dragOffset 132 | if let cb = onDraggedCallback { 133 | cb(dragOffset) 134 | } 135 | } 136 | } 137 | 138 | private let getChildView: (CGFloat, Bool) -> C 139 | private var onDraggedCallback: ((CGFloat) -> Void)? 140 | private var onReleasedCallback: ((CGFloat) -> Void)? 141 | 142 | var body: some View { 143 | getChildView(dragOffset, dragging) 144 | .gesture(dragGesture) 145 | } 146 | 147 | private var dragGesture: some Gesture { 148 | DragGesture(minimumDistance: 0, coordinateSpace: .global) 149 | .onChanged { val in 150 | let dragAmount = val.translation.height - prevDragTranslation.height 151 | dragOffset += dragAmount 152 | prevDragTranslation = val.translation 153 | if !dragging { 154 | dragging = true 155 | } 156 | } 157 | .onEnded { val in 158 | prevDragTranslation = .zero 159 | if let cb = onReleasedCallback { 160 | cb(dragOffset) 161 | } 162 | dragOffset = 0 163 | if dragging { 164 | dragging = false 165 | } 166 | } 167 | } 168 | } 169 | 170 | extension DraggableView { 171 | init (_ dragOffsetBinding: Binding, _ draggingBinding: Binding, _ getChildView: @escaping (CGFloat, Bool) -> C) { 172 | self._dragOffsetBinding = dragOffsetBinding 173 | self._draggingBinding = draggingBinding 174 | self.getChildView = getChildView 175 | } 176 | 177 | init (_ dragOffsetBinding: Binding, _ getChildView: @escaping (CGFloat, Bool) -> C) { 178 | self.init(dragOffsetBinding, Binding.constant(false), getChildView) 179 | } 180 | 181 | init (_ getChildView: @escaping (CGFloat, Bool) -> C) { 182 | self.init(Binding.constant(CGFloat(0)), getChildView) 183 | } 184 | 185 | func onDragged(_ onDraggedCallback: @escaping (CGFloat) -> Void) -> Self { 186 | var next = self 187 | next.onDraggedCallback = onDraggedCallback 188 | 189 | return next; 190 | } 191 | 192 | func onReleased(_ onReleasedCallback: @escaping (CGFloat) -> Void) -> Self { 193 | var next = self 194 | next.onReleasedCallback = onReleasedCallback 195 | 196 | return next; 197 | } 198 | } 199 | 200 | 201 | struct GameButtonView: View where S : Shape { 202 | var shape: S 203 | var label: String? 204 | var width: CGFloat = 50 205 | var height: CGFloat = 50 206 | var onPressed: () -> Void 207 | var onReleased: () -> Void 208 | 209 | var body: some View { 210 | VStack { 211 | PressableView { pressed in 212 | shape 213 | .fill(.white) 214 | .frame(width: width, height: height) 215 | .scaleEffect(pressed ? 1.2 : 1) 216 | .animation(.spring().speed(4), value: pressed) 217 | } 218 | .onPressed(onPressed) 219 | .onReleased(onReleased) 220 | 221 | if label != nil { 222 | Text(label!) 223 | .font(.footnote) 224 | .foregroundColor(.white) 225 | } 226 | } 227 | .rotation3DEffect(Angle(degrees: -30), axis: (x: 0, y: 0, z: 1)) 228 | } 229 | } 230 | 231 | struct DPadView: View { 232 | @EnvironmentObject private var buttons: Buttons 233 | 234 | var body: some View { 235 | VStack { 236 | GameButtonView(shape: Circle(), onPressed: { buttons.up = true }, onReleased: { buttons.up = false }) 237 | .offset(x: 0, y: 10) 238 | HStack(spacing: 40) { 239 | GameButtonView(shape: Circle(), onPressed: { buttons.left = true }, onReleased: { buttons.left = false }) 240 | GameButtonView(shape: Circle(), onPressed: { buttons.right = true }, onReleased: { buttons.right = false }) 241 | } 242 | GameButtonView(shape: Circle(), onPressed: { buttons.down = true }, onReleased: { buttons.down = false }) 243 | .offset(x: 0, y: -10) 244 | } 245 | } 246 | } 247 | 248 | struct ABView: View { 249 | @EnvironmentObject private var buttons: Buttons 250 | 251 | var body: some View { 252 | HStack { 253 | GameButtonView(shape: Circle(), label: "B", onPressed: { buttons.b = true }, onReleased: { buttons.b = false }) 254 | .offset(x: 0, y: 30) 255 | GameButtonView(shape: Circle(), label: "A", onPressed: { buttons.a = true }, onReleased: { buttons.a = false }) 256 | } 257 | } 258 | } 259 | 260 | struct StartSelectView: View { 261 | @EnvironmentObject private var buttons: Buttons 262 | 263 | var body: some View { 264 | HStack { 265 | GameButtonView( 266 | shape: RoundedRectangle(cornerRadius: 5), 267 | label: "START", 268 | width: 45, 269 | height: 10, 270 | onPressed: { buttons.start = true }, 271 | onReleased: { buttons.start = false } 272 | ) 273 | .padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) 274 | GameButtonView( 275 | shape: RoundedRectangle(cornerRadius: 5), 276 | label: "SELECT", 277 | width: 45, 278 | height: 10, 279 | onPressed: { buttons.select = true }, 280 | onReleased: { buttons.select = false } 281 | ) 282 | .padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) 283 | } 284 | } 285 | } 286 | 287 | struct TitleView: View { 288 | var title: String 289 | var onReleased: () -> Void 290 | 291 | var body: some View { 292 | PressableView { pressed in 293 | Text(title) 294 | .fontWeight(.bold) 295 | .textCase(.uppercase) 296 | .foregroundColor(pressed ? .black : .white) 297 | .padding(.vertical, 5) 298 | .background(Rectangle().fill(pressed ? .white : .white.opacity(0))) 299 | } 300 | .onReleased(onReleased) 301 | } 302 | } 303 | 304 | struct GameLibraryItemView: View { 305 | var game: Cartridge 306 | @State private var confirmDelete = false 307 | @EnvironmentObject private var gameLibraryManager: GameLibraryManager 308 | @Environment(\.dismiss) private var dismissLibraryView 309 | 310 | private func delay(numSeconds: Double, cb: @escaping () -> Void) { 311 | DispatchQueue.main.asyncAfter(deadline: .now() + numSeconds) { 312 | cb() 313 | } 314 | } 315 | 316 | func shouldHighlight(_ pressed: Bool) -> Bool { 317 | return gameLibraryManager.inserted === game || pressed 318 | } 319 | 320 | var body: some View { 321 | HStack (alignment: .top) { 322 | VStack { 323 | PressableView { pressed in 324 | Text(game.title) 325 | .font(.title2) 326 | .fontWeight(.bold) 327 | .textCase(.uppercase) 328 | .lineLimit(1) 329 | .foregroundColor(shouldHighlight(pressed) ? .white : .black) 330 | .background(Rectangle().fill(shouldHighlight(pressed) ? .black : .black.opacity(0))) 331 | .frame(maxWidth: .infinity, alignment: .leading) 332 | .padding(.trailing, 20) 333 | } 334 | .onReleased { 335 | gameLibraryManager.insertCartridge(game) 336 | dismissLibraryView() 337 | } 338 | Text(game.type == .unsupported ? "Not Supported ❌" : "Supported") 339 | .font(.caption) 340 | .fontWeight(.bold) 341 | .textCase(.uppercase) 342 | .foregroundColor(.black.opacity(0.4)) 343 | .frame(maxWidth: .infinity, alignment: .leading) 344 | } 345 | if game.canBeDeleted { 346 | Button(role: .none, action: { 347 | if confirmDelete { 348 | gameLibraryManager.deleteCartridge(game) 349 | } else { 350 | confirmDelete = true 351 | delay(numSeconds: 5) { 352 | confirmDelete = false 353 | } 354 | } 355 | }) { 356 | Label(confirmDelete ? "Confirm" : "", systemImage: "trash") 357 | .foregroundColor(confirmDelete ? .red : .black.opacity(0.4)) 358 | } 359 | .padding(.top, 5) 360 | .animation(.easeInOut, value: confirmDelete) 361 | } 362 | } 363 | .padding([.leading, .trailing]) 364 | } 365 | } 366 | 367 | struct GameLibraryModalView: View { 368 | var landscape = false 369 | @State private var showFilePicker = false 370 | @EnvironmentObject private var gameLibraryManager: GameLibraryManager 371 | @Environment(\.dismiss) private var dismiss 372 | 373 | var body: some View { 374 | VStack { 375 | ScrollView(.vertical, showsIndicators: true) { 376 | VStack(spacing: 20) { 377 | ForEach(gameLibraryManager.library.sortedByDeletablilityAndTitle()) { game in 378 | GameLibraryItemView(game: game) 379 | } 380 | } 381 | } 382 | .padding(.top, 20) 383 | .frame(maxHeight: .infinity) 384 | .animation(.easeInOut, value: gameLibraryManager.library.count) 385 | 386 | HStack (spacing: 10) { 387 | PressableView { pressed in 388 | Text("Import Game") 389 | .fontWeight(.bold) 390 | .textCase(.uppercase) 391 | .foregroundColor(pressed ? .black : .white) 392 | .frame(maxWidth: .infinity) 393 | .frame(height: 40) 394 | .background( 395 | pressed ? 396 | AnyView(RoundedRectangle(cornerRadius: 10).stroke(.black, lineWidth: 4)) : 397 | AnyView(RoundedRectangle(cornerRadius: 10).fill(.black)) 398 | ) 399 | } 400 | .onReleased { 401 | showFilePicker = true 402 | } 403 | .sheet(isPresented: $showFilePicker) { 404 | FilePickerUIRepresentable(types: [.gb], allowMultiple: true) { urls in 405 | gameLibraryManager.importURLs(urls: urls) 406 | } 407 | .ignoresSafeArea() 408 | } 409 | 410 | if landscape { 411 | PressableView { pressed in 412 | Text("Close") 413 | .fontWeight(.bold) 414 | .textCase(.uppercase) 415 | .foregroundColor(pressed ? .black : .white) 416 | .frame(maxWidth: 200) 417 | .frame(height: 40) 418 | .background( 419 | pressed ? 420 | AnyView(RoundedRectangle(cornerRadius: 10).stroke(.black, lineWidth: 4)) : 421 | AnyView(RoundedRectangle(cornerRadius: 10).fill(.black)) 422 | ) 423 | } 424 | .onReleased { 425 | dismiss() 426 | } 427 | } 428 | } 429 | .padding() 430 | } 431 | .frame(maxHeight: .infinity) 432 | .frame(maxWidth: .infinity) 433 | .background(.white) 434 | } 435 | } 436 | 437 | struct GameBoyView: View { 438 | private var lcd: LCDBitmapView 439 | @State private var showGameLibrary: Bool = false 440 | @EnvironmentObject private var gameLibraryManager: GameLibraryManager 441 | 442 | init(lcd: LCDBitmapView) { 443 | self.lcd = lcd 444 | } 445 | 446 | var body: some View { 447 | ZStack { 448 | GeometryReader { geometry in 449 | Group { 450 | if geometry.size.width > geometry.size.height { 451 | HStack { 452 | VStack { 453 | Spacer() 454 | DPadView() 455 | Spacer() 456 | } 457 | .padding(.trailing, 20) 458 | .frame(width: geometry.size.width * 0.2) 459 | lcd 460 | VStack { 461 | Spacer() 462 | ABView() 463 | Spacer() 464 | StartSelectView() 465 | } 466 | .frame(width: geometry.size.width * 0.2) 467 | } 468 | } else { 469 | VStack{ 470 | TitleView(title: gameLibraryManager.inserted.title) { 471 | showGameLibrary = true 472 | } 473 | .padding(.top, 20) 474 | lcd.frame(height: geometry.size.height * 0.5) 475 | VStack { 476 | Spacer() 477 | HStack { 478 | DPadView() 479 | Spacer() 480 | ABView() 481 | } 482 | Spacer() 483 | StartSelectView() 484 | } 485 | .padding() 486 | } 487 | } 488 | } 489 | .sheet(isPresented: $showGameLibrary) { 490 | GameLibraryModalView(landscape: geometry.size.width > geometry.size.height) 491 | } 492 | } 493 | .background(.black) 494 | } 495 | .frame(width: .infinity, height: .infinity) 496 | .sheet(isPresented: $showGameLibrary) { 497 | GameLibraryModalView() 498 | } 499 | } 500 | } 501 | -------------------------------------------------------------------------------- /app/SwiftBoy/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Swift Boy 9 | CFBundleDocumentTypes 10 | 11 | 12 | CFBundleTypeName 13 | Game Boy files 14 | LSHandlerRank 15 | Alternate 16 | LSItemContentTypes 17 | 18 | com.swiftboy.gameboyfile 19 | 20 | 21 | 22 | CFBundleExecutable 23 | $(EXECUTABLE_NAME) 24 | CFBundleIdentifier 25 | $(PRODUCT_BUNDLE_IDENTIFIER) 26 | CFBundleInfoDictionaryVersion 27 | 6.0 28 | CFBundleName 29 | $(PRODUCT_NAME) 30 | CFBundlePackageType 31 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 32 | CFBundleShortVersionString 33 | 1.0 34 | CFBundleVersion 35 | 1 36 | LSRequiresIPhoneOS 37 | 38 | UIApplicationSceneManifest 39 | 40 | UIApplicationSupportsMultipleScenes 41 | 42 | UISceneConfigurations 43 | 44 | UIWindowSceneSessionRoleApplication 45 | 46 | 47 | UISceneConfigurationName 48 | Default Configuration 49 | UISceneDelegateClassName 50 | $(PRODUCT_MODULE_NAME).SceneDelegate 51 | UISceneStoryboardFile 52 | Main 53 | 54 | 55 | 56 | 57 | UILaunchScreen 58 | 59 | UIColorName 60 | LaunchScreenColor 61 | 62 | UIMainStoryboardFile 63 | Main 64 | UIRequiredDeviceCapabilities 65 | 66 | armv7 67 | 68 | UISupportedInterfaceOrientations 69 | 70 | UIInterfaceOrientationPortrait 71 | UIInterfaceOrientationLandscapeLeft 72 | UIInterfaceOrientationLandscapeRight 73 | 74 | UISupportedInterfaceOrientations~ipad 75 | 76 | UIInterfaceOrientationPortrait 77 | UIInterfaceOrientationPortraitUpsideDown 78 | UIInterfaceOrientationLandscapeLeft 79 | UIInterfaceOrientationLandscapeRight 80 | 81 | UISupportsDocumentBrowser 82 | 83 | UTImportedTypeDeclarations 84 | 85 | 86 | UTTypeConformsTo 87 | 88 | public.data 89 | 90 | UTTypeDescription 91 | GB Data 92 | UTTypeIconFiles 93 | 94 | UTTypeIdentifier 95 | com.swiftboy.gameboyfile 96 | UTTypeTagSpecification 97 | 98 | public.filename-extension 99 | 100 | gb 101 | GB 102 | 103 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /app/SwiftBoy/ROMs/cpu_instrs.gb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/app/SwiftBoy/ROMs/cpu_instrs.gb -------------------------------------------------------------------------------- /app/SwiftBoy/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 4 | var window: UIWindow? 5 | 6 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 7 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 8 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 9 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 10 | guard let _ = (scene as? UIWindowScene) else { return } 11 | } 12 | 13 | func sceneDidDisconnect(_ scene: UIScene) { 14 | // Called as the scene is being released by the system. 15 | // This occurs shortly after the scene enters the background, or when its session is discarded. 16 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 17 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 18 | 19 | guard let viewController = self.window?.rootViewController as? ViewController else { return } 20 | guard let onClose = viewController.onClose else { return } 21 | 22 | onClose() 23 | } 24 | 25 | func sceneDidBecomeActive(_ scene: UIScene) { 26 | // Called when the scene has moved from an inactive state to an active state. 27 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 28 | } 29 | 30 | func sceneWillResignActive(_ scene: UIScene) { 31 | // Called when the scene will move from an active state to an inactive state. 32 | // This may occur due to temporary interruptions (ex. an incoming phone call). 33 | } 34 | 35 | func sceneWillEnterForeground(_ scene: UIScene) { 36 | // Called as the scene transitions from the background to the foreground. 37 | // Use this method to undo the changes made on entering the background. 38 | } 39 | 40 | func sceneDidEnterBackground(_ scene: UIScene) { 41 | // Called as the scene transitions from the foreground to the background. 42 | // Use this method to save data, release shared resources, and store enough scene-specific state information 43 | // to restore the scene back to its current state. 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/SwiftBoy/ViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | 4 | class ViewController: UIViewController { 5 | var onClose: (() -> Void)? 6 | 7 | override func viewDidLoad() { 8 | super.viewDidLoad() 9 | 10 | let mmu = MMU() 11 | let ppu = PPU(mmu) 12 | let cpu = CPU(mmu) 13 | let apu = APU(mmu) 14 | let timer = Timer(mmu) 15 | let joypad = Joypad(mmu) 16 | let clock = Clock(mmu, ppu, cpu, apu, timer) 17 | let glm = GameLibraryManager(clock) 18 | 19 | clock.start() 20 | 21 | let ui = UIHostingController(rootView: GameBoyView(lcd: ppu.view).environmentObject(joypad.buttons).environmentObject(glm)) 22 | 23 | view.backgroundColor = .black 24 | view.translatesAutoresizingMaskIntoConstraints = false 25 | view.addSubview(ui.view) 26 | 27 | ui.view.translatesAutoresizingMaskIntoConstraints = false 28 | ui.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true 29 | ui.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true 30 | ui.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true 31 | ui.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true 32 | 33 | self.onClose = { 34 | glm.inserted.saveRam() 35 | } 36 | } 37 | } 38 | 39 | 40 | -------------------------------------------------------------------------------- /assets/icloud-drive-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/icloud-drive-portrait.png -------------------------------------------------------------------------------- /assets/icon-rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/icon-rounded.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/icon.sketch -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | v3 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /assets/import-menu-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/import-menu-landscape.png -------------------------------------------------------------------------------- /assets/import-menu-portrait.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/import-menu-portrait.gif -------------------------------------------------------------------------------- /assets/import-menu-portrait.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/import-menu-portrait.mov -------------------------------------------------------------------------------- /assets/import-menu-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/import-menu-portrait.png -------------------------------------------------------------------------------- /assets/super-marioland-gameplay-landscape.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/super-marioland-gameplay-landscape.gif -------------------------------------------------------------------------------- /assets/super-marioland-gameplay-landscape.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/super-marioland-gameplay-landscape.mov -------------------------------------------------------------------------------- /assets/super-marioland-gameplay-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/super-marioland-gameplay-landscape.png -------------------------------------------------------------------------------- /assets/super-marioland-gameplay-portrait.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/super-marioland-gameplay-portrait.gif -------------------------------------------------------------------------------- /assets/super-marioland-gameplay-portrait.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/super-marioland-gameplay-portrait.mov -------------------------------------------------------------------------------- /assets/super-marioland-gameplay-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/super-marioland-gameplay-portrait.png -------------------------------------------------------------------------------- /assets/super-marioland-main-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/super-marioland-main-landscape.png -------------------------------------------------------------------------------- /assets/super-marioland-main-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/super-marioland-main-portrait.png -------------------------------------------------------------------------------- /assets/tetris-gameplay-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/tetris-gameplay-landscape.png -------------------------------------------------------------------------------- /assets/tetris-gameplay-portrait.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/tetris-gameplay-portrait.gif -------------------------------------------------------------------------------- /assets/tetris-gameplay-portrait.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/tetris-gameplay-portrait.mov -------------------------------------------------------------------------------- /assets/tetris-main-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/tetris-main-landscape.png -------------------------------------------------------------------------------- /assets/tetris-main-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/tetris-main-portrait.png -------------------------------------------------------------------------------- /assets/tetris-music-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/tetris-music-landscape.png -------------------------------------------------------------------------------- /assets/tetris-music-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bberak/swift-boy/4051da989d38dd39192130b0169245a4abdd4035/assets/tetris-music-portrait.png -------------------------------------------------------------------------------- /blog-posts/part-one.md: -------------------------------------------------------------------------------- 1 | # An introduction to the magical world of software emulation with Swift 2 | 3 | ## Overview 4 | 5 | - Intro 6 | - Selecting the hardware to emulate (scope) 7 | - Accuracy vs happiness 8 | - High-level vs low-level languages 9 | - HLE vs LLE 10 | - Exploring the CPU 11 | - 16-bit address space 12 | - 8-bit data bus 13 | - Registers 14 | - PC 15 | - SP 16 | - Flags 17 | - Instruction format 18 | - Cycles 19 | - The instruction set 20 | - The basic architecture 21 | - Counting cycles 22 | - Handling branching 23 | - Writing instructions for insane people 24 | - Writing instructions for pragmatic people 25 | 26 | ## Intro 27 | 28 | As a teenager in the early 2000's, tinkering with PCs and sinking countless hours into online gaming was a favourite pastime of mine. Nothing gave me the warm fuzzies more than throwing Gran Turismo into my CD-ROM and seeing the Playstation startup screen come to life on whatever semi-legal emulator was around at the time. 29 | 30 | Throughout my career as a software developer I had always toyed with the idea of attempting to write an emulator of my own. Having spent the majority of my working life in the higher levels of the computing stack -- I had always found the prospect of going lower to be overly daunting or intimidating. 31 | 32 | In this series of articles I will attempt to demistify some parts of the emulation black box and shed some light into an area of software development that the vast majority of programmers won't necessarily get to experience in their day-to-day work. 33 | 34 | This article is aimed at absolute beginners (such as myself) who generally work with high-level programming languages, and who don't necessarily need to understand the ins-and-outs of the hardware onto which they deploy their code. As such, a lot of the terminology I use and concepts I describe may not be completely accurate -- but will hopefully serve as a general purpose guide for further exploration. 35 | 36 | > DISCLAIMER: the purpose of this writing is not to develop a working emulator from scratch, but to provide some guidance on how this could be achieved using comparable hardware. My goal is to focus more on the delightful aspects of emulator development than on the mundane nitty-gritty details. 37 | 38 | ## Selecting an emulation target 39 | 40 | As a way to motivate myself to learn yet another high-level programming language (Swift), I set myself a goal of writing a basic emulator for one of my favourite childhood consoles - the original 1989 Nintendo Game Boy handheld (also referred to as the Game Boy DMG). 41 | 42 | The choice of the hardware to emulate is important because.. 43 | 44 | Indeed, a basic emulator for the Game Boy can be written just as well in [JavaScript](https://github.com/juchi/gameboy.js/) as it might be using a systems-level programming language such as [Rust](https://rylev.github.io/DMG-01/). -------------------------------------------------------------------------------- /gb-cpu-code-generator/format.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const _ = require("lodash"); 3 | const inputPath = "generated.swift" 4 | const outputPath = "formatted.swift"; 5 | 6 | const text = fs.readFileSync(inputPath, "utf8"); 7 | const all = text.split("\n"); 8 | 9 | const result = all.reduce((acc, line) => { 10 | const trimmed = _.trim(line); 11 | 12 | if (trimmed.startsWith("OpCode.byte") || trimmed.startsWith("OpCode.word")) { 13 | acc.sourceBlocks.push([line]) 14 | acc.commentBlocks.push([]) 15 | } else if (trimmed.startsWith("//")) { 16 | const block = _.last(acc.commentBlocks) 17 | block.push(line) 18 | } else { 19 | const block = _.last(acc.sourceBlocks) 20 | if (block) 21 | block.push(line) 22 | } 23 | 24 | return acc; 25 | }, { sourceBlocks: [], commentBlocks: [] }); 26 | 27 | const writeLine = (line) => { 28 | fs.appendFileSync(outputPath, `${line}\n`); 29 | }; 30 | 31 | if (fs.existsSync(outputPath)) 32 | fs.unlinkSync(outputPath); 33 | 34 | result.sourceBlocks.forEach((sb, index) => { 35 | const cb = result.commentBlocks[index]; 36 | 37 | cb.map(_.trim).map(x => "\t" + x).forEach((line, idx) => { 38 | if (idx == (cb.length -1) && line == "\t//") { 39 | //-- Ignore unecessary spacers 40 | } else { 41 | writeLine(line) 42 | } 43 | }) 44 | sb.forEach(writeLine) 45 | }) -------------------------------------------------------------------------------- /gb-cpu-code-generator/generate.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | const fs = require("fs"); 3 | const _ = require("lodash"); 4 | const outputPath = "generated.swift"; 5 | 6 | if (fs.existsSync(outputPath)) 7 | fs.unlinkSync(outputPath); 8 | 9 | fetch("https://gist.githubusercontent.com/bberak/ca001281bb8431d2706afd31401e802b/raw/118ac680ac43cc3153c3c33344a692d37d2fd5a7/gb-instructions-db.json") 10 | .then((res) => res.json()) 11 | .then((arr) => { 12 | const byteOps = _.sortBy(arr.filter((x) => x.opCode.length == 2), (x) => x.opCode); 13 | const wordOps = _.sortBy(arr.filter((x) => x.opCode.length == 4), (x) => x.opCode); 14 | writeLine("let instructions: [OpCode: Instruction] = ["); 15 | [...byteOps, ...wordOps].forEach(writeInstruction); 16 | writeLine("]"); 17 | }); 18 | 19 | const writeLine = (line) => { 20 | fs.appendFileSync(outputPath, `${line}\n`); 21 | }; 22 | 23 | const writeInstruction = (op) => { 24 | const cycles = op.cycles.length === 1 ? op.cycles : _.last(op.cycles.split("/")); 25 | 26 | writeLine(`\t// ${op.mnemonic}`); 27 | writeLine(`\t//`); 28 | writeLine(`\t// Cycles: ${op.cycles}`); 29 | writeLine(`\t// Bytes: ${op.bytes}`); 30 | writeLine(`\t// Flags: ${sanitizeFlags(op.flags)}`); 31 | writeLine(`\t//`); 32 | 33 | (op.description || "").split("\n").forEach((x) => writeLine(`\t// ${_.trim(x)}`)); 34 | 35 | if (op.opCode.length === 2) 36 | writeLine(`\tOpCode.byte(0x${op.opCode}): Instruction.atomic(cycles: ${cycles}) { cpu in`); 37 | else 38 | writeLine(`\tOpCode.word(0x${op.opCode.replace("CB", "")}): Instruction.atomic(cycles: ${cycles}) { cpu in`); 39 | 40 | 41 | if (op.mnemonic.startsWith("LD")) { 42 | writeLD(op); 43 | writeNotImplementedError(op); 44 | } 45 | else if (op.mnemonic.startsWith("BIT")) { 46 | writeBIT(op); 47 | //-- Pretty confident these instruction are generated correctly.. 48 | } 49 | else if (op.mnemonic.startsWith("RES")) { 50 | writeRES(op); 51 | //-- Pretty confident these instruction are generated correctly.. 52 | } 53 | else if (op.mnemonic.startsWith("SET")) { 54 | writeSET(op); 55 | //-- Pretty confident these instruction are generated correctly.. 56 | } 57 | else { 58 | writeFlags(op.flags); 59 | writeNotImplementedError(op); 60 | } 61 | 62 | writeLine(`\t},`); 63 | }; 64 | 65 | const writeLD = (op) => { 66 | /* 67 | d8 - 8-bit immediate data value 68 | d16 - 16-bit immediate data value 69 | a8 - 8-bit immediate value specifying an address in the range 0xFF00 - 0xFFFF 70 | a16 - 16-bit immediate address value 71 | s8 - 8-bit signed immediate data value 72 | */ 73 | const operands = op.mnemonic.replace(",", "").split(" "); 74 | 75 | if (operands.length === 3) { 76 | const destination = operands[1]; 77 | const wordOperation = countLetters(destination) == 2; 78 | const source = operands[2]; 79 | 80 | if (source === "d8") writeLine("\t\t//let data = try cpu.readNextByte()"); 81 | 82 | if (source === "d16") writeLine("\t\t//let data = try cpu.readNextWord()"); 83 | 84 | if (source === "a8") { 85 | writeLine("\t\t//let address = UInt16(try cpu.readNextByte() + 0xFF00)"); 86 | 87 | if (wordOperation) writeLine("\t\t//let data = try cpu.mmu.readWord(address: address)"); 88 | else writeLine("\t\t//let data = try cpu.mmu.readWord(address: address)"); 89 | } 90 | 91 | if (source === "a16") { 92 | writeLine("\t\t//let address = try cpu.readNextWord()"); 93 | 94 | if (wordOperation) writeLine("\t\t//let data = try cpu.mmu.readWord(address: address)"); 95 | else writeLine("\t\t//let data = try cpu.mmu.readWord(address: address)"); 96 | } 97 | 98 | if (source === "A") 99 | writeLine("\t\t//let data = cpu.a") 100 | 101 | if (source === "F") 102 | writeLine("\t\t//let data = cpu.f") 103 | 104 | if (source === "AF") 105 | writeLine("\t\t//let data = cpu.af"); 106 | 107 | if (source === "(AF)") { 108 | if (wordOperation) writeLine("\t\t//let data = try cpu.mmu.readWord(address: cpu.af)"); 109 | else writeLine("\t\t//let data = try cpu.mmu.readByte(address: cpu.af)"); 110 | } 111 | 112 | if (source === "B") 113 | writeLine("\t\t//let data = cpu.b") 114 | 115 | if (source === "C") 116 | writeLine("\t\t//let data = cpu.c") 117 | 118 | if (source === "BC") 119 | writeLine("\t\t//let data = cpu.bc"); 120 | 121 | if (source === "(BC)") { 122 | if (wordOperation) writeLine("\t\t//let data = try cpu.mmu.readWord(address: cpu.bc)"); 123 | else writeLine("\t\t//let data = try cpu.mmu.readByte(address: cpu.bc)"); 124 | } 125 | 126 | if (source === "D") 127 | writeLine("\t\t//let data = cpu.d") 128 | 129 | if (source === "E") 130 | writeLine("\t\t//let data = cpu.e") 131 | 132 | if (source === "DE") 133 | writeLine("\t\t//let data = cpu.de"); 134 | 135 | if (source === "(DE)") { 136 | if (wordOperation) writeLine("\t\t//let data = try cpu.mmu.readWord(address: cpu.de)"); 137 | else writeLine("\t\t//let data = try cpu.mmu.readByte(address: cpu.de)"); 138 | } 139 | 140 | if (source === "H") 141 | writeLine("\t\t//let data = cpu.h") 142 | 143 | if (source === "L") 144 | writeLine("\t\t//let data = cpu.l") 145 | 146 | if (source === "HL") 147 | writeLine("\t\t//let data = cpu.hl"); 148 | 149 | if (source === "(HL)") { 150 | if (wordOperation) writeLine("\t\t//let data = try cpu.mmu.readWord(address: cpu.hl)"); 151 | else writeLine("\t\t//let data = try cpu.mmu.readByte(address: cpu.hl)"); 152 | } 153 | 154 | if (destination.indexOf("(") !== -1) { 155 | if (wordOperation) writeLine(`\t\t//try cpu.mmu.writeWord(address: cpu.${sanitizeRegister(destination)}, word: data)`) 156 | else writeLine(`\t\t//try cpu.mmu.writeByte(address: cpu.${sanitizeRegister(destination)}, byte: data)`) 157 | } 158 | else 159 | writeLine(`\t\t//cpu.${sanitizeRegister(destination)} = data`) 160 | } 161 | 162 | writeFlags(op.flags); 163 | }; 164 | 165 | const writeBIT = (op) => { 166 | const operands = op.mnemonic.replace(",", "").split(" "); 167 | 168 | if (operands.length === 3) { 169 | const bit = operands[1]; 170 | const source = operands[2]; 171 | 172 | if (source === "A") 173 | writeLine("\t\tlet data = cpu.a") 174 | 175 | if (source === "F") 176 | writeLine("\t\tlet data = cpu.f") 177 | 178 | if (source === "AF") 179 | writeLine("\t\tlet data = cpu.af"); 180 | 181 | if (source === "(AF)") 182 | writeLine("\t\tlet data = try cpu.mmu.readByte(address: cpu.af)"); 183 | 184 | if (source === "B") 185 | writeLine("\t\tlet data = cpu.b") 186 | 187 | if (source === "C") 188 | writeLine("\t\tlet data = cpu.c") 189 | 190 | if (source === "BC") 191 | writeLine("\t\tlet data = cpu.bc"); 192 | 193 | if (source === "(BC)") 194 | writeLine("\t\tlet data = try cpu.mmu.readByte(address: cpu.bc)"); 195 | 196 | if (source === "D") 197 | writeLine("\t\tlet data = cpu.d") 198 | 199 | if (source === "E") 200 | writeLine("\t\tlet data = cpu.e") 201 | 202 | if (source === "DE") 203 | writeLine("\t\tlet data = cpu.de"); 204 | 205 | if (source === "(DE)") 206 | writeLine("\t\tlet data = try cpu.mmu.readByte(address: cpu.de)"); 207 | 208 | if (source === "H") 209 | writeLine("\t\tlet data = cpu.h") 210 | 211 | if (source === "L") 212 | writeLine("\t\tlet data = cpu.l") 213 | 214 | if (source === "HL") 215 | writeLine("\t\tlet data = cpu.hl"); 216 | 217 | if (source === "(HL)") 218 | writeLine("\t\tlet data = try cpu.mmu.readByte(address: cpu.hl)"); 219 | 220 | writeLine(`\t\tcpu.flags.zero = !data.bit(${bit})`); 221 | writeLine("\t\tcpu.flags.subtract = false"); 222 | writeLine("\t\tcpu.flags.halfCarry = true"); 223 | } else { 224 | writeFlags(op.flags); 225 | } 226 | }; 227 | 228 | const writeRES = (op) => { 229 | const operands = op.mnemonic.replace(",", "").split(" "); 230 | 231 | if (operands.length === 3) { 232 | const bit = operands[1]; 233 | const source = operands[2]; 234 | 235 | if (source === "A") 236 | writeLine("\t\tvar data = cpu.a") 237 | 238 | if (source === "F") 239 | writeLine("\t\tvar data = cpu.f") 240 | 241 | if (source === "AF") 242 | writeLine("\t\tvar data = cpu.af"); 243 | 244 | if (source === "(AF)") 245 | writeLine("\t\tvar data = try cpu.mmu.readByte(address: cpu.af)"); 246 | 247 | if (source === "B") 248 | writeLine("\t\tvar data = cpu.b") 249 | 250 | if (source === "C") 251 | writeLine("\t\tvar data = cpu.c") 252 | 253 | if (source === "BC") 254 | writeLine("\t\tvar data = cpu.bc"); 255 | 256 | if (source === "(BC)") 257 | writeLine("\t\tvar data = try cpu.mmu.readByte(address: cpu.bc)"); 258 | 259 | if (source === "D") 260 | writeLine("\t\tvar data = cpu.d") 261 | 262 | if (source === "E") 263 | writeLine("\t\tvar data = cpu.e") 264 | 265 | if (source === "DE") 266 | writeLine("\t\tvar data = cpu.de"); 267 | 268 | if (source === "(DE)") 269 | writeLine("\t\tvar data = try cpu.mmu.readByte(address: cpu.de)"); 270 | 271 | if (source === "H") 272 | writeLine("\t\tvar data = cpu.h") 273 | 274 | if (source === "L") 275 | writeLine("\t\tvar data = cpu.l") 276 | 277 | if (source === "HL") 278 | writeLine("\t\tvar data = cpu.hl"); 279 | 280 | if (source === "(HL)") 281 | writeLine("\t\tvar data = try cpu.mmu.readByte(address: cpu.hl)"); 282 | 283 | writeLine(`\t\tdata = data.reset(${bit})`); 284 | 285 | if (source.indexOf("(") !== -1) 286 | writeLine(`\t\ttry cpu.mmu.writeByte(address: cpu.${sanitizeRegister(source)}, byte: data)`) 287 | else 288 | writeLine(`\t\tcpu.${sanitizeRegister(source)} = data`) 289 | } 290 | }; 291 | 292 | const writeSET = (op) => { 293 | const operands = op.mnemonic.replace(",", "").split(" "); 294 | 295 | if (operands.length === 3) { 296 | const bit = operands[1]; 297 | const source = operands[2]; 298 | 299 | if (source === "A") 300 | writeLine("\t\tvar data = cpu.a") 301 | 302 | if (source === "F") 303 | writeLine("\t\tvar data = cpu.f") 304 | 305 | if (source === "AF") 306 | writeLine("\t\tvar data = cpu.af"); 307 | 308 | if (source === "(AF)") 309 | writeLine("\t\tvar data = try cpu.mmu.readByte(address: cpu.af)"); 310 | 311 | if (source === "B") 312 | writeLine("\t\tvar data = cpu.b") 313 | 314 | if (source === "C") 315 | writeLine("\t\tvar data = cpu.c") 316 | 317 | if (source === "BC") 318 | writeLine("\t\tvar data = cpu.bc"); 319 | 320 | if (source === "(BC)") 321 | writeLine("\t\tvar data = try cpu.mmu.readByte(address: cpu.bc)"); 322 | 323 | if (source === "D") 324 | writeLine("\t\tvar data = cpu.d") 325 | 326 | if (source === "E") 327 | writeLine("\t\tvar data = cpu.e") 328 | 329 | if (source === "DE") 330 | writeLine("\t\tvar data = cpu.de"); 331 | 332 | if (source === "(DE)") 333 | writeLine("\t\tvar data = try cpu.mmu.readByte(address: cpu.de)"); 334 | 335 | if (source === "H") 336 | writeLine("\t\tvar data = cpu.h") 337 | 338 | if (source === "L") 339 | writeLine("\t\tvar data = cpu.l") 340 | 341 | if (source === "HL") 342 | writeLine("\t\tvar data = cpu.hl"); 343 | 344 | if (source === "(HL)") 345 | writeLine("\t\tvar data = try cpu.mmu.readByte(address: cpu.hl)"); 346 | 347 | writeLine(`\t\tdata = data.set(${bit})`); 348 | 349 | if (source.indexOf("(") !== -1) 350 | writeLine(`\t\ttry cpu.mmu.writeByte(address: cpu.${sanitizeRegister(source)}, byte: data)`) 351 | else 352 | writeLine(`\t\tcpu.${sanitizeRegister(source)} = data`) 353 | } 354 | }; 355 | 356 | const writeFlags = (flags) => { 357 | if (flags.Z) writeLine("\t\t//cpu.flags.zero = result.zero"); 358 | if (flags.N) writeLine("\t\t//cpu.flags.subtract = result.subtract"); 359 | if (flags.H) writeLine("\t\t//cpu.flags.halfCarry = result.halfCarry"); 360 | if (flags.CY) writeLine("\t\t//cpu.flags.carry = result.carry"); 361 | }; 362 | 363 | const writeNotImplementedError = (op) => { 364 | if (op.opCode.length === 2) 365 | writeLine(`\t\tthrow CPUError.instructionNotImplemented(OpCode.byte(0x${op.opCode}))`); 366 | else 367 | writeLine(`\t\tthrow CPUError.instructionNotImplemented(OpCode.word(0x${op.opCode.replace("CB", "")}))`); 368 | }; 369 | 370 | const sanitizeFlags = (flags) => { 371 | let result = ""; 372 | 373 | if (flags.Z) result += flags.Z; 374 | else result += "-"; 375 | 376 | result += " "; 377 | 378 | if (flags.N) result += flags.N; 379 | else result += "-"; 380 | 381 | result += " "; 382 | 383 | if (flags.H) result += flags.H; 384 | else result += "-"; 385 | 386 | result += " "; 387 | 388 | if (flags.CY) result += flags.CY; 389 | else result += "-"; 390 | 391 | return result; 392 | }; 393 | 394 | const countLetters = (destination) => { 395 | return (destination.match(/is/g) || []).length; 396 | }; 397 | 398 | const sanitizeRegister = (destination) => { 399 | let result = destination 400 | let arr = ["+", "-", "(", ")"] 401 | 402 | arr.forEach(x => { 403 | result = result.replace(x, "") 404 | }) 405 | 406 | return result.toLowerCase() 407 | }; 408 | -------------------------------------------------------------------------------- /gb-cpu-code-generator/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gb-cpu-code-generator", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "lodash": { 8 | "version": "4.17.21", 9 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 10 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 11 | }, 12 | "node-fetch": { 13 | "version": "2.6.1", 14 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", 15 | "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /gb-cpu-code-generator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gb-cpu-code-generator", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "lodash": "^4.17.21", 13 | "node-fetch": "^2.6.1" 14 | } 15 | } 16 | --------------------------------------------------------------------------------