Repository: euan-forrester/save-file-converter
Branch: main
Commit: 5935b41fc1fd
Files: 936
Total size: 2.4 MB
Directory structure:
gitextract_5fpwvxle/
├── .gitattributes
├── .gitignore
├── .vscode/
│ └── settings.json
├── LICENSE
├── README.md
├── frontend/
│ ├── .editorconfig
│ ├── .eslintignore
│ ├── .gitignore
│ ├── .nvmrc
│ ├── README.md
│ ├── babel.config.js
│ ├── buildspec.yml
│ ├── lib/
│ │ └── minlzo-js/
│ │ └── lzo1x.js
│ ├── package.json
│ ├── public/
│ │ ├── browserconfig.xml
│ │ ├── index.html
│ │ ├── robots.txt
│ │ ├── site.webmanifest
│ │ └── sitemap.txt
│ ├── src/
│ │ ├── App.vue
│ │ ├── components/
│ │ │ ├── AdvancedUtils.vue
│ │ │ ├── ByteExpandContract.vue
│ │ │ ├── CompressionDecompression.vue
│ │ │ ├── CompressionType.vue
│ │ │ ├── ConversionDirection.vue
│ │ │ ├── ConvertDreamcast.vue
│ │ │ ├── ConvertFlashCarts.vue
│ │ │ ├── ConvertGameCube.vue
│ │ │ ├── ConvertGbaActionReplay.vue
│ │ │ ├── ConvertGbaGameShark.vue
│ │ │ ├── ConvertGbaGameSharkSP.vue
│ │ │ ├── ConvertMister.vue
│ │ │ ├── ConvertN64DexDrive.vue
│ │ │ ├── ConvertN64Mempack.vue
│ │ │ ├── ConvertNintendoSwitchOnline.vue
│ │ │ ├── ConvertOnlineEmulators.vue
│ │ │ ├── ConvertPs1DexDrive.vue
│ │ │ ├── ConvertPs1Emulator.vue
│ │ │ ├── ConvertPs1Ps3.vue
│ │ │ ├── ConvertPs1Psp.vue
│ │ │ ├── ConvertRetron5.vue
│ │ │ ├── ConvertSegaCd.vue
│ │ │ ├── ConvertSegaSaturnEmulator.vue
│ │ │ ├── ConvertSegaSaturnSaroo.vue
│ │ │ ├── ConvertSrmSav.vue
│ │ │ ├── ConvertWii.vue
│ │ │ ├── DecryptPsp.vue
│ │ │ ├── DreamcastIndividualSaveTypeSelector.vue
│ │ │ ├── EndiannessWordSize.vue
│ │ │ ├── EraseSave.vue
│ │ │ ├── FileInfo.vue
│ │ │ ├── FileList.vue
│ │ │ ├── FileReader.vue
│ │ │ ├── FileSize.vue
│ │ │ ├── FlashCartType.vue
│ │ │ ├── GameCubeEncodingSelector.vue
│ │ │ ├── HeaderFooter.vue
│ │ │ ├── HelpButton.vue
│ │ │ ├── IndividualSavesOrMemoryCardSelector.vue
│ │ │ ├── InputFile.vue
│ │ │ ├── InputNumber.vue
│ │ │ ├── MemoryCardSelector.vue
│ │ │ ├── MisterPlatform.vue
│ │ │ ├── NintendoSwitchOnlinePlatform.vue
│ │ │ ├── OnlineEmulatorPlatform.vue
│ │ │ ├── OutputFilename.vue
│ │ │ ├── OutputFilesize.vue
│ │ │ ├── PadFillByte.vue
│ │ │ ├── RegionViewer.vue
│ │ │ ├── Retron5EraseSave.vue
│ │ │ ├── SegaCdSaveTypeSelector.vue
│ │ │ ├── TabAddHeaderFooter.vue
│ │ │ ├── TabByteExpansion.vue
│ │ │ ├── TabCompression.vue
│ │ │ ├── TabEndianSwap.vue
│ │ │ ├── TabFileCompare.vue
│ │ │ ├── TabRemoveHeaderFooter.vue
│ │ │ ├── TabResize.vue
│ │ │ ├── TabSlice.vue
│ │ │ ├── TroubleshootingUtils.vue
│ │ │ └── WiiVcPlatform.vue
│ │ ├── main.js
│ │ ├── plugins/
│ │ │ ├── bootstrap-vue.js
│ │ │ ├── fontawesome-vue.js
│ │ │ ├── google-tag-manager-vue.js
│ │ │ ├── mediaquery-vue.js
│ │ │ └── vue-async-computed.js
│ │ ├── rom-formats/
│ │ │ ├── PspIso.js
│ │ │ ├── SegaSaturnCueBin.js
│ │ │ ├── gb.js
│ │ │ ├── gba.js
│ │ │ ├── nes.js
│ │ │ └── sms.js
│ │ ├── router/
│ │ │ └── index.js
│ │ ├── save-formats/
│ │ │ ├── Dreamcast/
│ │ │ │ ├── Components/
│ │ │ │ │ ├── Basics.js
│ │ │ │ │ ├── Directory.js
│ │ │ │ │ ├── DirectoryEntry.js
│ │ │ │ │ ├── FileAllocationTable.js
│ │ │ │ │ └── SystemInfo.js
│ │ │ │ ├── Dreamcast.js
│ │ │ │ ├── IndividualSaves/
│ │ │ │ │ ├── Dci.js
│ │ │ │ │ └── VmiVms.js
│ │ │ │ └── Util.js
│ │ │ ├── FlashCarts/
│ │ │ │ ├── GB.js
│ │ │ │ ├── GBA/
│ │ │ │ │ ├── EmulatorBase.js
│ │ │ │ │ ├── GBA.js
│ │ │ │ │ ├── GoombaEmulator.js
│ │ │ │ │ ├── PocketNesEmulator.js
│ │ │ │ │ └── SmsAdvanceEmulator.js
│ │ │ │ ├── GameGear.js
│ │ │ │ ├── Genesis/
│ │ │ │ │ ├── MegaEverdrivePro/
│ │ │ │ │ │ ├── 32X.js
│ │ │ │ │ │ ├── Genesis.js
│ │ │ │ │ │ ├── NES.js
│ │ │ │ │ │ ├── SMS.js
│ │ │ │ │ │ └── SegaCd.js
│ │ │ │ │ └── MegaSD/
│ │ │ │ │ ├── 32X.js
│ │ │ │ │ ├── Genesis.js
│ │ │ │ │ ├── GenesisBase.js
│ │ │ │ │ ├── SMS.js
│ │ │ │ │ └── SegaCd.js
│ │ │ │ ├── N64/
│ │ │ │ │ ├── GB64Emulator.js
│ │ │ │ │ ├── N64.js
│ │ │ │ │ ├── NES.js
│ │ │ │ │ └── Neon64Emulator.js
│ │ │ │ ├── NES.js
│ │ │ │ ├── PcEngine.js
│ │ │ │ ├── SMS.js
│ │ │ │ └── SNES/
│ │ │ │ ├── GB.js
│ │ │ │ └── SNES.js
│ │ │ ├── GBA/
│ │ │ │ ├── ActionReplay.js
│ │ │ │ ├── GameShark.js
│ │ │ │ └── GameSharkSP.js
│ │ │ ├── GameCube/
│ │ │ │ ├── Components/
│ │ │ │ │ ├── Basics.js
│ │ │ │ │ ├── BlockAllocationTable.js
│ │ │ │ │ ├── Directory.js
│ │ │ │ │ ├── DirectoryEntry.js
│ │ │ │ │ └── Header.js
│ │ │ │ ├── GameCube.js
│ │ │ │ ├── GameSpecificFixups/
│ │ │ │ │ ├── FZeroGx.js
│ │ │ │ │ ├── GameSpecificFixups.js
│ │ │ │ │ └── PhantasyStarOnline.js
│ │ │ │ ├── IndividualSaves/
│ │ │ │ │ ├── GameShark.js
│ │ │ │ │ ├── Gci.js
│ │ │ │ │ ├── IndividualSaves.js
│ │ │ │ │ └── MaxDrive.js
│ │ │ │ └── Util.js
│ │ │ ├── Mister/
│ │ │ │ ├── GameGear.js
│ │ │ │ ├── Gameboy.js
│ │ │ │ ├── GameboyAdvance.js
│ │ │ │ ├── Genesis.js
│ │ │ │ ├── N64Cart.js
│ │ │ │ ├── N64Mempack.js
│ │ │ │ ├── Nes.js
│ │ │ │ ├── PcEngine.js
│ │ │ │ ├── Ps1.js
│ │ │ │ ├── SegaCd.js
│ │ │ │ ├── SegaSaturn.js
│ │ │ │ ├── Sms.js
│ │ │ │ ├── Snes.js
│ │ │ │ └── WonderSwan.js
│ │ │ ├── N64/
│ │ │ │ ├── Components/
│ │ │ │ │ ├── Basics.js
│ │ │ │ │ ├── GameSerialCodeUtil.js
│ │ │ │ │ ├── IdArea.js
│ │ │ │ │ ├── InodeTable.js
│ │ │ │ │ ├── NoteTable.js
│ │ │ │ │ └── TextDecoder.js
│ │ │ │ ├── DexDrive.js
│ │ │ │ ├── IndividualSaveFilename.js
│ │ │ │ └── Mempack.js
│ │ │ ├── NintendoSwitchOnline/
│ │ │ │ ├── Gameboy.js
│ │ │ │ ├── GameboyAdvance.js
│ │ │ │ ├── Genesis.js
│ │ │ │ ├── N64.js
│ │ │ │ ├── Nes.js
│ │ │ │ └── Snes.js
│ │ │ ├── OnlineEmulators/
│ │ │ │ ├── Emulators/
│ │ │ │ │ ├── EmulatorBase.js
│ │ │ │ │ ├── Gambatte.js
│ │ │ │ │ ├── Gb.js
│ │ │ │ │ ├── Snes9x.js
│ │ │ │ │ ├── VBA-Next.js
│ │ │ │ │ └── mGba.js
│ │ │ │ └── OnlineEmulatorWrapper.js
│ │ │ ├── PS1/
│ │ │ │ ├── Components/
│ │ │ │ │ ├── Basics.js
│ │ │ │ │ ├── DirectoryBlock.js
│ │ │ │ │ ├── SaveBlocks.js
│ │ │ │ │ └── SonyUtil.js
│ │ │ │ ├── DexDrive.js
│ │ │ │ ├── Memcard.js
│ │ │ │ ├── Ps3.js
│ │ │ │ └── Psp.js
│ │ │ ├── PSP/
│ │ │ │ ├── Executable.js
│ │ │ │ ├── ParamSfo.js
│ │ │ │ ├── PspEncryptionUtil.js
│ │ │ │ ├── Savefile.js
│ │ │ │ └── psp-encryption/
│ │ │ │ ├── psp-encryption.js
│ │ │ │ └── psp-encryption.wasm
│ │ │ ├── PlatformSaveSizes.js
│ │ │ ├── Retron5/
│ │ │ │ └── Retron5.js
│ │ │ ├── SegaCd/
│ │ │ │ ├── Crc16.js
│ │ │ │ ├── ReedSolomon.js
│ │ │ │ └── SegaCd.js
│ │ │ ├── SegaError.js
│ │ │ ├── SegaSaturn/
│ │ │ │ ├── Emulators/
│ │ │ │ │ ├── Emulators.js
│ │ │ │ │ ├── mednafen.js
│ │ │ │ │ ├── yabasanshiro.js
│ │ │ │ │ └── yabause.js
│ │ │ │ ├── IndividualSaves/
│ │ │ │ │ └── Bup.js
│ │ │ │ ├── Saroo/
│ │ │ │ │ ├── Cart.js
│ │ │ │ │ ├── Internal.js
│ │ │ │ │ ├── System.js
│ │ │ │ │ └── Util.js
│ │ │ │ ├── SegaSaturn.js
│ │ │ │ └── Util.js
│ │ │ └── Wii/
│ │ │ ├── ConvertFrom/
│ │ │ │ ├── ConvertFromN64.js
│ │ │ │ ├── ConvertFromPcEngine.js
│ │ │ │ ├── ConvertFromPlatform.js
│ │ │ │ └── ConvertFromSega.js
│ │ │ ├── GetPlatform/
│ │ │ │ ├── GetPlatform.js
│ │ │ │ └── HttpClient.js
│ │ │ └── Wii.js
│ │ ├── store/
│ │ │ └── index.js
│ │ ├── util/
│ │ │ ├── Array.js
│ │ │ ├── CompressionGzip.js
│ │ │ ├── CompressionLzo.js
│ │ │ ├── CompressionRzip.js
│ │ │ ├── CompressionZlib.js
│ │ │ ├── Endian.js
│ │ │ ├── Genesis.js
│ │ │ ├── Hash.js
│ │ │ ├── Math.js
│ │ │ ├── N64.js
│ │ │ ├── Padding.js
│ │ │ ├── PcEngine.js
│ │ │ ├── SaveFiles.js
│ │ │ ├── SegaCd.js
│ │ │ ├── crypto-aes.js
│ │ │ ├── crypto-des.js
│ │ │ └── util.js
│ │ └── views/
│ │ ├── About.vue
│ │ ├── AdvancedView.vue
│ │ ├── DownloadSaves.vue
│ │ ├── Dreamcast.vue
│ │ ├── EraseSaveView.vue
│ │ ├── FlashCarts.vue
│ │ ├── GameCube.vue
│ │ ├── GbaActionReplay.vue
│ │ ├── GbaGameShark.vue
│ │ ├── GbaGameSharkSP.vue
│ │ ├── Mister.vue
│ │ ├── N64DexDrive.vue
│ │ ├── N64Mempack.vue
│ │ ├── NintendoSwitchOnline.vue
│ │ ├── OnlineEmulators.vue
│ │ ├── OriginalHardware.vue
│ │ ├── OtherConverters.vue
│ │ ├── Ps1DexDrive.vue
│ │ ├── Ps1Emulator.vue
│ │ ├── Ps1Ps3.vue
│ │ ├── Ps1Psp.vue
│ │ ├── PspDecrypt.vue
│ │ ├── Retron5.vue
│ │ ├── Retron5EraseSaveView.vue
│ │ ├── SegaCd.vue
│ │ ├── SegaSaturnEmulator.vue
│ │ ├── SegaSaturnSaroo.vue
│ │ ├── SrmSav.vue
│ │ ├── Troubleshooting.vue
│ │ └── Wii.vue
│ ├── tests/
│ │ ├── config.js
│ │ ├── config.local.js.example
│ │ ├── data/
│ │ │ ├── rom-formats/
│ │ │ │ ├── gb/
│ │ │ │ │ ├── Wario Land 3 header.gbc
│ │ │ │ │ └── Zelda - Link's Awakening header.gb
│ │ │ │ ├── gba/
│ │ │ │ │ └── Zelda - Minish Cap header.gba
│ │ │ │ ├── nes/
│ │ │ │ │ └── Zelda II - header.nes
│ │ │ │ └── psp/
│ │ │ │ ├── encrypted-executable-alternative-boot - EBOOT.DNR
│ │ │ │ ├── encrypted-executable-alternative-boot.iso
│ │ │ │ ├── encrypted-executable-incorrect-magic.iso
│ │ │ │ ├── encrypted-executable-magic0.iso
│ │ │ │ ├── encrypted-executable-magic1.iso
│ │ │ │ ├── encrypted-executable-other-alternative-boot - GBL
│ │ │ │ ├── encrypted-executable-other-alternative-boot-wrong-game-id.iso
│ │ │ │ ├── encrypted-executable-other-alternative-boot.iso
│ │ │ │ └── unencrypted-executable.iso
│ │ │ ├── save-formats/
│ │ │ │ ├── dreamcast/
│ │ │ │ │ ├── individualsaves/
│ │ │ │ │ │ ├── FLPPYBRD-recreated.VMI
│ │ │ │ │ │ ├── FLPPYBRD.VMI
│ │ │ │ │ │ ├── FLPPYBRD.vms
│ │ │ │ │ │ ├── IKARUGA-recreated.VMI
│ │ │ │ │ │ ├── IKARUGA.VMI
│ │ │ │ │ │ ├── IKARUGA.VMS
│ │ │ │ │ │ ├── KISSPC-recreated.VMI
│ │ │ │ │ │ ├── KISSPC.VMI
│ │ │ │ │ │ ├── KISSPC.VMS
│ │ │ │ │ │ ├── kiss-psycho-circus-the-nightmare-child.29341-recreated.dci
│ │ │ │ │ │ ├── kiss-psycho-circus-the-nightmare-child.29341.dci
│ │ │ │ │ │ ├── project-justice.882-recreated.dci
│ │ │ │ │ │ ├── project-justice.882.dci
│ │ │ │ │ │ ├── tetr-recreated.dci
│ │ │ │ │ │ ├── tetr.dci
│ │ │ │ │ │ ├── v4596-recreated.vmi
│ │ │ │ │ │ ├── v4596.VMS
│ │ │ │ │ │ ├── v4596.vmi
│ │ │ │ │ │ ├── v93102-recreated.vmi
│ │ │ │ │ │ ├── v93102.VMS
│ │ │ │ │ │ └── v93102.vmi
│ │ │ │ │ ├── vmoooo.bin-0
│ │ │ │ │ └── vmu5_FUCKED.vmu
│ │ │ │ ├── flashcarts/
│ │ │ │ │ ├── gamegear/
│ │ │ │ │ │ └── Crystalis.sav
│ │ │ │ │ ├── gb/
│ │ │ │ │ │ └── Final Fantasy Legend II.srm
│ │ │ │ │ ├── gba/
│ │ │ │ │ │ ├── Metroid - Zero Mission (USA).sav
│ │ │ │ │ │ ├── goombaemulator/
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe) (32k - compressed save data).sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe) (32k - compressed save data).srm
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe)-from-cart.esv
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe)-from-cart.sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe)-from-goomba.esv
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe)-from-goomba.sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Oracle of Seasons (USA, Australia) (32kB - uncompressed save data).sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Oracle of Seasons (USA, Australia) (32kB - uncompressed save data).srm
│ │ │ │ │ │ │ ├── Wario Land 3.sav
│ │ │ │ │ │ │ └── Wario Land 3.srm
│ │ │ │ │ │ ├── pocketnesemulator/
│ │ │ │ │ │ │ ├── Zelda II - The Adventure of Link (USA)-from-cart.esv
│ │ │ │ │ │ │ ├── Zelda II - The Adventure of Link (USA)-from-cart.sav
│ │ │ │ │ │ │ ├── Zelda II - The Adventure of Link (USA)-from-pocketnes.esv
│ │ │ │ │ │ │ └── Zelda II - The Adventure of Link (USA)-from-pocketnes.sav
│ │ │ │ │ │ └── smsadvanceemulator/
│ │ │ │ │ │ ├── Phantasy Star (USA, Europe) (Rev 1)-from-coury-raw.srm
│ │ │ │ │ │ ├── Phantasy Star (USA, Europe) (Rev 1)-from-coury.srm
│ │ │ │ │ │ ├── Phantasy Star (USA, Europe) (Rev A)-from-emulator-to-smsadvance.sav
│ │ │ │ │ │ ├── Phantasy Star (USA, Europe) (Rev A)-from-emulator.sav
│ │ │ │ │ │ ├── Phantasy Star (USA, Europe) (v1.3)-from-reddit-raw.srm
│ │ │ │ │ │ ├── Phantasy Star (USA, Europe) (v1.3)-from-reddit.srm
│ │ │ │ │ │ ├── Phantasy Star-from-smsadvance-bundled-raw.sav
│ │ │ │ │ │ └── Phantasy Star-from-smsadvance-bundled.sav
│ │ │ │ │ ├── genesis/
│ │ │ │ │ │ ├── megaeverdrivepro/
│ │ │ │ │ │ │ ├── 36 Great Holes Starring Fred Couples (Japan, USA).srm
│ │ │ │ │ │ │ ├── Dark Wizard (USA) cd-bram.brm
│ │ │ │ │ │ │ ├── Knuckles' Chaotix (Japan, USA) (En).srm
│ │ │ │ │ │ │ ├── Phantasy Star (USA, Europe) (Rev A).srm
│ │ │ │ │ │ │ ├── Phantasy Star II (USA, Europe) (Rev A).srm
│ │ │ │ │ │ │ ├── Phantasy_Star_USA_Europe_Rev_A-byte-collapsed.srm
│ │ │ │ │ │ │ ├── Phantasy_Star_USA_Europe_Rev_A-byte-expanded.srm
│ │ │ │ │ │ │ ├── Popful Mail (USA) (RE) cd-cart-raw.srm
│ │ │ │ │ │ │ ├── Popful Mail (USA) (RE) cd-cart.srm
│ │ │ │ │ │ │ ├── Popful Mail (USA) (RE)-from-emulator cart.brm
│ │ │ │ │ │ │ ├── Popful Mail (USA) (RE)-from-emulator-to-med cd-cart.srm
│ │ │ │ │ │ │ ├── Sonic The Hedgehog 3 (USA).srm
│ │ │ │ │ │ │ ├── Wonder Boy in Monster World (USA, Europe).srm
│ │ │ │ │ │ │ ├── Zelda II - The Adventure of Link (USA).srm
│ │ │ │ │ │ │ └── emulator/
│ │ │ │ │ │ │ └── Sonic The Hedgehog 3 (USA).sav
│ │ │ │ │ │ └── megasd/
│ │ │ │ │ │ ├── 36 Great Holes Starring Fred Couples (Japan, USA)-raw.sav
│ │ │ │ │ │ ├── 36 Great Holes Starring Fred Couples (Japan, USA).SRM
│ │ │ │ │ │ ├── Knuckles' Chaotix (Japan, USA) (En)-raw.sav
│ │ │ │ │ │ ├── Knuckles' Chaotix (Japan, USA) (En).SRM
│ │ │ │ │ │ ├── Lunar_The_Silver_Star-internal-memory.brm
│ │ │ │ │ │ ├── Lunar_The_Silver_Star-ram-cart.brm
│ │ │ │ │ │ ├── Lunar_The_Silver_Star.SRM
│ │ │ │ │ │ ├── Phantasy Star (USA, Europe) (Rev 1)-raw.sav
│ │ │ │ │ │ ├── Phantasy Star (USA, Europe) (Rev 1).SRM
│ │ │ │ │ │ ├── Phantasy Star II (USA, Europe) (Rev A)-new-style-raw.sav
│ │ │ │ │ │ ├── Phantasy Star II (USA, Europe) (Rev A)-new-style.SRM
│ │ │ │ │ │ ├── Phantasy Star II (USA, Europe) (Rev A)-old-style-raw.sav
│ │ │ │ │ │ ├── Phantasy Star II (USA, Europe) (Rev A)-old-style.srm
│ │ │ │ │ │ ├── Phantasy Star IV (USA)-new-style-converted-back.SRM
│ │ │ │ │ │ ├── Phantasy Star IV (USA)-new-style-raw.sav
│ │ │ │ │ │ ├── Phantasy Star IV (USA)-new-style.SRM
│ │ │ │ │ │ ├── Popful Mail (U)-converted-back-internal-memory.SRM
│ │ │ │ │ │ ├── Popful Mail (U)-converted-back-ram-cart.SRM
│ │ │ │ │ │ ├── Popful Mail (U)-internal-memory-only.SRM
│ │ │ │ │ │ ├── Popful Mail (U)-internal-memory.brm
│ │ │ │ │ │ ├── Popful Mail (U)-ram-cart.brm
│ │ │ │ │ │ ├── Popful Mail (U).SRM
│ │ │ │ │ │ ├── Sonic the Hedgehog 3 (USA)-new-style-raw.sav
│ │ │ │ │ │ ├── Sonic the Hedgehog 3 (USA)-new-style.SRM
│ │ │ │ │ │ ├── Sword of Vermilion (USA, Europe)-new-style-raw.sav
│ │ │ │ │ │ ├── Sword of Vermilion (USA, Europe)-new-style.SRM
│ │ │ │ │ │ ├── Wonder Boy in Monster World (USA, Europe)-new-style-raw.sav
│ │ │ │ │ │ ├── Wonder Boy in Monster World (USA, Europe)-new-style.SRM
│ │ │ │ │ │ ├── Wonder Boy in Monster World (USA, Europe)-old-style.srm
│ │ │ │ │ │ └── emulator/
│ │ │ │ │ │ └── Sonic The Hedgehog 3 (USA).sav
│ │ │ │ │ ├── n64/
│ │ │ │ │ │ ├── Pokemon Snap-flashcart.fla
│ │ │ │ │ │ ├── Pokemon Snap.fla
│ │ │ │ │ │ ├── Star Fox 64.eep
│ │ │ │ │ │ ├── f-zero-x.15165-flashcart.sra
│ │ │ │ │ │ ├── f-zero-x.15165.sra
│ │ │ │ │ │ ├── gb64emulator/
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe)-emulator-to-everdrive.fla
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe)-emulator.sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe)-everdrive-to-raw.sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe)-everdrive-uncompressed-data.sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Link's Awakening (USA, Europe)-everdrive.fla
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Oracle of Seasons (USA, Australia)-emulator-to-everdrive.fla
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Oracle of Seasons (USA, Australia)-emulator.sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Oracle of Seasons (USA, Australia)-everdrive-to-raw.sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Oracle of Seasons (USA, Australia)-everdrive-uncompressed-data.sav
│ │ │ │ │ │ │ ├── Legend of Zelda, The - Oracle of Seasons (USA, Australia)-everdrive.fla
│ │ │ │ │ │ │ ├── Wario Land 3 (World) (En,Ja)-emulator-to-everdrive.fla
│ │ │ │ │ │ │ ├── Wario Land 3 (World) (En,Ja)-emulator.sav
│ │ │ │ │ │ │ ├── Wario Land 3 (World) (En,Ja)-everdrive-to-raw.sav
│ │ │ │ │ │ │ ├── Wario Land 3 (World) (En,Ja)-everdrive-uncompressed-data.sav
│ │ │ │ │ │ │ └── Wario Land 3 (World) (En,Ja)-everdrive.fla
│ │ │ │ │ │ ├── neon64emulator/
│ │ │ │ │ │ │ ├── Zelda II - The Adventure of Link (USA)-neon.srm
│ │ │ │ │ │ │ ├── Zelda II - The Adventure of Link (USA)-to-raw.sav
│ │ │ │ │ │ │ ├── Zelda II - The Adventure of Link-cart-to-neon.srm
│ │ │ │ │ │ │ └── Zelda II - The Adventure of Link-cart.sav
│ │ │ │ │ │ ├── nes/
│ │ │ │ │ │ │ ├── Zelda II - The Adventure of Link (USA)-from-cart-to-everdrive.sav
│ │ │ │ │ │ │ ├── Zelda II - The Adventure of Link (USA)-from-cart.sav
│ │ │ │ │ │ │ └── Zelda II - The Adventure of Link (USA)-from-everdrive.srm
│ │ │ │ │ │ └── yoshis-story.17238.eep
│ │ │ │ │ ├── nes/
│ │ │ │ │ │ └── Crystalis.sav
│ │ │ │ │ ├── pcengine/
│ │ │ │ │ │ └── Castlevania - Rondo of Blood (my save) - raw.sav
│ │ │ │ │ ├── sms/
│ │ │ │ │ │ └── Phantasy Star (USA, Europe) (Rev A).srm
│ │ │ │ │ └── snes/
│ │ │ │ │ ├── Donkey Kong Country 2 - Diddy's Kong Quest.srm
│ │ │ │ │ └── Legend of Zelda, The - Link's Awakening (USA, Europe).srm
│ │ │ │ ├── gamecube/
│ │ │ │ │ ├── GameShark/
│ │ │ │ │ │ ├── soulcalibur-ii.20763.gcs
│ │ │ │ │ │ └── soulcalibur-ii.20766.gcs
│ │ │ │ │ ├── GameSpecificFixups/
│ │ │ │ │ │ ├── f_zero_gx_usa.gci
│ │ │ │ │ │ └── phantasy-star-online-SNUGGLES-WEAPONS.gcp
│ │ │ │ │ ├── MaxDrive/
│ │ │ │ │ │ ├── soulcalibur-ii.20764.sav
│ │ │ │ │ │ └── soulcalibur-ii.8736.sav
│ │ │ │ │ ├── gci/
│ │ │ │ │ │ ├── bleach_gc_tasogare_ni_mamieru_shinigami_jp.gci
│ │ │ │ │ │ ├── dokapon_dx_wataru_sekai_wa_oni_darake_jp_1.gci
│ │ │ │ │ │ ├── hikaru_no_go_3_jp.gci
│ │ │ │ │ │ ├── konjiki_no_gashbell__yuujou_no_tag_battle_jp.gci
│ │ │ │ │ │ ├── need_for_speed_underground_2_usa-recreated.gci
│ │ │ │ │ │ └── need_for_speed_underground_2_usa.gci
│ │ │ │ │ ├── jpn-empty-0251b-16mb.raw
│ │ │ │ │ ├── memcard-image-empty-no-serial.raw
│ │ │ │ │ ├── memcard-image-japan-nintendont.raw
│ │ │ │ │ ├── memcard-image-recreated-fzero-no-serial.raw
│ │ │ │ │ ├── memcard-image-recreated-fzero.raw
│ │ │ │ │ ├── memcard-image-recreated-resized.raw
│ │ │ │ │ ├── memcard-image-recreated.raw
│ │ │ │ │ ├── memcard-image.raw
│ │ │ │ │ ├── mine-different-flash-id-different-date.raw
│ │ │ │ │ ├── mine-different-flash-id.raw
│ │ │ │ │ ├── mine-same-flash-id-different-date.raw
│ │ │ │ │ ├── mine-same-flash-id.raw
│ │ │ │ │ └── usa-empty-0251b-16mb.raw
│ │ │ │ ├── gba/
│ │ │ │ │ ├── action-replay/
│ │ │ │ │ │ ├── the-legend-of-zelda-the-minish-cap.11439.srm
│ │ │ │ │ │ └── the-legend-of-zelda-the-minish-cap.11439.xps
│ │ │ │ │ ├── gameshark/
│ │ │ │ │ │ ├── mario-and-luigi-superstar-saga.25949.sps
│ │ │ │ │ │ ├── the-legend-of-zelda-the-minish-cap.6650.sps
│ │ │ │ │ │ ├── the-legend-of-zelda-the-minish-cap.6650.sps-2
│ │ │ │ │ │ └── the-legend-of-zelda-the-minish-cap.6650.srm
│ │ │ │ │ └── gameshark-sp/
│ │ │ │ │ ├── final-fantasy-tactics-advance.22864.gsv
│ │ │ │ │ └── final-fantasy-tactics-advance.22864.srm
│ │ │ │ ├── mister/
│ │ │ │ │ ├── gba/
│ │ │ │ │ │ ├── Final_Fight_One_Japan-mister.sav
│ │ │ │ │ │ ├── Final_Fight_One_Japan-raw-converted-back.srm
│ │ │ │ │ │ ├── Final_Fight_One_Japan-raw.srm
│ │ │ │ │ │ ├── pokemon-sapphire-mister-fake-rtc.sav
│ │ │ │ │ │ ├── pokemon-sapphire-mister-no-rtc.sav
│ │ │ │ │ │ ├── pokemon-sapphire-raw.srm
│ │ │ │ │ │ ├── the-legend-of-zelda-the-minish-cap.sav
│ │ │ │ │ │ └── the-legend-of-zelda-the-minish-cap.srm
│ │ │ │ │ ├── genesis/
│ │ │ │ │ │ ├── Phantasy Star II (USA, Europe) (Rev A)-from-mega-sd.sav
│ │ │ │ │ │ ├── Phantasy Star II (USA, Europe) (Rev A)-from-mega-sd.srm
│ │ │ │ │ │ ├── Phantasy_Star_IV_USA-from-mister-zero-padded-to-raw.srm
│ │ │ │ │ │ ├── Phantasy_Star_IV_USA-from-mister-zero-padded.sav
│ │ │ │ │ │ ├── Phantasy_Star_IV_USA-from-retrode-to-mister.sav
│ │ │ │ │ │ ├── Phantasy_Star_IV_USA-from-retrode.srm
│ │ │ │ │ │ ├── Wonder Boy in Monster World mister.sav
│ │ │ │ │ │ ├── Wonder Boy in Monster World raw.srm
│ │ │ │ │ │ ├── phantasy-star-ii.18168-mister.sav
│ │ │ │ │ │ └── phantasy-star-ii.18168-raw.srm
│ │ │ │ │ ├── n64/
│ │ │ │ │ │ ├── 007 - The World Is Not Enough (USA)_1.cpk
│ │ │ │ │ │ ├── Banjo-Kazooie (USA).eep
│ │ │ │ │ │ ├── Donkey Kong 64 (USA).eep
│ │ │ │ │ │ ├── Legend of Zelda, The - Majora's Mask (USA).fla
│ │ │ │ │ │ └── Legend of Zelda, The - Ocarina of Time (USA).sra
│ │ │ │ │ ├── segacd/
│ │ │ │ │ │ ├── Empty-mister-save.sav
│ │ │ │ │ │ ├── Popful Mail (USA) (RE)-internal-plus-raw-cart-to-mister.sav
│ │ │ │ │ │ ├── Popful Mail (USA) (RE)-mister-internal-only.sav
│ │ │ │ │ │ ├── Popful Mail (USA) (RE)-ram-cart-only.brm
│ │ │ │ │ │ ├── Popful Mail (USA) (RE)-raw-cart-only-to-mister.sav
│ │ │ │ │ │ ├── Popful Mail (USA) (RE)-raw-internal.brm
│ │ │ │ │ │ ├── Popful Mail (USA) Internal plus Cart to-emulator-internal.brm
│ │ │ │ │ │ ├── Popful Mail (USA) Internal plus Cart to-emulator-ram-cart.brm
│ │ │ │ │ │ ├── Popful Mail (USA) Internal plus Cart-mister.sav
│ │ │ │ │ │ ├── Shining Force CD (USA) (3R)-mister-internal-only-padded.sav
│ │ │ │ │ │ └── Shining Force CD (USA) (3R)-to-emulator-internal.brm
│ │ │ │ │ └── segasaturn/
│ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA)-recreated.sav
│ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA).bkr
│ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA).sav
│ │ │ │ │ ├── Empty-mister-save.sav
│ │ │ │ │ ├── Pretend-mister-save-only-cart.sav
│ │ │ │ │ ├── Rayman (USA) (R2)-cart-uncompressed.bcr
│ │ │ │ │ ├── Rayman (USA) (R2)-cart.bcr
│ │ │ │ │ ├── Rayman (USA) (R2)-internal.bkr
│ │ │ │ │ ├── Rayman (USA) (R2).sav
│ │ │ │ │ ├── Sega Rally Championship Plus (Japan).bkr
│ │ │ │ │ └── Sega Rally Championship Plus (Japan).sav
│ │ │ │ ├── n64/
│ │ │ │ │ ├── dexdrive/
│ │ │ │ │ │ ├── Ready 2 Rumble Boxing (U) [!]-fixed-1
│ │ │ │ │ │ ├── Ready 2 Rumble Boxing (U) [!]-fixed-2
│ │ │ │ │ │ ├── Ready 2 Rumble Boxing (U) [!]-fixed.mpk
│ │ │ │ │ │ ├── Ready 2 Rumble Boxing (U) [!].n64
│ │ │ │ │ │ ├── banjo-kazooie.1141-1
│ │ │ │ │ │ ├── banjo-kazooie.1141-2
│ │ │ │ │ │ ├── banjo-kazooie.1141-3
│ │ │ │ │ │ ├── banjo-kazooie.1141-4
│ │ │ │ │ │ ├── banjo-kazooie.1141.mpk
│ │ │ │ │ │ ├── banjo-kazooie.1141.n64
│ │ │ │ │ │ ├── banjokaz-1
│ │ │ │ │ │ ├── banjokaz-2
│ │ │ │ │ │ ├── banjokaz.mpk
│ │ │ │ │ │ ├── banjokaz.n64
│ │ │ │ │ │ ├── donkey-kong-64.1156-1
│ │ │ │ │ │ ├── donkey-kong-64.1156.mpk
│ │ │ │ │ │ ├── donkey-kong-64.1156.n64
│ │ │ │ │ │ ├── ecw-hardcore-revolution-empty-header.1000.n64
│ │ │ │ │ │ ├── ecw-hardcore-revolution-no-header.1000.n64
│ │ │ │ │ │ ├── ecw-hardcore-revolution.1000-1
│ │ │ │ │ │ ├── ecw-hardcore-revolution.1000.mpk
│ │ │ │ │ │ ├── ecw-hardcore-revolution.1000.n64
│ │ │ │ │ │ ├── mario-kart-64.1102.eep
│ │ │ │ │ │ ├── mario-kart-64.1102.n64
│ │ │ │ │ │ ├── mario-kart-64.1116-1
│ │ │ │ │ │ ├── mario-kart-64.1116.mpk
│ │ │ │ │ │ ├── mario-kart-64.1116.n64
│ │ │ │ │ │ ├── perfect-dark.1043.1116-1
│ │ │ │ │ │ ├── perfect-dark.1043.mpk
│ │ │ │ │ │ ├── perfect-dark.1043.n64
│ │ │ │ │ │ ├── san-francisco-rush-extreme-racing.1103-1
│ │ │ │ │ │ ├── san-francisco-rush-extreme-racing.1103.mpk
│ │ │ │ │ │ ├── san-francisco-rush-extreme-racing.1103.n64
│ │ │ │ │ │ ├── super-mario-64.1091-1
│ │ │ │ │ │ ├── super-mario-64.1091.mpk
│ │ │ │ │ │ ├── super-mario-64.1091.n64
│ │ │ │ │ │ ├── tony-hawks-pro-skater-2.1077-1
│ │ │ │ │ │ ├── tony-hawks-pro-skater-2.1077-2
│ │ │ │ │ │ ├── tony-hawks-pro-skater-2.1077-output.n64
│ │ │ │ │ │ ├── tony-hawks-pro-skater-2.1077.mpk
│ │ │ │ │ │ └── tony-hawks-pro-skater-2.1077.n64
│ │ │ │ │ └── mempack/
│ │ │ │ │ ├── banjokaz-1
│ │ │ │ │ ├── mario-kart-64.1116-1
│ │ │ │ │ ├── mario-kart-64.1116.mpk
│ │ │ │ │ ├── san-francisco-rush-extreme-racing.1103-1
│ │ │ │ │ ├── super-mario-64.1091-1
│ │ │ │ │ ├── tony-hawks-pro-skater-2.1077-1
│ │ │ │ │ ├── tony-hawks-pro-skater-2.1077-2
│ │ │ │ │ └── tony-hawks-pro-skater-2.1077.mpk
│ │ │ │ ├── nintendoswitchonline/
│ │ │ │ │ ├── gb/
│ │ │ │ │ │ ├── Kirbys_Dreamland_2.sav
│ │ │ │ │ │ ├── Kirbys_Dreamland_2.sram
│ │ │ │ │ │ ├── Links_Awakening_DX.sav
│ │ │ │ │ │ ├── Links_Awakening_DX.sram
│ │ │ │ │ │ ├── Metroid_II_Return_of_Samus.sav
│ │ │ │ │ │ ├── Metroid_II_Return_of_Samus.sram
│ │ │ │ │ │ ├── Pokemon Cristal.sav
│ │ │ │ │ │ ├── Pokemon Cristal.sram
│ │ │ │ │ │ ├── Pokemon_-_Crystal_Version.sav
│ │ │ │ │ │ ├── Pokemon_-_Crystal_Version.sram
│ │ │ │ │ │ ├── Pokemon_TCG.sav
│ │ │ │ │ │ ├── Pokemon_TCG.sram
│ │ │ │ │ │ ├── Pokemon_TCG_Europe.sav
│ │ │ │ │ │ ├── Pokemon_TCG_Europe.sram
│ │ │ │ │ │ ├── Wario_Land_3.sav
│ │ │ │ │ │ └── Wario_Land_3.sram
│ │ │ │ │ ├── gba/
│ │ │ │ │ │ └── The_Legend_of_Zelda_The_Minish_Cap.sram
│ │ │ │ │ ├── genesis/
│ │ │ │ │ │ ├── MegaMan Wily Wars SEGA GENESIS.sav
│ │ │ │ │ │ ├── MegaMan Wily Wars SEGA GENESIS.sram
│ │ │ │ │ │ ├── Phantasy Star 4 SEGA GENESIS.sav
│ │ │ │ │ │ └── Phantasy Star 4 SEGA GENESIS.sram
│ │ │ │ │ ├── n64/
│ │ │ │ │ │ ├── F-Zero N64.sra
│ │ │ │ │ │ ├── F-Zero N64.sram
│ │ │ │ │ │ ├── Majoras Mask N64.fla
│ │ │ │ │ │ ├── Majoras Mask N64.sram
│ │ │ │ │ │ ├── Mario 64 N64.sram
│ │ │ │ │ │ ├── Mario Tennis N64.sram
│ │ │ │ │ │ └── Operation Winback N64.sram
│ │ │ │ │ ├── nes/
│ │ │ │ │ │ ├── Legend_of_Zelda_The_USA_Rev_1.sram
│ │ │ │ │ │ └── Zelda-converted.srm
│ │ │ │ │ └── snes/
│ │ │ │ │ └── Super Mario World SNES.sram
│ │ │ │ ├── online-emulators/
│ │ │ │ │ ├── arcadespot.com/
│ │ │ │ │ │ ├── gb/
│ │ │ │ │ │ │ ├── final-fantasy-legend.sav
│ │ │ │ │ │ │ ├── final-fantasy-legend.save
│ │ │ │ │ │ │ ├── invalid-save-state.save
│ │ │ │ │ │ │ ├── legend-of-zelda-the-links-awakening.sav
│ │ │ │ │ │ │ ├── legend-of-zelda-the-links-awakening.save
│ │ │ │ │ │ │ ├── pokemon-crystal.sav
│ │ │ │ │ │ │ ├── pokemon-crystal.save
│ │ │ │ │ │ │ ├── pokemon-yellow.sav
│ │ │ │ │ │ │ ├── pokemon-yellow.save
│ │ │ │ │ │ │ ├── the-legend-of-zelda-links-awakening-dx.sav
│ │ │ │ │ │ │ ├── the-legend-of-zelda-links-awakening-dx.save
│ │ │ │ │ │ │ ├── the-legend-of-zelda-oracle-of-seasons.sav
│ │ │ │ │ │ │ ├── the-legend-of-zelda-oracle-of-seasons.save
│ │ │ │ │ │ │ ├── wario-land-2.sav
│ │ │ │ │ │ │ └── wario-land-2.save
│ │ │ │ │ │ ├── gba/
│ │ │ │ │ │ │ ├── advance-wars.sav
│ │ │ │ │ │ │ ├── advance-wars.save
│ │ │ │ │ │ │ ├── donkey-kong-country-3.sav
│ │ │ │ │ │ │ ├── donkey-kong-country-3.save
│ │ │ │ │ │ │ ├── golden-sun.sav
│ │ │ │ │ │ │ ├── golden-sun.save
│ │ │ │ │ │ │ ├── invalid-save-state.save
│ │ │ │ │ │ │ ├── metroid-fusion.sav
│ │ │ │ │ │ │ ├── metroid-fusion.save
│ │ │ │ │ │ │ ├── metroid-zero-mission.sav
│ │ │ │ │ │ │ ├── metroid-zero-mission.save
│ │ │ │ │ │ │ ├── pokemon-sapphire.sav
│ │ │ │ │ │ │ ├── pokemon-sapphire.save
│ │ │ │ │ │ │ ├── super-mario-advance-4.sav
│ │ │ │ │ │ │ ├── super-mario-advance-4.save
│ │ │ │ │ │ │ ├── the-legend-of-zelda-the-minish-cap.sav
│ │ │ │ │ │ │ └── the-legend-of-zelda-the-minish-cap.save
│ │ │ │ │ │ ├── genesis/
│ │ │ │ │ │ │ ├── phantasy-star-ii.save
│ │ │ │ │ │ │ └── readme
│ │ │ │ │ │ └── snes/
│ │ │ │ │ │ ├── Legend-of-Zelda-The-A-Link-to-the-Past-U-.sav
│ │ │ │ │ │ └── Legend-of-Zelda-The-A-Link-to-the-Past-U-.save
│ │ │ │ │ ├── myemulator.online/
│ │ │ │ │ │ ├── gba/
│ │ │ │ │ │ │ ├── gbazelda-0.sav
│ │ │ │ │ │ │ ├── gbazelda-1.sav
│ │ │ │ │ │ │ ├── gbazelda-2.sav
│ │ │ │ │ │ │ ├── gbazelda-3.sav
│ │ │ │ │ │ │ └── gbazelda.ggz
│ │ │ │ │ │ ├── genesis/
│ │ │ │ │ │ │ ├── genesisphantasystar4.ggz
│ │ │ │ │ │ │ └── readme
│ │ │ │ │ │ └── snes/
│ │ │ │ │ │ ├── sneszelda1.ggz
│ │ │ │ │ │ └── sneszelda1.sav
│ │ │ │ │ └── retrogames.onl/
│ │ │ │ │ ├── gb/
│ │ │ │ │ │ ├── final-fantasy-legend-gboy.sav
│ │ │ │ │ │ ├── final-fantasy-legend-gboy.state
│ │ │ │ │ │ ├── invalid-file.state
│ │ │ │ │ │ ├── legend-zelda-link-awakening-dx.sav
│ │ │ │ │ │ ├── legend-zelda-link-awakening-dx.state
│ │ │ │ │ │ ├── pokemon-crystal.sav
│ │ │ │ │ │ ├── pokemon-crystal.state
│ │ │ │ │ │ ├── pokemon-yellow.sav
│ │ │ │ │ │ ├── pokemon-yellow.state
│ │ │ │ │ │ ├── wario-land-2-gbcolor.sav
│ │ │ │ │ │ ├── wario-land-2-gbcolor.state
│ │ │ │ │ │ ├── wrong-size.state
│ │ │ │ │ │ ├── zelda-oracle-sesion.sav
│ │ │ │ │ │ └── zelda-oracle-sesion.state
│ │ │ │ │ └── gba/
│ │ │ │ │ ├── advance-wars-gmba.sav
│ │ │ │ │ ├── advance-wars-gmba.state
│ │ │ │ │ ├── baldurs-gate.sav
│ │ │ │ │ ├── baldurs-gate.state
│ │ │ │ │ ├── golden-sun-gmba.sav
│ │ │ │ │ ├── golden-sun-gmba.state
│ │ │ │ │ ├── invalid-file.state
│ │ │ │ │ ├── metroid-fusion.sav
│ │ │ │ │ ├── metroid-fusion.state
│ │ │ │ │ ├── metroid-zero-mission.sav
│ │ │ │ │ ├── metroid-zero-mission.state
│ │ │ │ │ ├── pokemon-sapphire.sav
│ │ │ │ │ ├── pokemon-sapphire.state
│ │ │ │ │ ├── super-mario-advance-4.sav
│ │ │ │ │ ├── super-mario-advance-4.state
│ │ │ │ │ ├── wrong-size.state
│ │ │ │ │ ├── zelda-minish-cap.sav
│ │ │ │ │ └── zelda-minish-cap.state
│ │ │ │ ├── ps1/
│ │ │ │ │ ├── dexdrive/
│ │ │ │ │ │ ├── Castlevania - Symphony of the Night (USA).0.BASLUS-00067DRAX00.srm
│ │ │ │ │ │ ├── Castlevania - Symphony of the Night (USA).0.gme
│ │ │ │ │ │ ├── Castlevania - Symphony of the Night (USA).0.mcr
│ │ │ │ │ │ ├── castlevania-symphony-of-the-night.1368-BASLUS-00067DRAX01.srm
│ │ │ │ │ │ ├── castlevania-symphony-of-the-night.1368.gme
│ │ │ │ │ │ ├── castlevania-symphony-of-the-night.1782-BASLUS-00067DRAX00.srm
│ │ │ │ │ │ ├── castlevania-symphony-of-the-night.1782-BASLUS-00067DRAX01.srm
│ │ │ │ │ │ ├── castlevania-symphony-of-the-night.1782.gme
│ │ │ │ │ │ ├── castlevania-symphony-of-the-night.3172.gme
│ │ │ │ │ │ ├── digimon-world.21133-BASLUS-01032DMR0.srm
│ │ │ │ │ │ ├── digimon-world.21133.gme
│ │ │ │ │ │ ├── gran-turismo.26535-BASCUS-94194GT.srm
│ │ │ │ │ │ ├── gran-turismo.26535.gme
│ │ │ │ │ │ ├── gran-turismo.26537-BASCUS-94194GT.srm
│ │ │ │ │ │ ├── gran-turismo.26537-BASCUS-94194RT.srm
│ │ │ │ │ │ ├── gran-turismo.26537-output.gme
│ │ │ │ │ │ ├── gran-turismo.26537.gme
│ │ │ │ │ │ ├── tony-hawks-pro-skater-4.24197-BASLUS-01485PNMOG01.srm
│ │ │ │ │ │ └── tony-hawks-pro-skater-4.24197.gme
│ │ │ │ │ ├── memcard/
│ │ │ │ │ │ ├── Castlevania - Symphony of the Night (USA).0.BASLUS-00067DRAX00.srm
│ │ │ │ │ │ ├── Castlevania - Symphony of the Night (USA).0.mcr
│ │ │ │ │ │ ├── Castlevania - Symphony of the Night (USA).1.mcr
│ │ │ │ │ │ ├── castlevania-symphony-of-the-night.1782-BASLUS-00067DRAX00.srm
│ │ │ │ │ │ ├── castlevania-symphony-of-the-night.1782-BASLUS-00067DRAX01.srm
│ │ │ │ │ │ ├── gran-turismo.26537-BASCUS-94194GT.srm
│ │ │ │ │ │ └── gran-turismo.26537-BASCUS-94194RT.srm
│ │ │ │ │ ├── ps3/
│ │ │ │ │ │ ├── Street Fighter EX2 Plus-BASLUS-0110553595354454D.PSV
│ │ │ │ │ │ ├── Suikoden 2-BASLUS-009584753322D37.PSV
│ │ │ │ │ │ ├── Suikoden 2-BASLUS-00958GS2-7.srm
│ │ │ │ │ │ └── street-fighter-ex2-plus.17782.mcr
│ │ │ │ │ └── psp/
│ │ │ │ │ ├── Castlevania - Symphony of the Night (USA).0.BASLUS-00067DRAX00.srm
│ │ │ │ │ ├── Castlevania - Symphony of the Night (USA).0.VMP
│ │ │ │ │ ├── Castlevania - Symphony of the Night (USA).0.mcr
│ │ │ │ │ ├── Suikoden 1-save-editor-signature.VMP
│ │ │ │ │ ├── Suikoden 2-BASLUS-00958GS2-1.srm
│ │ │ │ │ ├── Suikoden 2-BASLUS-00958GS2-2.srm
│ │ │ │ │ ├── Suikoden 2-BASLUS-00958GS2-3.srm
│ │ │ │ │ ├── Suikoden 2-BASLUS-00958GS2-4.srm
│ │ │ │ │ ├── Suikoden 2-BASLUS-00958GS2-5.srm
│ │ │ │ │ ├── Suikoden 2-BASLUS-00958GS2-6.srm
│ │ │ │ │ ├── Suikoden 2-BASLUS-00958GS2-7.srm
│ │ │ │ │ ├── Suikoden 2-no-signature.VMP
│ │ │ │ │ ├── Suikoden 2-psp-signature.VMP
│ │ │ │ │ ├── Suikoden 2-vita-mcr2vmp-signature.VMP
│ │ │ │ │ ├── Suikoden 2.VMP
│ │ │ │ │ ├── gran-turismo.26537-BASCUS-94194GT.srm
│ │ │ │ │ ├── gran-turismo.26537-BASCUS-94194RT.srm
│ │ │ │ │ └── gran-turismo.26537-output.vmp
│ │ │ │ ├── psp/
│ │ │ │ │ ├── DRACULA-PARAM-reencrypted-debug.SFO
│ │ │ │ │ ├── DRACULA-PARAM-reencrypted-release.SFO
│ │ │ │ │ ├── DRACULA-PARAM.SFO
│ │ │ │ │ └── PARAM.SFO
│ │ │ │ ├── retron5/
│ │ │ │ │ ├── Final Fantasy III (USA).sav
│ │ │ │ │ ├── Final Fantasy III (USA).srm
│ │ │ │ │ ├── Tomato Adventure (Japan)-truncated.srm
│ │ │ │ │ └── Tomato Adventure (Japan).sav
│ │ │ │ ├── segacd/
│ │ │ │ │ ├── Multiple titles - corrupted.brm
│ │ │ │ │ ├── Multiple titles - created by program.brm
│ │ │ │ │ ├── Multiple titles-1.srm
│ │ │ │ │ ├── Multiple titles-2.srm
│ │ │ │ │ ├── Multiple titles-3.srm
│ │ │ │ │ ├── Multiple titles-4.srm
│ │ │ │ │ ├── Multiple titles-5.srm
│ │ │ │ │ ├── Multiple titles-6.srm
│ │ │ │ │ ├── Multiple titles-7.srm
│ │ │ │ │ ├── Multiple titles.brm
│ │ │ │ │ ├── Popful Mail (U)-internal-memory-1.srm
│ │ │ │ │ ├── Popful Mail (U)-internal-memory.brm
│ │ │ │ │ ├── SHINING FORCE CD-0.srm
│ │ │ │ │ ├── SHINING FORCE CD-1.srm
│ │ │ │ │ ├── SHINING FORCE CD-2.srm
│ │ │ │ │ ├── SHINING FORCE CD-3.srm
│ │ │ │ │ ├── SHINING FORCE CD-4.srm
│ │ │ │ │ └── SHINING FORCE CD.brm
│ │ │ │ ├── segasaturn/
│ │ │ │ │ ├── DRACULAX-recreated.BUP
│ │ │ │ │ ├── DRACULAX.BUP
│ │ │ │ │ ├── DRACULAX.raw
│ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA)-1.raw
│ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA)-2.raw
│ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA)-uncompressed-recreated.bcr
│ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA)-uncompressed.bcr
│ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA).bcr
│ │ │ │ │ ├── Dezaemon 2 (Japan)-1.raw
│ │ │ │ │ ├── Dezaemon 2 (Japan).bkr
│ │ │ │ │ ├── Empty save.bkr
│ │ │ │ │ ├── Hyper Duel (Japan)-1.raw
│ │ │ │ │ ├── Hyper Duel (Japan).bkr
│ │ │ │ │ ├── SFORCE31-recreated.BUP
│ │ │ │ │ ├── SFORCE31.BUP
│ │ │ │ │ ├── SFORCE31.raw
│ │ │ │ │ ├── Shining Force III Scenario 3 (English v25.1)-1.raw
│ │ │ │ │ ├── Shining Force III Scenario 3 (English v25.1)-cart-1.raw
│ │ │ │ │ ├── Shining Force III Scenario 3 (English v25.1)-recreated.bcr
│ │ │ │ │ ├── Shining Force III Scenario 3 (English v25.1)-uncompressed.bcr
│ │ │ │ │ ├── Shining Force III Scenario 3 (English v25.1).bcr
│ │ │ │ │ ├── Shining Force III Scenario 3 (English v25.1).bkr
│ │ │ │ │ ├── saroo/
│ │ │ │ │ │ ├── Blast Wind (Japan).raw
│ │ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA) 1-1.raw
│ │ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA) 1-2.raw
│ │ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA) 2-1.raw
│ │ │ │ │ │ ├── Daytona USA - Championship Circuit Edition (USA) 2-2.raw
│ │ │ │ │ │ ├── Dungeons and Dragons Collection (Japan) (Disc 2) (Shadows over Mystara).raw
│ │ │ │ │ │ ├── Hyper Duel (Japan).raw
│ │ │ │ │ │ ├── Shining Force III Scenario 1 (English v25.1)-1.raw
│ │ │ │ │ │ └── Shining Force III Scenario 1 (English v25.1)-2.raw
│ │ │ │ │ └── yabause/
│ │ │ │ │ ├── Akumajou Dracula X - Gekka no Yasoukyoku (Japan) (2M).srm
│ │ │ │ │ └── Akumajou Dracula X - Gekka no Yasoukyoku (Japan) (2M).srm-1.raw
│ │ │ │ └── wii/
│ │ │ │ ├── gametdb/
│ │ │ │ │ ├── C97E.html
│ │ │ │ │ ├── D40A.html
│ │ │ │ │ ├── DAXE01.html
│ │ │ │ │ ├── E6ZJ.html
│ │ │ │ │ ├── EAEP.html
│ │ │ │ │ ├── FBNE.html
│ │ │ │ │ ├── JECE.html
│ │ │ │ │ ├── LADE.html
│ │ │ │ │ ├── MC4E.html
│ │ │ │ │ ├── NADJ.html
│ │ │ │ │ ├── NAJE.html
│ │ │ │ │ ├── QAPN.html
│ │ │ │ │ ├── R3OP01.html
│ │ │ │ │ └── WKTE.html
│ │ │ │ ├── n64/
│ │ │ │ │ ├── f-zero-x.15165-raw.sra
│ │ │ │ │ ├── mario-golf.23681-raw.sra
│ │ │ │ │ ├── mario-kart-64.14534-raw.eep
│ │ │ │ │ ├── paper-mario.17225-raw.fla
│ │ │ │ │ ├── super-mario-64.14546-raw.eep
│ │ │ │ │ ├── the-legend-of-zelda-majoras-mask.19354-raw.fla
│ │ │ │ │ └── yoshis-story.17238-raw.eep
│ │ │ │ └── pcengine/
│ │ │ │ ├── Castlevania - Rondo of Blood (my save) - extracted.bup
│ │ │ │ ├── Castlevania - Rondo of Blood (my save) - raw.sav
│ │ │ │ ├── Castlevania - Rondo of Blood (test save) - extracted.bup
│ │ │ │ ├── Castlevania - Rondo of Blood (test save) - raw.sav
│ │ │ │ ├── battle-lode-runner.16193 - extracted.bup
│ │ │ │ ├── battle-lode-runner.16193 - raw.sav
│ │ │ │ ├── bomberman-93.20114 - extracted.bup
│ │ │ │ ├── bomberman-93.20114 - raw.sav
│ │ │ │ ├── neutopia-ii.21439 - extracted.bup
│ │ │ │ └── neutopia-ii.21439 - raw.sav
│ │ │ └── util/
│ │ │ ├── Multiple titles-to-ram-cart.brm
│ │ │ ├── Multiple titles.brm
│ │ │ ├── Popful Mail (USA) (RE)-internal-to-ram-cart.brm
│ │ │ ├── Popful Mail (USA) (RE)-internal.sav
│ │ │ ├── Tomato Adventure (Japan) extra padding at end.srm
│ │ │ ├── Tomato Adventure (Japan) fixed extra padding at end.srm
│ │ │ ├── Tomato Adventure (Japan) fixed extra padding at start.srm
│ │ │ ├── Tomato Adventure (Japan) fixed.srm
│ │ │ ├── Tomato Adventure (Japan) test save all padding.srm
│ │ │ ├── Tomato Adventure (Japan).srm
│ │ │ ├── Xenogears (USA) (Disc 1)-compressed.srm
│ │ │ ├── Xenogears (USA) (Disc 1)-uncompressed.srm
│ │ │ └── endian/
│ │ │ ├── 2ByteWord-input.sav
│ │ │ ├── 2ByteWord-output.sav
│ │ │ ├── 4ByteWord-input.sav
│ │ │ ├── 4ByteWord-output.sav
│ │ │ ├── 8ByteWord-input.sav
│ │ │ └── 8ByteWord-output.sav
│ │ ├── unit/
│ │ │ ├── rom-formats/
│ │ │ │ ├── GB/
│ │ │ │ │ └── gb.spec.js
│ │ │ │ ├── GBA/
│ │ │ │ │ └── gba.spec.js
│ │ │ │ ├── NES/
│ │ │ │ │ └── nes.spec.js
│ │ │ │ ├── PSP/
│ │ │ │ │ └── PspIso.spec.js
│ │ │ │ ├── SMS/
│ │ │ │ │ └── sms.spec.js
│ │ │ │ └── SegaSaturn/
│ │ │ │ └── SegaSaturnCueBin.spec.js
│ │ │ ├── save-formats/
│ │ │ │ ├── Dreamcast/
│ │ │ │ │ ├── Dreamcast.spec.js
│ │ │ │ │ └── IndividualSaves/
│ │ │ │ │ ├── Dci.spec.js
│ │ │ │ │ └── VmiVms.spec.js
│ │ │ │ ├── FlashCarts/
│ │ │ │ │ ├── GB.spec.js
│ │ │ │ │ ├── GBA/
│ │ │ │ │ │ ├── GBA.spec.js
│ │ │ │ │ │ ├── GoombaEmulator.spec.js
│ │ │ │ │ │ ├── PocketNesEmulator.spec.js
│ │ │ │ │ │ └── SmsAdvanceEmulator.spec.js
│ │ │ │ │ ├── GameGear.spec.js
│ │ │ │ │ ├── Genesis/
│ │ │ │ │ │ ├── MegaEverdrivePro/
│ │ │ │ │ │ │ ├── 32X.spec.js
│ │ │ │ │ │ │ ├── Genesis.spec.js
│ │ │ │ │ │ │ ├── NES.spec.js
│ │ │ │ │ │ │ ├── SMS.spec.js
│ │ │ │ │ │ │ └── SegaCd.spec.js
│ │ │ │ │ │ └── MegaSD/
│ │ │ │ │ │ ├── 32X.spec.js
│ │ │ │ │ │ ├── Genesis.spec.js
│ │ │ │ │ │ ├── SMS.spec.js
│ │ │ │ │ │ └── SegaCd.spec.js
│ │ │ │ │ ├── N64/
│ │ │ │ │ │ ├── GB64.spec.js
│ │ │ │ │ │ ├── N64.spec.js
│ │ │ │ │ │ ├── NES.spec.js
│ │ │ │ │ │ └── Neon64Emulator.spec.js
│ │ │ │ │ ├── NES.spec.js
│ │ │ │ │ ├── PcEngine.spec.js
│ │ │ │ │ ├── SMS.spec.js
│ │ │ │ │ └── SNES/
│ │ │ │ │ ├── GB.spec.js
│ │ │ │ │ └── SNES.spec.js
│ │ │ │ ├── GBA/
│ │ │ │ │ ├── ActionReplay.spec.js
│ │ │ │ │ ├── GameShark.spec.js
│ │ │ │ │ └── GameSharkSP.spec.js
│ │ │ │ ├── GameCube/
│ │ │ │ │ ├── GameCube.spec.js
│ │ │ │ │ ├── GameSpecificFixups/
│ │ │ │ │ │ ├── FZeroGx.spec.js
│ │ │ │ │ │ └── PhantasyStarOnline.spec.js
│ │ │ │ │ └── IndividualSaves/
│ │ │ │ │ ├── GameShark.spec.js
│ │ │ │ │ ├── Gci.spec.js
│ │ │ │ │ └── MaxDrive.spec.js
│ │ │ │ ├── Mister/
│ │ │ │ │ ├── GameboyAdvance.spec.js
│ │ │ │ │ ├── Genesis.spec.js
│ │ │ │ │ ├── N64Cart.spec.js
│ │ │ │ │ ├── N64Mempack.spec.js
│ │ │ │ │ ├── SegaCd.spec.js
│ │ │ │ │ └── SegaSaturn.spec.js
│ │ │ │ ├── N64/
│ │ │ │ │ ├── DexDrive.spec.js
│ │ │ │ │ ├── IndividualSaveFilename.spec.js
│ │ │ │ │ └── Mempack.spec.js
│ │ │ │ ├── NintendoSwitchOnline/
│ │ │ │ │ ├── Gameboy.spec.js
│ │ │ │ │ ├── GameboyAdvance.spec.js
│ │ │ │ │ ├── Genesis.spec.js
│ │ │ │ │ ├── N64.spec.js
│ │ │ │ │ ├── Nes.spec.js
│ │ │ │ │ └── Snes.spec.js
│ │ │ │ ├── OnlineEmulators/
│ │ │ │ │ ├── Emulators/
│ │ │ │ │ │ ├── Gambatte.spec.js
│ │ │ │ │ │ ├── Gb.spec.js
│ │ │ │ │ │ ├── Snes9x.spec.js
│ │ │ │ │ │ ├── VBA-Next.spec.js
│ │ │ │ │ │ └── mGba.spec.js
│ │ │ │ │ └── OnlineEmulatorWrapper.spec.js
│ │ │ │ ├── PS1/
│ │ │ │ │ ├── DexDrive.spec.js
│ │ │ │ │ ├── Memcard.spec.js
│ │ │ │ │ ├── Ps3.spec.js
│ │ │ │ │ └── Psp.spec.js
│ │ │ │ ├── PSP/
│ │ │ │ │ ├── Executable.spec.js
│ │ │ │ │ ├── ParamSfo.spec.js
│ │ │ │ │ └── Savefile.spec.js
│ │ │ │ ├── Retron5/
│ │ │ │ │ └── Retron5.spec.js
│ │ │ │ ├── SegaCd/
│ │ │ │ │ └── SegaCd.spec.js
│ │ │ │ ├── SegaSaturn/
│ │ │ │ │ ├── Emulators/
│ │ │ │ │ │ ├── mednafen.spec.js
│ │ │ │ │ │ ├── yabasanshiro.spec.js
│ │ │ │ │ │ └── yabause.spec.js
│ │ │ │ │ ├── IndividualSaves/
│ │ │ │ │ │ └── Bup.spec.js
│ │ │ │ │ ├── Saroo/
│ │ │ │ │ │ ├── Cart.spec.js
│ │ │ │ │ │ ├── Internal.spec.js
│ │ │ │ │ │ └── System.spec.js
│ │ │ │ │ └── SegaSaturn.spec.js
│ │ │ │ └── Wii/
│ │ │ │ ├── ConvertFrom/
│ │ │ │ │ ├── ConvertFromN64.spec.js
│ │ │ │ │ ├── ConvertFromPcEngine.spec.js
│ │ │ │ │ └── ConvertFromSega.spec.js
│ │ │ │ ├── GetPlatform/
│ │ │ │ │ ├── GetPlatform.spec.js
│ │ │ │ │ └── MockHttpClient.js
│ │ │ │ └── Wii.spec.js
│ │ │ └── util/
│ │ │ ├── CompressionGzip.spec.js
│ │ │ ├── CompressionLzo.spec.js
│ │ │ ├── CompressionRzip.spec.js
│ │ │ ├── CompressionZlib.spec.js
│ │ │ ├── Endian.spec.js
│ │ │ ├── Math.spec.js
│ │ │ ├── Padding.spec.js
│ │ │ ├── SaveFiles.spec.js
│ │ │ ├── SegaCdUtil.spec.js
│ │ │ ├── Troubleshooting test files.spec.js
│ │ │ ├── crypto-aes.spec.js
│ │ │ ├── crypto-des.spec.js
│ │ │ └── util.spec.js
│ │ └── util/
│ │ ├── ArrayBuffer.js
│ │ └── Crypto.js
│ └── vue.config.js
└── terraform/
├── .gitignore
├── aws_credentials.example
├── dev/
│ ├── main.tf
│ ├── provider.tf
│ ├── variables.tf
│ └── versions.tf
├── modules/
│ ├── alarms/
│ │ ├── outputs.tf
│ │ ├── sns-topic.tf
│ │ ├── variables.tf
│ │ └── versions.tf
│ ├── build-common-infrastructure/
│ │ ├── logs-bucket.tf
│ │ ├── outputs.tf
│ │ ├── role.tf
│ │ ├── variables.tf
│ │ └── versions.tf
│ ├── build-pipeline-frontend/
│ │ ├── alarms.tf
│ │ ├── code-build.tf
│ │ ├── email-logs.tf
│ │ ├── eventbridge.tf
│ │ ├── python/
│ │ │ └── email-logs.py
│ │ ├── variables.tf
│ │ └── versions.tf
│ └── frontend/
│ ├── build.tf
│ ├── cloudfront.tf
│ ├── dns.tf
│ ├── outputs.tf
│ ├── s3.tf
│ ├── variables.tf
│ └── versions.tf
├── prod/
│ ├── main.tf
│ ├── provider.tf
│ ├── variables.tf
│ └── versions.tf
└── terraform.tfvars.example
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
terraform/* linguist-detectable=false
================================================
FILE: .gitignore
================================================
.DS_Store
/test
/frontend/stats.json
/frontend/tests/data/rom-formats/psp/retail/*
/frontend/tests/data/rom-formats/psp/make-iso/*
/frontend/tests/data/rom-formats/sms/retail/*
/frontend/tests/data/save-formats/flashcarts/gba/goombaemulator/retail/*
/frontend/tests/data/save-formats/flashcarts/gba/pocketnesemulator/retail/*
/frontend/tests/data/save-formats/flashcarts/gba/smsadvanceemulator/retail/*
/frontend/tests/data/save-formats/flashcarts/n64/gb64emulator/retail/*
/frontend/tests/config.local.js
================================================
FILE: .vscode/settings.json
================================================
{
"vue.server.path": "node_modules/@vue/language-server",
}
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: README.md
================================================
# Save File Converter
Web-based tool to convert save files from retro game consoles to different formats

Available at https://savefileconverter.com
## Upcoming features
- PS2 formats (check out https://gamefaqs.gamespot.com/ps2/536777-suikoden-iii/saves for a potential list)
## Contact
If you have questions, need help, or have comments or suggestions, please hop on Discord: https://discord.gg/wtJ7xUKKTR
Or email savefileconverter (at) gmail (dot) com
## Donations
Everything on this site is free and open source with no advertising. If you find the site helpful and want to donate you can here:
[](https://www.paypal.com/donate/?hosted_button_id=DHDERHCQVLGPJ)
## Emulators with incompatible save formats
- (GBA) Pizza Boy files incompatible with mGBA (believe mGBA uses raw files)
- (PS1) Beetle HW incompatible with DuckStation
- (Saturn) SSF incompatible with Mednafen/Beetle Saturn
- (N64) Mupen64Plus Next files incompatible with Wii64
- (32X) Picodrive has its own save format (somewhere around here? https://github.com/notaz/picodrive/blob/1d366b1ad9362fd463c42979c8a687dfc7f46c46/platform/common/emu.c#L873)
## Save file formats
- Retron5
- https://www.retro5.net/viewtopic.php?f=5&t=67&start=10
- GameShark (GBA)
- (partial): https://gbatemp.net/threads/converting-gsv-or-sps-files-to-sav.51838/#post-664786
- (reading): https://github.com/visualboyadvance-m/visualboyadvance-m/blob/master/src/gba/GBA.cpp#L1025
- (writing): https://github.com/visualboyadvance-m/visualboyadvance-m/blob/master/src/gba/GBA.cpp#L1146
- (partial): https://github.com/mgba-emu/mgba/blob/master/src/gba/sharkport.c
- GameShark SP (GBA)
- (reading): https://github.com/visualboyadvance-m/visualboyadvance-m/blob/master/src/gba/GBA.cpp#L1078
- GameShark (PS2)
- (partial): https://www.ps2savetools.com/documents/xps-format/
- PS2
- Tool to parse images, contains links to docs describing various parts of the filesystem: https://github.com/sevonj/eightmb
- Description of PS2 save data: https://babyno.top/en/posts/2023/09/parsing-ps2-memcard-file-system/
- Parsing PS2 save data: https://babyno.top/en/posts/2023/09/exporting-file-from-ps2-memcard/
- Parser with 3D icon viewer: https://github.com/caol64/ps2mc-browser
- Save Tools: https://www.ps2savetools.com/
- mymc save tool:
- http://www.csclub.uwaterloo.ca:11068/mymc/
- https://github.com/ps2dev/mymc
- https://git.sr.ht/~thestr4ng3r/mymcplus (port of mymc with some enhancements)
- Memory card format:
- http://www.csclub.uwaterloo.ca:11068/mymc/ps2mcfs.html
- https://www.ps2savetools.com/ps2memcardformat.html (same info as link above, and that one appears to be newer/more cleaned up)
- Use PS2 files on PS3: https://github.com/bucanero/apollo-ps3
- Convert PS2 formats: https://github.com/bucanero/psv-save-converter
- Manage PS2 memory cards: https://github.com/bucanero/ps2vmc-tool
- Wii Virtual Console save game format (note that the files are encrypted)
- https://wiibrew.org/wiki/Wii_Savegame_Parser
- https://wiibrew.org/wiki/Savegame_Files
- https://wiibrew.org/wiki/FE100
- https://hackmii.com/2008/04/keys-keys-keys/
- https://github.com/Plombo/segher-wii-tools
- (writing) https://github.com/Plombo/segher-wii-tools/blob/master/twintig.c
- https://github.com/Plombo/vcromclaim
- https://github.com/JanErikGunnar/vcromclaim
- Wii U Virtual Console:
- https://github.com/mstan/SaveAIO
- PSP save file decryption/encryption (note that some files require a per-game key):
- KIRK engine reverse-engineering: https://github.com/ProximaV/kirk-engine-full/tree/master
- https://www.psdevwiki.com/ps3/PSP_Savedata
- https://github.com/BrianBTB/SED-PC (PC application that decrypts/encrypts saves)
- https://github.com/cielavenir/psp-savedata-endecrypter (improvement to SED-PC that works with "mode 4" (whatever that is???))
- https://wololo.net/talk/viewtopic.php?t=37556 (PSP plugin that dumps a game's key)
- https://github.com/TheHellcat/psp-hb/blob/master/SavegameDeemer_620TN/deemer_hooker/main.c#L204 Code for a plugin that does this. It intercepts the call to save the game, and writes out a copy of the save in plaintext, plus the key
- https://wololo.net/talk/viewtopic.php?p=137153&sid=37bd385d91e7c2d89cf1ff8d70e8c640#p137153 Discussion saying that you need to either use a plugin, or parse the executable for look for code around a certain system call to figure out what value is being passed to it
- I can't seem to find an online list of game keys, and requiring users to install a plugin on their PSP then type a gamekey into the interface seems like too much to ask.
- https://github.com/hrydgard/ppsspp PSP emulator
- https://github.com/hrydgard/ppsspp/blob/master/Tools/SaveTool/decrypt.c This decrypts a savegame file
- https://github.com/hrydgard/ppsspp/blob/81b5e080ff885e98b5761632158457ce3e5d1fb5/Core/HLE/sceKernelModule.cpp#L1251 The code before here decrypts the executable
- https://github.com/euan-forrester/psp-encryption-webassembly Copy of the relevant code in PPSSPP, compiled into webassembly
- For Gran Turismo the game key is device-specific. Must be generated somehow from a device ID. So can't find it in the executable.
- So we still need a box to enter the game key instead of just ISO/eboot
- Maybe detect this ISO and warn the user?
- NES/SNES Classic save game format
- https://github.com/TeamShinkansen/Hakchi2-CE (tool used to write games onto the devices)
- https://github.com/JanErikGunnar/ClassicEditionTools (scripts to convert raw saves to/from NES Classic format)
- http://darkakuma.z-net.us/p/sfromtool.html (convert SNES ROMs and/or saves to the SNES Classic ROM and/or save format)
- I'm not sure there's enough demand for a web-based tool that does this. The devices aren't available for sale anymore and you already have to run a Windows program to transfer the games. Also the Windows tool for the SNES Classic has the ability to convert saves already.
- PS1:
- https://www.psdevwiki.com/ps3/PS1_Savedata
- MemcardRex format: https://github.com/ShendoXT/memcardrex/blob/master/MemcardRex/ps1card.cs
- DexDrive format: https://github.com/ShendoXT/memcardrex/blob/master/MemcardRex/ps1card.cs
- Signing a PSP .VMP file: https://github.com/dots-tb/vita-mcr2vmp
- PS3 format: https://psdevwiki.com/ps3/PS1_Savedata#PS1_Single_Save_.3F_.28.PSV.29
- Command line tool for PS1 memcards: https://github.com/G4Vi/psx_mc_cli
- N64:
- https://github.com/bryc/mempak
- https://bryc.github.io/mempak/ (online preview)
- https://github.com/bryc/mempak/wiki/DexDrive-.N64-format
- https://github.com/bryc/mempak/wiki/MemPak-structure
- https://github.com/DragonMinded/libdragon/wiki/Cpaktool
- https://github.com/heuripedes/pj64tosrm (convert raw save to emulator format)
- http://micro-64.com/database/gamesave.shtml (list of games which use each save type)
- https://www.reddit.com/r/n64/comments/ezhleg/guide_how_to_backup_your_n64_saves_to_your_pc/
- https://www.youtube.com/watch?v=PpolokImIeU (convert individual note to .eep format)
- https://github.com/ssokolow/saveswap Swap endianness of N64 saves to move between Everdrive/emulators/etc
- https://github.com/Ninjiteu/N64SaveConverter Does endian swaps, padding/trimming of files, and parses retroarch files that store n64 cart saves at various offsets
- https://github.com/drehren/ra_mp64_srm_convert Combines/splits retroarch saves. Also has GUI version: https://github.com/drehren/ramp64-convert-gui and web version: https://github.com/drehren/ramp64-convert-web
- Game serial codes:
- https://niwanetwork.org/wiki/List_of_Nintendo_64_games_by_serial_code
- https://meanwhileblog.wordpress.com/n64-game-code-list/
- 3DS Virtual Console
- SNES games: https://github.com/manuGMG/3ds-snes-sc
- This tool above apparently works on some WiiU VC games as well: https://gbatemp.net/threads/release-3ds-snes-save-converter.574927/
- Pokemon saves: https://sav2vc.fm1337.com/
- https://github.com/mstan/SaveAIO
- List of emulators on the 3DS: https://wiki.gbatemp.net/wiki/List_of_3DS_homebrew_emulators
- open_agb_firm: https://github.com/profi200/open_agb_firm allows usage of the build-in GBA hardware
- Save files from here need to be 64-bit endian-swapped to work in a regular emulator: https://github.com/exelotl/gba-eeprom-save-fix
- Sega Saturn
- https://github.com/hitomi2500/ss-save-parser
- https://github.com/slinga-homebrew/Save-Game-BUP-Scripts (description of the `.BUP` save format)
- https://github.com/slinga-homebrew/Save-Game-Copier
- https://segaxtreme.net/resources/saturn-save-converter.74/
- https://github.com/slinga-homebrew/Save-Game-Extractor (cool, but hopefully not the main method of getting files off of an actual Saturn)
- http://ppcenter.free.fr/satdocs/ST-162-R1-092994.html (official documentation)
- https://segaxtreme.net/threads/backup-memory-structure.16803/
- Saroo:
- https://github.com/tpunix/SAROO/tree/master/tools/savetool (SAROO save file converter)
- https://github.com/tpunix/SAROO/issues/131 (description of how the saroo deals with save files, also watch this for potential changes)
- https://github.com/tpunix/SAROO/issues/117 (description of current process of copying save files to saroo)
- https://www.reddit.com/r/SegaSaturn/comments/1acty0v/saroo_save_file_format/ (information on the saroo file format, tools, and example files)
- Potential save formats (based on what Saturn Save Converter does):
- These ones to initially support:
- BUP (is this the standardized format? Used by Psuedo Saturn Kai -> this is the tool used by ODEs as well?)
- https://github.com/slinga-homebrew/Save-Game-BUP-Scripts/blob/main/bup_header.h#L94
- SaroSave (Saroo flash cart)
- https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_bup.c#L135
- https://github.com/tpunix/SAROO/blob/6f6e18289bbdc9b23b4c91b9da343a1362ed921c/doc/SAROO%E6%8A%80%E6%9C%AF%E7%82%B9%E6%BB%B4.txt#L448 (translation posted at https://www.reddit.com/r/SegaSaturn/comments/1acty0v/comment/kjz73ft/)
- Mednafen - generates 3 files:
- `.bkr`: Internal save memory? Appears to be described here: https://www.reddit.com/r/SegaSaturn/comments/1acty0v/comment/kjz73ft/
- `.bcr`: Cartridge save memory? Compressed with gzip, then as described in link above but with bigger block size: https://www.reddit.com/r/RetroArch/comments/wke28j/comment/ijmw25t/
- `.smpc`: System management data, including clock information: https://docs.exodusemulator.com/Archives/SSDDV25/segahtml/index.html?page=hard/smpc/index.htm
- These ones to maybe support based on feedback:
- SSF (SSF emulator: https://segaretro.org/SSF)
- Yabause (https://yabause.org/ -- seems old, Kronos is a newer fork: https://github.com/FCare/Kronos)
- Giri Giri
- Nova
- Action Replay (https://github.com/hitomi2500/ss-save-parser/blob/master/config.cpp#L26)
- Druid II (https://github.com/hitomi2500/ss-save-parser/blob/master/config.cpp#L59)
- TG-16/PCE:
- https://github.com/Widdiful/PCE_BRAM_Manager
- Gamecube
- Memory card image
- https://www.gc-forever.com/yagcd/chap12.html#sec12
- https://github.com/bodgit/gc
- https://github.com/bodgit/memcardpro
- https://github.com/suloku/gcmm
- .GCI individual save file
- http://www.surugi.com/projects/gcifaq.html
- https://github.com/suloku/gcmm/blob/master/source/gci.h#L12
- Recovery tool
- https://github.com/GerbilSoft/mcrecover
- Flash carts:
- Goomba emulator (for GB/C games on GBA flash cart)
- https://github.com/libertyernie/goombasav (save converter)
- https://github.com/masterhou/goombacolor/blob/master/src/sram.c
- https://www.reddit.com/r/everdrive/comments/qcgus7/using_a_gba_x5_goomba_sram_save_file_on_a_gbc_x7/****
- It seems that uncompressed save data may be available at `0x6000` rather than `0xe000`? Was there a bug in Goomba Save Converter?
- https://www.reddit.com/r/everdrive/comments/1368o8d/converting_goomba_gbc_saves_for_use_on_other/
- N64 Everdrive
- https://github.com/ssokolow/saveswap
- GB/C emulator: https://github.com/lambertjamesd/gb64/blob/master/src/save.c
- NES emulator:
- https://github.com/hcs64/neon64v2/issues/20
- http://themanbehindcurtain.blogspot.com/
- Sega CD:
- https://github.com/superctr/buram/
- Sega operating manuals
- Standards that games were to use when interfacing with backup RAM: https://segaretro.org/images/6/6e/Sega-CD_Software_Standards.pdf
- BIOS calls that games were to use to access backup RAM: https://segaretro.org/images/4/44/MCDBios.pdf
- https://segaretro.org/CD_BackUp_RAM_Cart
- https://github.com/ekeeke/Genesis-Plus-GX/issues/449
- savesplitter tool here: https://krikzz.com/pub/support/mega-everdrive/pro-series/
- Dreamcast
- https://vmu.falcogirgis.net/filesystem.html
- https://github.com/likeagfeld/VM2_TO_VMUPRO_CONVERTER
- https://github.com/DerekPascarella/VMU-Disc-Builder (just packs up save files and doesn't do much parsing, so not much info about their format)
- https://bswirl.kitsunet.org/dci_converter/ (old and unmaintained, only parts of it worK: https://www.dreamcast-talk.com/forum/viewtopic.php?t=17425)
- https://segaretro.org/VMU_Explorer (Old Windows-only tool without source code)
- https://github.com/sizious/vmu-tool-pc
- https://github.com/bucanero/dc-save-converter
- Official docs: https://segaxtreme.net/resources/maple-bus-1-0-function-type-specifications-ft1-storage-function.195/ (these were apparently leaked, apparently have been translated back and forth, and do not appear to be final as they contain multiple descrepancies vs the actual files)
- Analogue Pocket:
- List of available cores: https://openfpga-cores-inventory.github.io/analogue-pocket/
- Note that there are sometimes multiple cores for a particular platform (e.g. Genesis)
- Visual Boy Advance emulator
- Explanation of its format: https://emulation.gametechwiki.com/index.php/Game_Boy_Advance_emulators#Save_formats
- Code that does the conversion:
- https://github.com/Thysbelon/gbaconv-web
- https://github.com/libretro/vbam-libretro/blob/25fefc1b3dcdc6362c44845687bea70dd350c33a/src/libretro/gbaconv/gbaconv.c
- Online converter: https://thysbelon.github.io/gbaconv-web/
- Info on some ports of this emulator: https://thysbelon.github.io/2023/03/07/How-to-Convert-4GS-and-DSZ-save-files-to-SAV
- Online emulators
- `.ggz` files when unzipped apparently contain one or more pairs of `.gba`/`.snes`/etc and `.png` file: a save state and a thumbnail
- There's a port of Retroarch available online here: https://binbashbanana.github.io/webretro/ with code here: https://github.com/BinBashBanana/webretro . Its save file compatibility is the same as regular Retroarch and the emulators therein
- Retroarch
- Some save files are compressed with ZLIB compression. I'm not sure the circumstances under which this happens: https://github.com/libretro/RetroArch/issues/14031#issuecomment-1159400581
- iOS
- The Delta emulator uses .svs for save states. Instructions for creating a raw save instead: https://www.reddit.com/r/Delta_Emulator/comments/1helbo1/svs_to_sav/
- Gameboy
- Games using the MBC2 mapper can have their save files represented differently
- Either as half-bytes with the top nybble set to `0x0000` or as packed bytes
- Half bytes are more common; mGBA uses packed bytes but can convert
- See discussion here: https://github.com/Gronis/gb-save-manager/issues/5#issuecomment-4101338096
- See more details here: https://github.com/Gronis/gb-save-manager?tab=readme-ov-file#games-to-watch-out-for---mbc2-cartridges
- Game affected:
- F-1 Race
- Golf
- Kirby's Pinball Land
- Top Rank Tennis
## Cart reader notes
- Retrode2
- Genesis: SRAM/FRAM saves are byte expanded by doubling: "HELLO" becomes "HHEELLLLOO" rather than " H E L L O" like in many emulators/flash carts
- Retroblaster
- Same as Retrode2
## GBA save file size difficulty
- https://zork.net/~st/jottings/GBA_saves.html
- https://dillonbeliveau.com/2020/06/05/GBA-FLASH.html
## Real-Time Clock save format
- https://bgb.bircd.org/rtcsave.html
Some platforms (e.g. some MiSTer cores) append RTC data to the end of a save file. The above link describes a common format for RTC data.
## PSP decompiling
- Use ghidra:
- Download from: https://github.com/NationalSecurityAgency/ghidra/releases
- Add this extension: https://github.com/kotcrab/ghidra-allegrex
- Add these scripts: https://github.com/pspdev/psp-ghidra-scripts
- Libraries documentation: https://github.com/mathieulh/PSP-PRX-Libraries-Documentation-Project
## Offline use
Occassionally there's a need to use the tool offline, such as when you'll be without an Internet connection for an extended period. There's 2 methods to achieve this:
### Method 1: Use a website saving tool
You can't just right click on the page and select Save As... because the site is divided internally into many different files, and that will only download some of them.
Google `website saving tool` or something similar to find an up-to-date list of such tools.
### Method 2: Build it locally (for people comfortable with the command line and development tools)
You may need to modify some of these steps depending on your development environment, but this should give you the general idea.
#### MacOS/Linux
Install `homebrew`: https://brew.sh/
```
brew install yarn
brew install git
```
Then proceed to the [Common](https://github.com/euan-forrester/save-file-converter#common) section
#### Windows
Find an equivalent package manager to `homebrew`, and use it to install `git` and `yarn` (or install them and their dependencies manually: `git`: https://github.com/git-guides/install-git, `yarn`: https://yarnpkg.com/getting-started/install)
Then proceed to the [Common](https://github.com/euan-forrester/save-file-converter#common) section
#### Common
```
git clone git@github.com:euan-forrester/save-file-converter.git
cd save-file-converter/frontend
yarn install
yarn serve
```
Then open http://localhost:8080/ in your browser.
Note that you'll have to keep the command line window open with `yarn serve` running for as long as you want to access the site.
## Internet archive
If you need to, you can also access the site via the Internet archive here: https://web.archive.org/web/https://savefileconverter.com/
================================================
FILE: frontend/.editorconfig
================================================
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100
================================================
FILE: frontend/.eslintignore
================================================
src/save-formats/PSP/psp-encryption/*
lib/*
================================================
FILE: frontend/.gitignore
================================================
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
================================================
FILE: frontend/.nvmrc
================================================
16.19.1
================================================
FILE: frontend/README.md
================================================
# Vue 3 upgrade notes
Need to undo these changes made for vscode: https://github.com/vuejs/language-tools/wiki/Vue-2-Compat-Guides
Described here: https://github.com/vuejs/language-tools/discussions/5455
================================================
FILE: frontend/babel.config.js
================================================
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
],
};
================================================
FILE: frontend/buildspec.yml
================================================
version: 0.2
phases:
install:
runtime-versions:
nodejs: 16
commands:
- apt-get update -y
- apt-get install -y yarn
- cd frontend
- yarn install
pre_build:
commands:
- yarn test:unit
build:
commands:
- yarn build --mode=$ENVIRONMENT
post_build:
commands:
- yarn deploy --mode=$ENVIRONMENT && yarn deploy:cleanup --mode=$ENVIRONMENT
================================================
FILE: frontend/lib/minlzo-js/lzo1x.js
================================================
// This file is copied from https://github.com/euan-forrester/minilzo-js
// Which was forked from https://github.com/abraidwood/minilzo-js
/*
* minilzo-js
* JavaScript port of minilzo by Alistair Braidwood
*
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License, or (at your option) any later version.
*
* You should have received a copy of the GNU General Public License
* along with the minilzo-js library; see the file COPYING.
* If not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
/*
* original minilzo.c by:
*
* Markus F.X.J. Oberhumer
*
* http://www.oberhumer.com/opensource/lzo/
*/
/*
* NOTE:
* the full LZO package can be found at
* http://www.oberhumer.com/opensource/lzo/
*/
function _lzo1x() {
this.blockSize = 128 * 1024;
this.minNewSize = this.blockSize;
this.maxSize = 0;
this.OK = 0;
this.INPUT_OVERRUN = -4;
this.OUTPUT_OVERRUN = -5;
this.LOOKBEHIND_OVERRUN = -6;
this.EOF_FOUND = -999;
this.ret = 0;
this.buf = null;
this.buf32 = null;
this.out = new Uint8Array(256 * 1024);
this.cbl = 0;
this.ip_end = 0;
this.op_end = 0;
this.t = 0;
this.ip = 0;
this.op = 0;
this.m_pos = 0;
this.m_len = 0;
this.m_off = 0;
this.dv_hi = 0;
this.dv_lo = 0;
this.dindex = 0;
this.ii = 0;
this.jj = 0;
this.tt = 0;
this.v = 0;
this.dict = new Uint32Array(16384);
this.emptyDict = new Uint32Array(16384);
this.skipToFirstLiteralFun = false;
this.returnNewBuffers = true;
this.setBlockSize = function(blockSize) {
if(typeof blockSize === 'number' && !isNaN(blockSize) && parseInt(blockSize) > 0) {
this.blockSize = parseInt(blockSize);
return true;
} else {
return false;
}
};
this.setOutputSize = function(outputSize) {
if(typeof outputSize === 'number' && !isNaN(outputSize) && parseInt(outputSize) > 0) {
this.out = new Uint8Array(parseInt(outputSize));
return true;
} else {
return false;
}
};
this.setReturnNewBuffers = function(b) {
this.returnNewBuffers = !!b;
};
this.applyConfig = function(cfg) {
if(cfg !== undefined) {
if(cfg.outputSize !== undefined) {
instance.setOutputSize(cfg.outputSize);
}
if(cfg.blockSize !== undefined) {
instance.setBlockSize(cfg.blockSize);
}
}
};
this.ctzl = function(v) {
// this might be needed for _compressCore (it isn't in my current test files)
/*
* https://graphics.stanford.edu/~seander/bithacks.html#ZerosOnRightBinSearch
* Matt Whitlock suggested this on January 25, 2006. Andrew Shapira shaved a couple operations off on Sept. 5, 2007 (by setting c=1 and unconditionally subtracting at the end).
*/
var c; // c will be the number of zero bits on the right,
// so if v is 1101000 (base 2), then c will be 3
// NOTE: if 0 == v, then c = 31.
if (v & 0x1) {
// special case for odd v (assumed to happen half of the time)
c = 0;
} else {
c = 1;
if ((v & 0xffff) === 0) {
v >>= 16;
c += 16;
}
if ((v & 0xff) === 0) {
v >>= 8;
c += 8;
}
if ((v & 0xf) === 0) {
v >>= 4;
c += 4;
}
if ((v & 0x3) === 0) {
v >>= 2;
c += 2;
}
c -= v & 0x1;
}
return c;
};
// It might be faster to copy 4 bytes at a time, but
// the allocation seems to kill performance.
// this._get4ByteAlignedBuf = function(buf) {
// if(buf.length % 4 === 0) {
// return new Uint32Array(buf.buffer);
// } else {
// var buf_4b = new Uint8Array(buf.length + (4 - buf.length % 4));
// buf_4b.set(buf);
// return new Uint32Array(buf_4b.buffer);
// }
// };
this.extendBuffer = function() {
var newBuffer = new Uint8Array(this.minNewSize + (this.blockSize - this.minNewSize % this.blockSize));
newBuffer.set(this.out);
this.out = newBuffer;
this.cbl = this.out.length;
};
this.match_next = function() {
// if (op_end - op < t) return OUTPUT_OVERRUN;
// if (this.ip_end - ip < t+3) return INPUT_OVERRUN;
this.minNewSize = this.op + 3;
if(this.minNewSize > this.cbl) {this.extendBuffer();}
this.out[this.op++] = this.buf[this.ip++];
if(this.t > 1) {
this.out[this.op++] = this.buf[this.ip++];
if(this.t > 2) {
this.out[this.op++] = this.buf[this.ip++];
}
}
this.t = this.buf[this.ip++];
};
this.match_done = function() {
this.t = this.buf[this.ip-2] & 3;
return this.t;
};
this.copy_match = function() {
this.t += 2;
this.minNewSize = this.op + this.t;
if(this.minNewSize > this.cbl) {this.extendBuffer();}
do {
this.out[this.op++] = this.out[this.m_pos++];
} while(--this.t > 0);
};
this.copy_from_buf = function() {
this.minNewSize = this.op + this.t;
if(this.minNewSize > this.cbl) {this.extendBuffer();}
do {
this.out[this.op++] = this.buf[this.ip++];
} while (--this.t > 0);
};
this.match = function() {
for (;;) {
if (this.t >= 64) {
this.m_pos = (this.op - 1) - ((this.t >> 2) & 7) - (this.buf[this.ip++] << 3);
this.t = (this.t >> 5) - 1;
// if ( m_pos < out || m_pos >= op) return LOOKBEHIND_OVERRUN;
// if (op_end - op < t+3-1) return OUTPUT_OVERRUN;
this.copy_match();
} else if (this.t >= 32) {
this.t &= 31;
if (this.t === 0) {
while (this.buf[this.ip] === 0) {
this.t += 255;
this.ip++;
// if (t > -511) return OUTPUT_OVERRUN;
// if (this.ip_end - ip < 1) return INPUT_OVERRUN;
}
this.t += 31 + this.buf[this.ip++];
// if (this.ip_end - ip < 2) return INPUT_OVERRUN;
}
this.m_pos = (this.op - 1) - (this.buf[this.ip] >> 2) - (this.buf[this.ip + 1] << 6);
this.ip += 2;
this.copy_match();
} else if (this.t >= 16) {
this.m_pos = this.op - ((this.t & 8) << 11);
this.t &= 7;
if (this.t === 0) {
while (this.buf[this.ip] === 0) {
this.t += 255;
this.ip++;
// if (t > -511) return OUTPUT_OVERRUN;
// if (this.ip_end - ip < 1) return INPUT_OVERRUN;
}
this.t += 7 + this.buf[this.ip++];
// if (this.ip_end - ip < 2) return INPUT_OVERRUN;
}
this.m_pos -= (this.buf[this.ip] >> 2) + (this.buf[this.ip + 1] << 6);
this.ip += 2;
if (this.m_pos === this.op) {
this.state.outputBuffer = this.returnNewBuffers === true ?
new Uint8Array(this.out.subarray(0, this.op)) :
this.out.subarray(0, this.op);
return this.EOF_FOUND;
} else {
this.m_pos -= 0x4000;
this.copy_match();
}
} else {
this.m_pos = (this.op - 1) - (this.t >> 2) - (this.buf[this.ip++] << 2);
// if (m_pos < out || m_pos >= op) return LOOKBEHIND_OVERRUN;
// if (op_end - op < 2) return OUTPUT_OVERRUN;
this.minNewSize = this.op + 2;
if(this.minNewSize > this.cbl) {this.extendBuffer();}
this.out[this.op++] = this.out[this.m_pos++];
this.out[this.op++] = this.out[this.m_pos];
}
// if (m_pos < out || m_pos >= op) return LOOKBEHIND_OVERRUN;
// if (op_end - op < t+3-1) return OUTPUT_OVERRUN;
if(this.match_done() === 0) {
return this.OK;
}
this.match_next();
}
};
this.decompress = function(state) {
this.state = state;
this.buf = this.state.inputBuffer;
this.cbl = this.out.length;
this.ip_end = this.buf.length;
// this.op_end = this.out.length;
this.t = 0;
this.ip = 0;
this.op = 0;
this.m_pos = 0;
this.skipToFirstLiteralFun = false;
// if (this.ip_end - ip < 1) return INPUT_OVERRUN;
if (this.buf[this.ip] > 17) {
this.t = this.buf[this.ip++] - 17;
if (this.t < 4) {
this.match_next();
this.ret = this.match();
if(this.ret !== this.OK) {
return this.ret === this.EOF_FOUND ? this.OK : this.ret;
}
} else {
// if (op_end - op < t) return OUTPUT_OVERRUN;
// if (this.ip_end - ip < t+3) return INPUT_OVERRUN;
this.copy_from_buf();
this.skipToFirstLiteralFun = true;
}
}
for (;;) {
if(!this.skipToFirstLiteralFun) {
// EDIT EUAN: I re-enabled the line below because otherwise the function enters an infinite loop when presented with
// unexpected data. this.ip gets incremented forever, and this.t gets set to undefined as it tries to read past the end of the array
if (this.ip_end - this.ip < 3) return this.INPUT_OVERRUN;
this.t = this.buf[this.ip++];
if (this.t >= 16) {
this.ret = this.match();
if(this.ret !== this.OK) {
return this.ret === this.EOF_FOUND ? this.OK : this.ret;
}
continue;
} else if (this.t === 0) {
while (this.buf[this.ip] === 0) {
this.t += 255;
this.ip++;
// if (t > 511) return INPUT_OVERRUN;
// if (this.ip_end - ip < 1) return INPUT_OVERRUN;
}
this.t += 15 + this.buf[this.ip++];
}
// if (op_end - op < t+3) return OUTPUT_OVERRUN;
// if (this.ip_end - ip < t+6) return INPUT_OVERRUN;
this.t += 3;
this.copy_from_buf();
} else {
this.skipToFirstLiteralFun = false;
}
this.t = this.buf[this.ip++];
if (this.t < 16) {
this.m_pos = this.op - (1 + 0x0800);
this.m_pos -= this.t >> 2;
this.m_pos -= this.buf[this.ip++] << 2;
// if ( m_pos < out || m_pos >= op) return LOOKBEHIND_OVERRUN;
// if (op_end - op < 3) return OUTPUT_OVERRUN;
this.minNewSize = this.op + 3;
if(this.minNewSize > this.cbl) {this.extendBuffer();}
this.out[this.op++] = this.out[this.m_pos++];
this.out[this.op++] = this.out[this.m_pos++];
this.out[this.op++] = this.out[this.m_pos];
if(this.match_done() === 0) {
continue;
} else {
this.match_next();
}
}
this.ret = this.match();
if(this.ret !== this.OK) {
return this.ret === this.EOF_FOUND ? this.OK : this.ret;
}
}
return this.OK;
};
this._compressCore = function() {
this.ip_start = this.ip;
this.ip_end = this.ip + this.ll - 20;
this.jj = this.ip;
this.ti = this.t;
this.ip += this.ti < 4 ? 4 - this.ti : 0;
this.ip += 1 + ((this.ip - this.jj) >> 5);
for (;;) {
if(this.ip >= this.ip_end) {
break;
}
// dv = this.buf[this.ip] | (this.buf[this.ip + 1] << 8) | (this.buf[this.ip + 2] << 16) | (this.buf[this.ip + 3] << 24);
// this.dindex = ((0x1824429d * dv) >> 18) & 16383;
// The above code doesn't work in JavaScript due to a lack of 64 bit bitwise operations
// Instead, use (optimised two's complement integer arithmetic)
// Optimization is based on us only needing the high 16 bits of the lower 32 bit integer.
this.dv_lo = this.buf[this.ip] | (this.buf[this.ip + 1] << 8);
this.dv_hi = this.buf[this.ip + 2] | (this.buf[this.ip + 3] << 8);
this.dindex = (((this.dv_lo * 0x429d) >>> 16) + (this.dv_hi * 0x429d) + (this.dv_lo * 0x1824) & 0xFFFF) >>> 2;
this.m_pos = this.ip_start + this.dict[this.dindex];
this.dict[this.dindex] = this.ip - this.ip_start;
if ((this.dv_hi<<16) + this.dv_lo != (this.buf[this.m_pos] | (this.buf[this.m_pos + 1] << 8) | (this.buf[this.m_pos + 2] << 16) | (this.buf[this.m_pos + 3] << 24))) {
this.ip += 1 + ((this.ip - this.jj) >> 5);
continue;
}
this.jj -= this.ti;
this.ti = 0;
this.v = this.ip - this.jj;
if (this.v !== 0) {
if (this.v <= 3) {
this.out[this.op - 2] |= this.v;
do {
this.out[this.op++] = this.buf[this.jj++];
} while (--this.v > 0);
} else {
if (this.v <= 18) {
this.out[this.op++] = this.v - 3;
} else {
this.tt = this.v - 18;
this.out[this.op++] = 0;
while (this.tt > 255) {
this.tt -= 255;
this.out[this.op++] = 0;
}
this.out[this.op++] = this.tt;
}
do {
this.out[this.op++] = this.buf[this.jj++];
} while (--this.v > 0);
}
}
this.m_len = 4;
// var skipTo_this.m_len_done = false;
if (this.buf[this.ip + this.m_len] === this.buf[this.m_pos + this.m_len]) {
do {
this.m_len += 1; if(this.buf[this.ip + this.m_len] !== this.buf[this.m_pos + this.m_len]) {break;}
this.m_len += 1; if(this.buf[this.ip + this.m_len] !== this.buf[this.m_pos + this.m_len]) {break;}
this.m_len += 1; if(this.buf[this.ip + this.m_len] !== this.buf[this.m_pos + this.m_len]) {break;}
this.m_len += 1; if(this.buf[this.ip + this.m_len] !== this.buf[this.m_pos + this.m_len]) {break;}
this.m_len += 1; if(this.buf[this.ip + this.m_len] !== this.buf[this.m_pos + this.m_len]) {break;}
this.m_len += 1; if(this.buf[this.ip + this.m_len] !== this.buf[this.m_pos + this.m_len]) {break;}
this.m_len += 1; if(this.buf[this.ip + this.m_len] !== this.buf[this.m_pos + this.m_len]) {break;}
this.m_len += 1; if(this.buf[this.ip + this.m_len] !== this.buf[this.m_pos + this.m_len]) {break;}
if(this.ip + this.m_len >= this.ip_end) {
// skipTo_this.m_len_done = true;
break;
}
} while (this.buf[this.ip + this.m_len] === this.buf[this.m_pos + this.m_len]);
}
// if (!skipTo_this.m_len_done) {
// var inc = this.ctzl(this.buf[this.ip + this.m_len] ^ this.buf[this.m_pos + this.m_len]) >> 3;
// this.m_len += inc;
// }
this.m_off = this.ip - this.m_pos;
this.ip += this.m_len;
this.jj = this.ip;
if (this.m_len <= 8 && this.m_off <= 0x0800) {
this.m_off -= 1;
this.out[this.op++] = ((this.m_len - 1) << 5) | ((this.m_off & 7) << 2);
this.out[this.op++] = this.m_off >> 3;
} else if (this.m_off <= 0x4000) {
this.m_off -= 1;
if (this.m_len <= 33) {
this.out[this.op++] = 32 | (this.m_len - 2);
} else {
this.m_len -= 33;
this.out[this.op++] = 32;
while (this.m_len > 255) {
this.m_len -= 255;
this.out[this.op++] = 0;
}
this.out[this.op++] = this.m_len;
}
this.out[this.op++] = this.m_off << 2;
this.out[this.op++] = this.m_off >> 6;
} else {
this.m_off -= 0x4000;
if (this.m_len <= 9) {
this.out[this.op++] = 16 | ((this.m_off >> 11) & 8) | (this.m_len - 2);
} else {
this.m_len -= 9;
this.out[this.op++] = 16 | ((this.m_off >> 11) & 8);
while (this.m_len > 255) {
this.m_len -= 255;
this.out[this.op++] = 0;
}
this.out[this.op++] = this.m_len;
}
this.out[this.op++] = this.m_off << 2;
this.out[this.op++] = this.m_off >> 6;
}
}
this.t = this.ll - ((this.jj - this.ip_start) - this.ti);
};
this.compress = function (state) {
this.state = state;
this.ip = 0;
this.buf = this.state.inputBuffer;
this.maxSize = this.buf.length + Math.ceil(this.buf.length / 16) + 64 + 3;
if(this.maxSize > this.out.length) {
this.out = new Uint8Array(this.maxSize);
}
// this.state.outputBuffer = new Uint8Array(this.buf.length + Math.ceil(this.buf.length / 16) + 64 + 3);
// this.out = this.state.outputBuffer;
this.op = 0;
this.l = this.buf.length;
this.t = 0;
while (this.l > 20) {
this.ll = (this.l <= 49152) ? this.l : 49152;
if ((this.t + this.ll) >> 5 <= 0) {
break;
}
this.dict.set(this.emptyDict);
this.prev_ip = this.ip;
this._compressCore();
this.ip = this.prev_ip + this.ll;
this.l -= this.ll;
}
this.t += this.l;
if (this.t > 0) {
this.ii = this.buf.length - this.t;
if (this.op === 0 && this.t <= 238) {
this.out[this.op++] = 17 + this.t;
} else if (this.t <= 3) {
this.out[this.op-2] |= this.t;
} else if (this.t <= 18) {
this.out[this.op++] = this.t - 3;
} else {
this.tt = this.t - 18;
this.out[this.op++] = 0;
while (this.tt > 255) {
this.tt -= 255;
this.out[this.op++] = 0;
}
this.out[this.op++] = this.tt;
}
do {
this.out[this.op++] = this.buf[this.ii++];
} while (--this.t > 0);
}
this.out[this.op++] = 17;
this.out[this.op++] = 0;
this.out[this.op++] = 0;
this.state.outputBuffer = this.returnNewBuffers === true ?
new Uint8Array(this.out.subarray(0, this.op)) :
this.out.subarray(0, this.op);
return this.OK;
}
};
var instance = new _lzo1x();
export default {
OK: instance.OK,
INPUT_OVERRUN: instance.INPUT_OVERRUN,
OUTPUT_OVERRUN: instance.OUTPUT_OVERRUN,
LOOKBEHIND_OVERRUN: instance.LOOKBEHIND_OVERRUN,
EOF_FOUND: instance.EOF_FOUND,
setBlockSize: function(blockSize) {
return instance.setBlockSize(blockSize);
},
setOutputEstimate: function(outputSize) {
return instance.setOutputSize(outputSize);
},
setReturnNewBuffers: function(b) {
instance.setReturnNewBuffers(b);
},
compress: function(state, cfg) {
if(cfg !== undefined) {
instance.applyConfig(cfg);
}
return instance.compress(state);
},
decompress: function(state, cfg) {
if(cfg !== undefined) {
instance.applyConfig(cfg);
}
return instance.decompress(state);
}
};
================================================
FILE: frontend/package.json
================================================
{
"name": "save-file-converter",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint",
"deploy": "vue-cli-service s3-deploy",
"deploy:cleanup": "vue-cli-service s3-deploy-cleanup"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-solid-svg-icons": "^6.5.0",
"@fortawesome/vue-fontawesome": "^2.0.2",
"@gtm-support/vue2-gtm": "^1.2.0",
"axios": "^0.21.2",
"axios-retry": "^3.1.9",
"bootstrap": "^4.5.3",
"bootstrap-vue": "^2.22.0",
"browserfs": "^1.4.3",
"browserify-aes": "^1.2.0",
"browserify-des": "^1.0.2",
"core-js": "^3.6.5",
"crc-32": "^1.2.0",
"create-hash": "^1.2.0",
"dayjs": "^1.10.4",
"encoding-japanese": "^2.2.0",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"node-html-parser": "^4.0.0",
"pako": "^2.0.3",
"path": "^0.12.7",
"process": "^0.11.10",
"rzipjs": "^1.0.0",
"stream-browserify": "^3.0.0",
"vue": "^2.6.12",
"vue-async-computed": "^4.0.1",
"vue-mq": "^1.0.1",
"vue-router": "^3.2.0",
"vuex": "^3.4.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.4",
"@vue/cli-plugin-eslint": "~5.0.4",
"@vue/cli-plugin-router": "~5.0.4",
"@vue/cli-plugin-unit-mocha": "~5.0.4",
"@vue/cli-plugin-vuex": "~5.0.4",
"@vue/cli-service": "~5.0.4",
"@vue/eslint-config-airbnb": "^6.0.0",
"@vue/language-server": "~3.0.0",
"@vue/test-utils": "^1.0.3",
"chai": "^4.1.2",
"eslint": "^7.32.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-vue": "^8.0.3",
"eslint-plugin-vuejs-accessibility": "^1.1.0",
"file-loader": "^6.2.0",
"pkcs7": "^1.0.4",
"seedrandom": "^3.0.5",
"vue-cli-plugin-s3-deploy": "~4.0.0-rc3",
"vue-cli-plugin-s3-deploy-cleanup": "^1.1.0",
"vue-cli-plugin-webpack-bundle-analyzer": "~4.0.0",
"vue-template-compiler": "^2.6.12",
"webpack-bundle-analyzer": "^4.5.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"@vue/airbnb"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {
"max-len": [
"error",
{
"code": 200,
"tabWidth": 2,
"ignoreComments": true,
"ignoreTrailingComments": true,
"ignoreUrls": true
}
]
},
"overrides": [
{
"files": [
"**/__tests__/*.{j,t}s?(x)",
"**/tests/unit/**/*.spec.{j,t}s?(x)"
],
"env": {
"mocha": true
}
}
]
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
================================================
FILE: frontend/public/browserconfig.xml
================================================
#da532c
================================================
FILE: frontend/public/index.html
================================================
Save File Converter
================================================
FILE: frontend/public/robots.txt
================================================
Sitemap: http://savefileconverter.com/sitemap.txt
================================================
FILE: frontend/public/site.webmanifest
================================================
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
================================================
FILE: frontend/public/sitemap.txt
================================================
https://savefileconverter.com/#/retron-5
https://savefileconverter.com/#/retron-5/erase-save
https://savefileconverter.com/#/wii
https://savefileconverter.com/#/nintendo-switch-online
https://savefileconverter.com/#/gba/gameshark
https://savefileconverter.com/#/gba/gameshark-sp
https://savefileconverter.com/#/gba/action-replay
https://savefileconverter.com/#/ps1/dexdrive
https://savefileconverter.com/#/ps1/psp
https://savefileconverter.com/#/ps1/ps3
https://savefileconverter.com/#/ps1/emulator
https://savefileconverter.com/#/psp
https://savefileconverter.com/#/sega-cd
https://savefileconverter.com/#/sega-saturn/emulator
https://savefileconverter.com/#/sega-saturn/saroo
https://savefileconverter.com/#/dreamcast
https://savefileconverter.com/#/gamecube
https://savefileconverter.com/#/n64/dexdrive
https://savefileconverter.com/#/n64/controller-pak
https://savefileconverter.com/#/mister
https://savefileconverter.com/#/flash-carts
https://savefileconverter.com/#/online-emulators
https://savefileconverter.com/#/srm-sav
https://savefileconverter.com/#/utilities/erase-save
https://savefileconverter.com/#/utilities/advanced
https://savefileconverter.com/#/other-converters
https://savefileconverter.com/#/download-saves
https://savefileconverter.com/#/original-hardware
https://savefileconverter.com/#/about
================================================
FILE: frontend/src/App.vue
================================================
Before copying a save file to or from a flash cart, please launch a different game first.
Please see your flash cart documentation for more details, or check #faqs on the Discord.
Not enough is known about GameShark SP saves to be able to write out a file in this format. We can currently only extract the raw save from these files.
Emulator/Raw
Convert!
Help: how can I copy save files to and from a GBA cartridge?
Help: how do I copy save files to and from my GameShark SP?
Save state
It is only possible to extract an in-game save from the save state files of some online emulators.
It isn't possible to create a new save state, nor possible to convert a save state to another emulator.
Emulator/Raw
Convert!
Please note that you must perform an in-game save in the emulator before creating/downloading a save state. Only the in-game save can be converted.
We can currently only extract the raw save from Wii Virtual Console files.
If you need to convert to a Wii Virtual Console file, please google the tool named FE100.
Emulator/Raw
Convert!
Help: how can I copy save files to and from my
NES /
SNES /
SMS /
Genesis cartridge,
N64 cartridge or Controller Pak, or
TG-16 console?
Help: how do I copy files from my Nintendo Wii, or to my Nintendo Wii?
================================================
FILE: frontend/src/components/FileList.vue
================================================
No saves found in file
Tip: if this tool doesn't work for you, try opening both files in a hex editor and look for similarities or differences that may help you fix the file.
Did this page help you? Please tell me if it did or if it didn't: savefileconverter{{'\xa0'}}(at){{'\xa0'}}gmail{{'\xa0'}}(dot){{'\xa0'}}com
================================================
FILE: frontend/src/main.js
================================================
import Vue from 'vue';
import './plugins/bootstrap-vue';
import './plugins/fontawesome-vue';
import './plugins/mediaquery-vue';
import './plugins/google-tag-manager-vue';
import './plugins/vue-async-computed';
import App from './App.vue';
import router from './router';
import store from './store';
Vue.config.productionTip = false;
new Vue({
router,
store,
render: (h) => h(App),
}).$mount('#app');
================================================
FILE: frontend/src/plugins/bootstrap-vue.js
================================================
import Vue from 'vue';
// Reduce bundle size by pulling in only what we need from bootstrap.
// We could further optimize this by pulling in only the actual specific elements and directives
// that we use, but it would be more work for not a ton more gain.
// See here for more details: https://bootstrap-vue.js.org/docs/#tree-shaking-with-module-bundlers
//
// Note that this makes devving a bit annoying since anytime you want to try out a new
// type you have to add it here. So consider temporarily just using
//
// import { BootstrapVue } from 'bootstrap-vue';
// Vue.use(BootstrapVue);
//
// in those situations.
// Place all imports from 'bootstrap-vue' in a single import
// statement for optimal bundle sizes
import {
LayoutPlugin,
AlertPlugin,
FormFilePlugin,
JumbotronPlugin,
ButtonPlugin,
FormRadioPlugin,
FormGroupPlugin,
FormInputPlugin,
LinkPlugin,
FormSelectPlugin,
ListGroupPlugin,
CollapsePlugin,
SpinnerPlugin,
ImagePlugin,
TabsPlugin,
} from 'bootstrap-vue';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-vue/dist/bootstrap-vue.min.css';
Vue.use(LayoutPlugin);
Vue.use(AlertPlugin);
Vue.use(FormFilePlugin);
Vue.use(JumbotronPlugin);
Vue.use(ButtonPlugin);
Vue.use(FormRadioPlugin);
Vue.use(FormGroupPlugin);
Vue.use(FormInputPlugin);
Vue.use(LinkPlugin);
Vue.use(FormSelectPlugin);
Vue.use(ListGroupPlugin);
Vue.use(CollapsePlugin);
Vue.use(SpinnerPlugin);
Vue.use(ImagePlugin);
Vue.use(TabsPlugin);
================================================
FILE: frontend/src/plugins/fontawesome-vue.js
================================================
import Vue from 'vue';
import { library, dom } from '@fortawesome/fontawesome-svg-core';
import {
faArrowCircleLeft,
faArrowCircleRight,
faArrowCircleUp,
faArrowCircleDown,
faArrowRightArrowLeft,
faQuestionCircle,
faCheck,
faTimes,
faExclamationTriangle,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
library.add(faArrowCircleLeft);
library.add(faArrowCircleRight);
library.add(faArrowCircleUp);
library.add(faArrowCircleDown);
library.add(faArrowRightArrowLeft);
library.add(faQuestionCircle);
library.add(faCheck);
library.add(faTimes);
library.add(faExclamationTriangle);
Vue.component('font-awesome-icon', FontAwesomeIcon);
// This will kick off the initial replacement of i to svg tags and configure a MutationObserver
dom.watch();
================================================
FILE: frontend/src/plugins/google-tag-manager-vue.js
================================================
import Vue from 'vue';
import VueGtm from '@gtm-support/vue2-gtm';
import router from '../router/index';
Vue.use(VueGtm, {
id: 'GTM-MFQ82X4',
defer: false, // Script can be set to `defer` to speed up page load at the cost of less accurate results (in case visitor leaves before script is loaded, which is unlikely but possible). Defaults to false, so the script is loaded `async` by default
compatibility: false, // Will add `async` and `defer` to the script tag to not block requests for old browsers that do not support `async`
enabled: process.env.VUE_APP_ENABLE_GOOGLE_TAG_MANAGER === 'true',
debug: process.env.VUE_APP_DEBUG_GOOGLE_TAG_MANAGER === 'true', // Whether or not display console logs debugs (optional)
loadScript: true,
vueRouter: router,
});
================================================
FILE: frontend/src/plugins/mediaquery-vue.js
================================================
import Vue from 'vue';
import VueMq from 'vue-mq';
Vue.use(VueMq, {
breakpoints: { // Values copied from https://bootstrap-vue.js.org/docs/components/layout/ to be consistent with bootstrap's layout plugin
xs: 576,
sm: 768,
md: 992,
lg: 1200,
xl: Infinity,
},
});
================================================
FILE: frontend/src/plugins/vue-async-computed.js
================================================
import Vue from 'vue';
import AsyncComputed from 'vue-async-computed';
Vue.use(AsyncComputed);
================================================
FILE: frontend/src/rom-formats/PspIso.js
================================================
// Extracts executables from a PSP .ISO image
// This is based on https://github.com/hrydgard/ppsspp/blob/master/Core/PSPLoaders.cpp
import * as BrowserFS from 'browserfs';
import Util from '../util/util';
import PspParmSfo from '../save-formats/PSP/ParamSfo';
const EXECUTABLE_MAGIC_ENCODING = 'US-ASCII';
const EXECUTABLE_MAGIC = ['~PSP', '\x7FELF'];
const EXECUTABLE_MAGIC_OFFSET = 0;
const MAIN_EXECUTABLE_PATH = '/PSP_GAME/SYSDIR/EBOOT.BIN';
const UNENCRYPTED_EXECUTABLE_PATH = '/PSP_GAME/SYSDIR/BOOT.BIN'; // After firmware 3 was released, games no longer came with unencrypted versions on the UMD. The file is still present, but it's all dummy data for those later games. Apparently the size of the dummy data is the correct size of the decrypted executable though
const PARAM_SFO_PATH = '/PSP_GAME/PARAM.SFO';
// Taken from https://github.com/hrydgard/ppsspp/blob/e094f5673a4f171927afe6eb41eba0326c4511c7/Core/PSPLoaders.cpp#L221
//
// Apparently some translators like to rename the original EBOOT.BIN file to one of the filenames below,
// and then make a new EBOOT.BIN that first launches a plugin and then the actual game.
// We want to look in the actual executable to try and find our gamekey
const ALTERNATIVE_EXECUTABLE_PATHS = [
'/PSP_GAME/SYSDIR/EBOOT.OLD',
'/PSP_GAME/SYSDIR/EBOOT.DAT',
'/PSP_GAME/SYSDIR/EBOOT.BI',
'/PSP_GAME/SYSDIR/EBOOT.LLD',
// '/PSP_GAME/SYSDIR/OLD_EBOOT.BIN', //Utawareru Mono Chinese version
'/PSP_GAME/SYSDIR/EBOOT.123',
// '/PSP_GAME/SYSDIR/EBOOT_LRC_CH.BIN', // Hatsune Miku Project Diva Extend chinese version
'/PSP_GAME/SYSDIR/BOOT0.OLD',
'/PSP_GAME/SYSDIR/BOOT1.OLD',
'/PSP_GAME/SYSDIR/BINOT.BIN',
'/PSP_GAME/SYSDIR/EBOOT.FRY',
'/PSP_GAME/SYSDIR/EBOOT.Z.Y',
'/PSP_GAME/SYSDIR/EBOOT.LEI',
'/PSP_GAME/SYSDIR/EBOOT.DNR',
'/PSP_GAME/SYSDIR/DBZ2.BIN',
// '/PSP_GAME/SYSDIR/ss.RAW',//Code Geass: Lost Colors chinese version
];
// Taken from https://github.com/hrydgard/ppsspp/blob/e094f5673a4f171927afe6eb41eba0326c4511c7/Core/PSPLoaders.cpp#L264
//
// PPSSPP checks for these game IDs by looking at the PARAM.SFO file on the disc. It sounds like other games may have other
// game data files with these paths, so that's why they check the game ID as well.
const OTHER_ALTERNATIVE_EXECUTABLE_PATHS = [
{ gameId: 'NPJH50624', path: '/PSP_GAME/USRDIR/PAKFILE2.BIN' }, // Hunter x Hunter World Adventure (Jpn)
{ gameId: 'NPJH00100', path: '/PSP_GAME/USRDIR/DATA/GIM/GBL' }, // World Neverland: Kukuria Oukoku Monogatari (Jpn)
];
async function getFileSystem(isoArrayBuffer, name) {
const browserFsBuffer = BrowserFS.BFSRequire('buffer').Buffer;
const fsReady = new Promise((resolve, reject) => {
BrowserFS.configure({
fs: 'IsoFS',
options: {
data: browserFsBuffer.from(isoArrayBuffer),
name,
},
}, (err) => { if (err) reject(err); resolve(); });
});
await fsReady;
return BrowserFS.BFSRequire('fs');
}
async function fileExists(fs, path) {
const statPromise = new Promise((resolve, reject) => {
fs.stat(path, (err) => { if (err) reject(err); resolve(); });
});
try {
await statPromise;
} catch (e) {
return false;
}
return true;
}
async function readFile(fs, path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => { if (err) reject(err); resolve(Util.bufferToArrayBuffer(data)); }); // This returns a browserfs Buffer. Somewhere buried in browserfs there is a function called buffer2ArrayBuffer() but I can't figure out how to import it and it looks a lot like our own util function here. Apparently browserfs Buffers are uint8arrays
});
}
async function getGameId(fs) {
const paramSfoExists = await fileExists(fs, PARAM_SFO_PATH);
if (!paramSfoExists) {
return null; // PPSSPP doesn't fail loading a game if this file is missing, so we shouldn't either
}
const paramSfoArrayBuffer = await readFile(fs, PARAM_SFO_PATH);
const paramSfo = new PspParmSfo(paramSfoArrayBuffer);
return paramSfo.getValue('DISC_ID');
}
async function findFirstPathThatExists(fs, pathsArray) {
const pathExistsArray = await Promise.all(pathsArray.map((x) => fileExists(fs, x)));
const pathExistsIndex = pathExistsArray.findIndex((x) => x);
if (pathExistsIndex >= 0) {
return pathsArray[pathExistsIndex];
}
return null;
}
async function getExecutable(fs, gameId) {
// This is based on https://github.com/hrydgard/ppsspp/blob/e094f5673a4f171927afe6eb41eba0326c4511c7/Core/PSPLoaders.cpp#L240
let pathToExecutable = null;
let executableArrayBuffer = null;
let executableIsEncrypted = true;
const mainExecutablePathExists = await fileExists(fs, MAIN_EXECUTABLE_PATH);
if (mainExecutablePathExists) {
pathToExecutable = MAIN_EXECUTABLE_PATH;
}
const alternativePathToExecutable = await findFirstPathThatExists(fs, ALTERNATIVE_EXECUTABLE_PATHS);
if (alternativePathToExecutable !== null) {
pathToExecutable = alternativePathToExecutable;
}
const otherAlternativePaths = OTHER_ALTERNATIVE_EXECUTABLE_PATHS.filter((x) => x.gameId === gameId).map((x) => x.path);
const otherAlternativePathToExecutable = await findFirstPathThatExists(fs, otherAlternativePaths);
if (otherAlternativePathToExecutable !== null) {
pathToExecutable = otherAlternativePathToExecutable;
}
// There's a file that exists at either that main executable path or one of the alternative paths.
// Check if it's an actual encrypted executable
if (pathToExecutable !== null) {
executableArrayBuffer = await readFile(fs, pathToExecutable);
let magicMatches = false;
EXECUTABLE_MAGIC.forEach((potentialMagic) => {
try {
Util.checkMagic(executableArrayBuffer, EXECUTABLE_MAGIC_OFFSET, potentialMagic, EXECUTABLE_MAGIC_ENCODING);
magicMatches = true;
} catch (e) {
// Try the next potentialMagic
}
});
if (!magicMatches) {
pathToExecutable = null;
}
}
if (pathToExecutable === null) {
// Couldn't find an encrypted executable, so let's try the unencrypted one
const unencryptedExecutablePathExists = await fileExists(fs, UNENCRYPTED_EXECUTABLE_PATH);
if (unencryptedExecutablePathExists) {
pathToExecutable = UNENCRYPTED_EXECUTABLE_PATH;
executableIsEncrypted = false;
executableArrayBuffer = await readFile(fs, UNENCRYPTED_EXECUTABLE_PATH);
}
}
if (pathToExecutable === null) {
// We couldn't find any known file, so it's not a valid disc image
throw new Error('This does not appear to be a valid PSP UMD image');
}
return {
gameId,
path: pathToExecutable,
encrypted: executableIsEncrypted,
arrayBuffer: executableArrayBuffer,
};
}
export default class PspIso {
static async Create(isoArrayBuffer, name) {
const fs = await getFileSystem(isoArrayBuffer, name);
const gameId = await getGameId(fs);
const executableInfo = await getExecutable(fs, gameId);
return new PspIso(executableInfo);
}
constructor(executableInfo) {
this.executableInfo = executableInfo;
}
getExecutableInfo() {
return this.executableInfo;
}
}
================================================
FILE: frontend/src/rom-formats/SegaSaturnCueBin.js
================================================
// Extracts some information from a Sega Saturn .bin file for a game in .cue/.bin format
// Note that it must be the first track
import Util from '../util/util';
const MAGIC = 'SEGA SEGASATURN ';
const MAGIC_OFFSET = 0x10;
const MAGIC_ENCODING = 'US-ASCII';
const GAME_ID_OFFSET = 0x30;
const GAME_ID_LENGTH = 0x10;
const GAME_ID_ENCODING = 'US-ASCII';
export default class SegaSaturnCueBin {
constructor(binArrayBuffer) {
Util.checkMagic(binArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const binUint8Array = new Uint8Array(binArrayBuffer);
this.gameId = Util.readNullTerminatedString(binUint8Array, GAME_ID_OFFSET, GAME_ID_ENCODING, GAME_ID_LENGTH);
}
getGameId() {
return this.gameId;
}
static getFileExtensions() {
return [
'.bin',
];
}
}
================================================
FILE: frontend/src/rom-formats/gb.js
================================================
// Extracts some information from a Game Boy ROM.
// Information can be found here: https://gbdev.gg8.se/wiki/articles/The_Cartridge_Header#0134-0143_-_Title
import Util from '../util/util';
const INTERNAL_NAME_OFFSET = 0x134;
const INTERNAL_NAME_LENGTH_GB = 0x10;
const INTERNAL_NAME_LENGTH_GBC = 0xB;
const INTERNAL_NAME_ENCODING = 'US-ASCII';
const GBC_FLAG_OFFSET = 0x143;
const GBC_FLAG_GB_AND_GBC = 0x80;
const GBC_FLAG_GBC_ONLY = 0xC0;
const CARTRIDGE_TYPE_OFFSET = 0x147;
const SRAM_SIZE_OFFSET = 0x149;
const CARTRIDGE_TYPE_MBC2 = 0x5;
const CARTRIDGE_TYPE_MBC2_PLUS_BATTERY = 0x6;
function isMbc2(cartridgeType) {
return ((cartridgeType === CARTRIDGE_TYPE_MBC2) || (cartridgeType === CARTRIDGE_TYPE_MBC2_PLUS_BATTERY));
}
function getSramSize(sramValue, cartridgeType) {
// Memory Bank Controller 2 cartridge types must specify 0 for sramValue, but have SRAM
if (isMbc2(cartridgeType)) {
return 2048;
}
switch (sramValue) {
case 0:
return 0;
case 1:
return 2048;
case 2:
return 8192;
case 3:
return 32768;
case 4:
return 131072;
case 5:
return 65536;
default:
throw new Error(`Unknown SRAM size value: ${sramValue}. Valid values are 0 - 5`);
}
}
export default class GbRom {
constructor(romArrayBuffer) {
const uint8Array = new Uint8Array(romArrayBuffer);
this.isGbc = (uint8Array[GBC_FLAG_OFFSET] === GBC_FLAG_GB_AND_GBC) || (uint8Array[GBC_FLAG_OFFSET] === GBC_FLAG_GBC_ONLY);
const internalNameLength = this.isGbc ? INTERNAL_NAME_LENGTH_GBC : INTERNAL_NAME_LENGTH_GB;
this.internalName = Util.readNullTerminatedString(uint8Array, INTERNAL_NAME_OFFSET, INTERNAL_NAME_ENCODING, internalNameLength);
this.cartridgeType = uint8Array[CARTRIDGE_TYPE_OFFSET];
this.sramSize = getSramSize(uint8Array[SRAM_SIZE_OFFSET], this.cartridgeType);
this.romArrayBuffer = romArrayBuffer;
}
getInternalName() {
return this.internalName;
}
getRomArrayBuffer() {
return this.romArrayBuffer;
}
getIsGbc() {
return this.isGbc;
}
getCartridgeType() {
return this.cartridgeType;
}
getSramSize() {
return this.sramSize;
}
static getFileExtensions() {
return [
'.gb',
'.gbc',
];
}
}
================================================
FILE: frontend/src/rom-formats/gba.js
================================================
// Extracts some information from a Game Boy Advance ROM.
// Based on https://github.com/visualboyadvance-m/visualboyadvance-m/blob/master/src/gba/GBA.cpp#L1168
const INTERNAL_NAME_OFFSET = 0xA0;
const INTERNAL_NAME_LENGTH = 0x10;
const CHECKSUM_OFFSET = 0xBE;
const COMPLIMENT_CHECK_OFFSET = 0xBD;
const MAKER_OFFSET = 0xB0;
const LITTLE_ENDIAN = true;
export default class GbaRom {
constructor(romArrayBuffer) {
const textDecoder = new TextDecoder('utf-8');
const dataView = new DataView(romArrayBuffer);
const internalNameArrayBuffer = romArrayBuffer.slice(INTERNAL_NAME_OFFSET, INTERNAL_NAME_OFFSET + INTERNAL_NAME_LENGTH);
const internalNameUint8Array = new Uint8Array(internalNameArrayBuffer);
this.internalName = textDecoder.decode(internalNameUint8Array);
this.checkSum = dataView.getUint16(CHECKSUM_OFFSET, LITTLE_ENDIAN);
this.complimentCheck = dataView.getUint8(COMPLIMENT_CHECK_OFFSET);
this.maker = dataView.getUint8(MAKER_OFFSET);
}
getInternalName() {
return this.internalName;
}
getChecksum() {
return this.checkSum;
}
getComplimentCheck() {
return this.complimentCheck;
}
getMaker() {
return this.maker;
}
}
================================================
FILE: frontend/src/rom-formats/nes.js
================================================
/* eslint-disable no-bitwise */
// Extracts some information from an NES ROM.
//
// There are 2 different formats: iNES and NES 2.0:
// - iNES: https://www.nesdev.org/wiki/INES
// - NES 2.0: https://www.nesdev.org/wiki/NES_2.0
const LITTLE_ENDIAN = true;
const HEADER_LENGTH = 16; // Both formats have a header that's the same length
const MAGIC_OFFSET = 0;
const MAGIC = 0x1A53454E; // 'NES' followed by EOF, backwards
export default class NesRom {
constructor(romArrayBuffer) {
const dataView = new DataView(romArrayBuffer);
const magic = dataView.getUint32(MAGIC_OFFSET, LITTLE_ENDIAN);
if (magic !== MAGIC) {
throw new Error('This does not appear to be an NES ROM');
}
// According to the wiki, everything that gets to here counts as being a valid iNES headered ROM, and there's
// an additional check to see if it's an NES 2.0 headered ROM
this.isNes20Header = ((dataView.getUint8(7) & 0x0C) === 0x80); // https://www.nesdev.org/wiki/NES_2.0#Identification
this.romArrayBuffer = romArrayBuffer;
}
getIsNes20Header() {
return this.isNes20Header;
}
getRomArrayBufferWithHeader() {
return this.romArrayBuffer;
}
getRomArrayBufferWithoutHeader() {
return this.romArrayBuffer.slice(HEADER_LENGTH);
}
static getFileExtensions() {
return [
'.nes',
];
}
}
================================================
FILE: frontend/src/rom-formats/sms.js
================================================
/* eslint-disable no-bitwise */
// Extracts some information from an SMS ROM.
//
// More info at: https://www.smspower.org/Development/ROMHeader
import Util from '../util/util';
const LITTLE_ENDIAN = true;
const MAGIC_OFFSET = 0x7FF0;
const MAGIC = 'TMR SEGA';
const MAGIC_ENCODING = 'US-ASCII';
const CHECKSUM_OFFSET = 0x7FFA;
export default class SmsRom {
constructor(romArrayBuffer) {
Util.checkMagic(romArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const dataView = new DataView(romArrayBuffer);
this.checksum = dataView.getUint16(CHECKSUM_OFFSET, LITTLE_ENDIAN);
this.romArrayBuffer = romArrayBuffer;
}
getChecksum() {
return this.checksum;
}
static getFileExtensions() {
return [
'.sms',
];
}
}
================================================
FILE: frontend/src/router/index.js
================================================
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
const routes = [
{
path: '/',
redirect: '/mister',
},
{
path: '/retron-5',
name: 'Retron5',
component: () => import(/* webpackChunkName: "retron5" */ '../views/Retron5.vue'),
},
{
path: '/retron-5/erase-save',
name: 'Erase save - Retron5',
component: () => import(/* webpackChunkName: "erase-save-retron5" */ '../views/Retron5EraseSaveView.vue'),
},
{
path: '/wii',
name: 'Wii',
component: () => import(/* webpackChunkName: "wii" */ '../views/Wii.vue'),
},
{
path: '/gba/action-replay',
name: 'GBA - ActionReplay',
component: () => import(/* webpackChunkName: "gba-action-replay" */ '../views/GbaActionReplay.vue'),
},
{
path: '/gba/gameshark',
name: 'GBA - GameShark',
component: () => import(/* webpackChunkName: "gba-gameshark" */ '../views/GbaGameShark.vue'),
},
{
path: '/gba/gameshark-sp',
name: 'GBA - GameShark SP',
component: () => import(/* webpackChunkName: "gba-gameshark-sp" */ '../views/GbaGameSharkSP.vue'),
},
{
path: '/gba',
redirect: '/gba/gameshark',
},
{
path: '/ps1/emulator',
name: 'PS1 - Emulator',
component: () => import(/* webpackChunkName: "ps1-emulator" */ '../views/Ps1Emulator.vue'),
},
{
path: '/ps1/dexdrive',
name: 'PS1 - DexDrive',
component: () => import(/* webpackChunkName: "ps1-dexdrive" */ '../views/Ps1DexDrive.vue'),
},
{
path: '/ps1/psp',
name: 'PS1 - PSP',
component: () => import(/* webpackChunkName: "ps1-psp" */ '../views/Ps1Psp.vue'),
},
{
path: '/ps1/ps3',
name: 'PS1 - PS3',
component: () => import(/* webpackChunkName: "ps1-ps3" */ '../views/Ps1Ps3.vue'),
},
{
path: '/ps1',
redirect: '/ps1/dexdrive',
},
{
path: '/psp',
name: 'PSP decrypt',
component: () => import(/* webpackChunkName: "psp-decrypt" */ '../views/PspDecrypt.vue'),
},
{
path: '/n64/dexdrive',
name: 'N64 - DexDrive',
component: () => import(/* webpackChunkName: "n64-dexdrive" */ '../views/N64DexDrive.vue'),
},
{
path: '/n64/controller-pak',
name: 'N64 - Controller Pak',
component: () => import(/* webpackChunkName: "n64-mempack" */ '../views/N64Mempack.vue'),
},
{
path: '/n64',
redirect: '/n64/dexdrive',
},
{
path: '/mister',
name: 'MiSTer',
component: () => import(/* webpackChunkName: "mister" */ '../views/Mister.vue'),
},
{
path: '/flash-carts',
name: 'Flash carts',
component: () => import(/* webpackChunkName: "flash-carts" */ '../views/FlashCarts.vue'),
},
{
path: '/online-emulators',
name: 'Online emulators',
component: () => import(/* webpackChunkName: "online-emulators" */ '../views/OnlineEmulators.vue'),
},
{
path: '/sega-cd',
name: 'Sega CD',
component: () => import(/* webpackChunkName: "sega-cd" */ '../views/SegaCd.vue'),
},
{
path: '/sega-saturn',
redirect: '/sega-saturn/emulator',
},
{
path: '/sega-saturn/emulator',
name: 'Sega Saturn - Emulator',
component: () => import(/* webpackChunkName: "sega-saturn-emulator" */ '../views/SegaSaturnEmulator.vue'),
},
{
path: '/sega-saturn/saroo',
name: 'Sega Saturn - Saroo',
component: () => import(/* webpackChunkName: "sega-saturn-saroo" */ '../views/SegaSaturnSaroo.vue'),
},
{
path: '/dreamcast',
name: 'Dreamcast',
component: () => import(/* webpackChunkName: "dreamcast" */ '../views/Dreamcast.vue'),
},
{
path: '/nintendo-switch-online',
name: 'Nintendo Switch Online',
component: () => import(/* webpackChunkName: "nintendo-switch-online" */ '../views/NintendoSwitchOnline.vue'),
},
{
path: '/gamecube',
name: 'GameCube',
component: () => import(/* webpackChunkName: "gamecube" */ '../views/GameCube.vue'),
},
{
path: '/srm-sav',
name: '.srm to/from .sav',
component: () => import(/* webpackChunkName: "srm-sav" */ '../views/SrmSav.vue'),
},
{
path: '/utilities',
redirect: '/utilities/advanced',
},
{
path: '/utilities/troubleshooting',
name: 'Troubleshooting',
component: () => import(/* webpackChunkName: "troubleshooting" */ '../views/Troubleshooting.vue'),
},
{
path: '/utilities/erase-save',
name: 'Erase save',
component: () => import(/* webpackChunkName: "erase-save" */ '../views/EraseSaveView.vue'),
},
{
path: '/utilities/advanced',
name: 'Advanced',
component: () => import(/* webpackChunkName: "advanced" */ '../views/AdvancedView.vue'),
props: (route) => ({ initialTab: route.query.tab }),
},
{
path: '/other-converters',
name: 'OtherConverters',
component: () => import(/* webpackChunkName: "other-converters" */ '../views/OtherConverters.vue'),
},
{
path: '/download-saves',
name: 'DownloadSaves',
component: () => import(/* webpackChunkName: "download-saves" */ '../views/DownloadSaves.vue'),
},
{
path: '/original-hardware',
name: 'OriginalHardware',
component: () => import(/* webpackChunkName: "original-hardware" */ '../views/OriginalHardware.vue'),
props: (route) => ({ initialSortBy: route.query.sort }),
},
{
path: '/about',
name: 'About',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
},
// Old paths
{
path: '/action-replay',
redirect: '/gba/action-replay',
},
{
path: '/gameshark',
redirect: '/gba/gameshark',
},
{
path: '/gameshark-sp',
redirect: '/gba/gameshark-sp',
},
{
path: '/troubleshooting',
redirect: '/utilities/troubleshooting',
},
];
const router = new VueRouter({
routes,
});
export default router;
================================================
FILE: frontend/src/save-formats/Dreamcast/Components/Basics.js
================================================
export default class DreamcastBasics {
static LITTLE_ENDIAN = true;
static WORD_SIZE_IN_BYTES = 4;
static BLOCK_SIZE = 512;
static NUM_BLOCKS = 256;
static TOTAL_SIZE = this.BLOCK_SIZE * this.NUM_BLOCKS;
static SYSTEM_INFO_BLOCK_NUMBER = 0xFF; // https://github.com/flyinghead/flycast/blob/33833cfd1ed2d94d907223442fdb8cdafd8d5d80/core/hw/maple/maple_devs.cpp#L516
static SYSTEM_INFO_SIZE_IN_BLOCKS = 1;
static FILE_ALLOCATION_TABLE_BLOCK_NUMBER = 0xFE; // https://github.com/flyinghead/flycast/blob/33833cfd1ed2d94d907223442fdb8cdafd8d5d80/core/hw/maple/maple_devs.cpp#L518
static FILE_ALLOCATION_TABLE_SIZE_IN_BLOCKS = 1;
static DIRECTORY_BLOCK_NUMBER = 0xFD; // https://github.com/flyinghead/flycast/blob/33833cfd1ed2d94d907223442fdb8cdafd8d5d80/core/hw/maple/maple_devs.cpp#L522
static DIRECTORY_SIZE_IN_BLOCKS = 13;
static DIRECTORY_END_BLOCK_NUMBER = this.DIRECTORY_BLOCK_NUMBER - this.DIRECTORY_SIZE_IN_BLOCKS + 1;
static EXTRA_AREA_BLOCK_NUMBER = this.DIRECTORY_BLOCK_NUMBER - this.DIRECTORY_SIZE_IN_BLOCKS;
static EXTRA_AREA_SIZE_IN_BLOCKS = 41;
static SAVE_AREA_SIZE_IN_BLOCKS = 200;
static SAVE_AREA_BLOCK_NUMBER = this.SAVE_AREA_SIZE_IN_BLOCKS - 1; // All of the block numbers specify the final block number, so this one needs to too
static DEFAULT_GAME_BLOCK = 0;
static DEFAULT_MAX_GAME_SIZE = 128; // The hardware to run a game can't address memory past this boundary so we should never need a value greater than this
static MAX_NUM_GAMES = 1;
static FILE_TYPE_DATA = 'Data';
static FILE_TYPE_GAME = 'Game';
}
================================================
FILE: frontend/src/save-formats/Dreamcast/Components/Directory.js
================================================
/* eslint-disable no-bitwise */
/*
Format taken from https://mc.pp.se/dc/vms/flashmem.html
The directory is just a sequence of directory entries
A directory entry that's all 0's is empty
*/
import Util from '../../../util/util';
import ArrayUtil from '../../../util/Array';
import DreamcastBasics from './Basics';
import DreamcastDirectoryEntry from './DirectoryEntry';
const {
BLOCK_SIZE,
DIRECTORY_SIZE_IN_BLOCKS,
SAVE_AREA_SIZE_IN_BLOCKS,
} = DreamcastBasics;
const DIRECTORY_PADDING_VALUE = 0x00;
const MAX_DIRECTORY_ENTRIES = SAVE_AREA_SIZE_IN_BLOCKS;
const DIRECTORY_ENTRY_LENGTH = DreamcastDirectoryEntry.LENGTH;
export default class DreamcastDirectory {
static writeDirectory(saveFilesWithBlockInfo) {
const directoryEntries = saveFilesWithBlockInfo.map((saveFile) => DreamcastDirectoryEntry.writeDirectoryEntry(saveFile));
const directoryEntriesSize = directoryEntries.length * DreamcastDirectoryEntry.LENGTH;
const padding = Util.getFilledArrayBuffer((BLOCK_SIZE * DIRECTORY_SIZE_IN_BLOCKS) - directoryEntriesSize, DIRECTORY_PADDING_VALUE);
return Util.concatArrayBuffers([...directoryEntries, padding]);
}
static readDirectory(arrayBuffer) {
const directoryEntries = ArrayUtil.createSequentialArray(0, MAX_DIRECTORY_ENTRIES).map((i) => {
const directoryEntryArrayBuffer = arrayBuffer.slice(i * DIRECTORY_ENTRY_LENGTH, (i + 1) * DIRECTORY_ENTRY_LENGTH);
return DreamcastDirectoryEntry.readDirectoryEntry(directoryEntryArrayBuffer);
}).filter((directoryEntry) => directoryEntry !== null);
return directoryEntries;
}
}
================================================
FILE: frontend/src/save-formats/Dreamcast/Components/DirectoryEntry.js
================================================
/* eslint-disable no-bitwise */
/*
Format taken from
- https://mc.pp.se/dc/vms/flashmem.html
- https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fs_utils.h#L68
- https://segaxtreme.net/resources/maple-bus-1-0-function-type-specifications-ft1-storage-function.195/
0x00 : 8 bit int : file type (0x00 = no file, 0x33 = data, 0xcc = game)
0x01 : 8 bit int : copy protect (0xff = copy protected, anything else = copy okay. Can limit number of times file can be copied by incrementing this value when it's copied)
0x02-0x03 : 16 bit int (little endian) : location of first block
0x04-0x0f : shift-jis string : filename (12 characters)
0x10-0x17 : BCD timestamp (see below) : file update time
0x18-0x19 : 16 bit int (little endian) : file size (in blocks)
0x1a-0x1b : 16 bit int (little endian) : file header block number (0 for data, 1 for game): https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fs_utils.h#L76
0x1c-0x1f : unused (all zero)
Then in the save data there is the file header block. It can be anywhere in the file data and its location is specified in the directory entry.
0x00-0x0F : Storage comment (note that comments are stored with little endian byte ordering)
0x10-0x3F : File comment
0x40-0x1FF : Icon data
*/
import Util from '../../../util/util';
import DreamcastBasics from './Basics';
import DreamcastUtil from '../Util';
const {
LITTLE_ENDIAN,
BLOCK_SIZE,
FILE_TYPE_DATA,
FILE_TYPE_GAME,
} = DreamcastBasics;
const FILE_TYPE_OFFSET = 0x00;
const COPY_PROTECT_OFFSET = 0x01;
const FIRST_BLOCK_NUMBER_OFFSET = 0x02;
const FILENAME_OFFSET = 0x04;
const FILENAME_LENGTH = 12;
const FILENAME_ENCODING = 'shift-jis'; // https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fs_utils.h#L73
const FILE_CREATION_TIME_OFFSET = 0x10;
const FILE_SIZE_IN_BLOCKS_OFFSET = 0x18;
const FILE_HEADER_BLOCK_NUMBER_OFFSET = 0x1A;
const FILE_TYPE_LOOKUP = {
0x00: 'No file',
0x33: FILE_TYPE_DATA,
0xCC: FILE_TYPE_GAME,
};
const UNKNOWN_FILE_TYPE_STRING = 'Unknown';
const UNKNOWN_FILE_TYPE = 0xFF;
const POSSIBLE_FILE_TYPES = Object.keys(FILE_TYPE_LOOKUP);
const COPY_PROTECT_COPY_OKAY = 0x00;
const COPY_PROTECT_NO_COPY = 0xFF;
const DIRECTORY_ENTRY_PADDING_VALUE = 0x00;
const DIRECTORY_ENTRY_LENGTH = 32;
const FILE_HEADER_COMMENT_ENCODING = 'shift-jis'; // The official docs say that the storage comment is ascii and the file comment is either one- or two-byte encoding. In practice, I've found that both comments can be encoded with shift-jis (which is identical with ascii for most of the first 127 ascii characters).
const FILE_HEADER_STORAGE_COMMENT_OFFSET = 0x00;
const FILE_HEADER_STORAGE_COMMENT_LENGTH = 0x10;
const FILE_HEADER_FILE_COMMENT_OFFSET = 0x10;
const FILE_HEADER_FILE_COMMENT_LENGTH = 0x30;
function getFileTypeString(fileType) {
if (Object.hasOwn(FILE_TYPE_LOOKUP, fileType)) {
return FILE_TYPE_LOOKUP[fileType];
}
return UNKNOWN_FILE_TYPE_STRING;
}
function getFileTypeValue(fileTypeString) {
const fileType = POSSIBLE_FILE_TYPES.find((key) => FILE_TYPE_LOOKUP[key] === fileTypeString);
if (fileType === undefined) {
return UNKNOWN_FILE_TYPE;
}
return fileType;
}
export default class DreamcastDirectoryEntry {
static LENGTH = DIRECTORY_ENTRY_LENGTH;
static writeDirectoryEntry(saveFile) {
let arrayBuffer = Util.getFilledArrayBuffer(DIRECTORY_ENTRY_LENGTH, DIRECTORY_ENTRY_PADDING_VALUE);
arrayBuffer = Util.setString(arrayBuffer, FILENAME_OFFSET, saveFile.filename, FILENAME_ENCODING, FILENAME_LENGTH);
arrayBuffer = DreamcastUtil.writeBcdTimestamp(arrayBuffer, FILE_CREATION_TIME_OFFSET, saveFile.fileCreationTime);
const dataView = new DataView(arrayBuffer);
dataView.setUint8(FILE_TYPE_OFFSET, getFileTypeValue(saveFile.fileType));
dataView.setUint8(COPY_PROTECT_OFFSET, saveFile.copyProtected ? COPY_PROTECT_NO_COPY : COPY_PROTECT_COPY_OKAY);
dataView.setUint16(FIRST_BLOCK_NUMBER_OFFSET, saveFile.firstBlockNumber, LITTLE_ENDIAN);
dataView.setUint16(FILE_SIZE_IN_BLOCKS_OFFSET, saveFile.fileSizeInBlocks, LITTLE_ENDIAN);
dataView.setUint16(FILE_HEADER_BLOCK_NUMBER_OFFSET, saveFile.fileHeaderBlockNumber, LITTLE_ENDIAN);
return arrayBuffer;
}
static getComments(fileHeaderBlockNumber, rawData) {
const fileHeaderBlock = rawData.slice(fileHeaderBlockNumber * BLOCK_SIZE, (fileHeaderBlockNumber + 1) * BLOCK_SIZE);
const uint8Array = new Uint8Array(fileHeaderBlock);
// Some of these strings contain control characters and other garbage at the end. We can try to filter them out
let endOfStringIndex = uint8Array.findIndex((val) => val < 0x20); // 0x20 is a space character, and all the ones prior are control codes
if (endOfStringIndex < 0) {
endOfStringIndex = uint8Array.length;
}
const uint8ArrayTruncated = uint8Array.slice(0, endOfStringIndex);
return {
storageComment: Util.readNullTerminatedString(uint8ArrayTruncated, FILE_HEADER_STORAGE_COMMENT_OFFSET, FILE_HEADER_COMMENT_ENCODING, FILE_HEADER_STORAGE_COMMENT_LENGTH),
fileComment: Util.readNullTerminatedString(uint8ArrayTruncated, FILE_HEADER_FILE_COMMENT_OFFSET, FILE_HEADER_COMMENT_ENCODING, FILE_HEADER_FILE_COMMENT_LENGTH),
};
}
static readDirectoryEntry(arrayBuffer) {
const uint8Array = new Uint8Array(arrayBuffer);
const dataView = new DataView(arrayBuffer);
// An empty entry is one that's all 0x0 (might only be necessary to check the file type?)
const isValidEntry = uint8Array.some((i) => i !== DIRECTORY_ENTRY_PADDING_VALUE);
if (!isValidEntry) {
return null;
}
const fileTypeVal = dataView.getUint8(FILE_TYPE_OFFSET);
const fileType = getFileTypeString(fileTypeVal);
const copyProtected = dataView.getUint8(COPY_PROTECT_OFFSET) === COPY_PROTECT_NO_COPY; // Any value other than 0xFF means that the file can be copied: https://segaxtreme.net/resources/maple-bus-1-0-function-type-specifications-ft1-storage-function.195/
const firstBlockNumber = dataView.getUint16(FIRST_BLOCK_NUMBER_OFFSET, LITTLE_ENDIAN);
const filename = Util.readNullTerminatedString(uint8Array, FILENAME_OFFSET, FILENAME_ENCODING, FILENAME_LENGTH);
const fileCreationTime = DreamcastUtil.readBcdTimestamp(arrayBuffer, FILE_CREATION_TIME_OFFSET);
const fileSizeInBlocks = dataView.getUint16(FILE_SIZE_IN_BLOCKS_OFFSET, LITTLE_ENDIAN);
const fileHeaderBlockNumber = dataView.getUint16(FILE_HEADER_BLOCK_NUMBER_OFFSET, LITTLE_ENDIAN);
return {
fileType,
copyProtected,
firstBlockNumber,
filename,
fileCreationTime,
fileSizeInBlocks,
fileHeaderBlockNumber,
};
}
}
================================================
FILE: frontend/src/save-formats/Dreamcast/Components/FileAllocationTable.js
================================================
/* eslint-disable no-bitwise */
/*
Dreamcast file allocation table block
Format taken from https://mc.pp.se/dc/vms/flashmem.html and https://segaxtreme.net/resources/maple-bus-1-0-function-type-specifications-ft1-storage-function.195/
Each 16 bit value indicates the block number of the next block in the file.
Special values:
- 0xFFFC: Block is unallocated
- 0xFFFA: This is the last block in a file
- 0xFFFF: This block is physically damaged
*/
import Util from '../../../util/util';
import ArrayUtil from '../../../util/Array';
import DreamcastBasics from './Basics';
const {
LITTLE_ENDIAN,
BLOCK_SIZE,
DEFAULT_GAME_BLOCK,
SAVE_AREA_BLOCK_NUMBER,
SYSTEM_INFO_BLOCK_NUMBER,
FILE_ALLOCATION_TABLE_BLOCK_NUMBER,
DIRECTORY_BLOCK_NUMBER,
DIRECTORY_SIZE_IN_BLOCKS,
} = DreamcastBasics;
const UNALLOCATED_BLOCK = 0xFFFC;
const LAST_BLOCK_IN_FILE = 0xFFFA;
const BLOCK_PHYSICALLY_DAMAGED = 0xFFFF;
const PADDING_VALUE = 0x00;
export default class DreamcastFileAllocationTable {
static UNALLOCATED_BLOCK = UNALLOCATED_BLOCK;
static LAST_BLOCK_IN_FILE = LAST_BLOCK_IN_FILE;
static BLOCK_PHYSICALLY_DAMAGED = BLOCK_PHYSICALLY_DAMAGED;
static writeFileAllocationTable(gameFilesWithBlockInfo, dataFilesWithBlockInfo) {
const arrayBuffer = Util.getFilledArrayBuffer(BLOCK_SIZE, PADDING_VALUE); // The portion of the table that corresponds to the padding between the save area and the directory is filled with 0x00 rather than UNALLOCATED_BLOCK
const dataView = new DataView(arrayBuffer);
// Write out entries for the various system blocks
dataView.setUint16(SYSTEM_INFO_BLOCK_NUMBER * 2, LAST_BLOCK_IN_FILE, LITTLE_ENDIAN);
dataView.setUint16(FILE_ALLOCATION_TABLE_BLOCK_NUMBER * 2, LAST_BLOCK_IN_FILE, LITTLE_ENDIAN);
dataView.setUint16((DIRECTORY_BLOCK_NUMBER - DIRECTORY_SIZE_IN_BLOCKS + 1) * 2, LAST_BLOCK_IN_FILE, LITTLE_ENDIAN);
ArrayUtil.createReverseSequentialArray(DIRECTORY_BLOCK_NUMBER, DIRECTORY_SIZE_IN_BLOCKS - 1).forEach((i) => dataView.setUint16(i * 2, i - 1, LITTLE_ENDIAN));
// Write out entries for games to the beginning of the table
let lastUnusedGameBlockNumber = DEFAULT_GAME_BLOCK;
gameFilesWithBlockInfo.forEach((saveFile) => {
ArrayUtil.createSequentialArray(saveFile.firstBlockNumber, saveFile.fileSizeInBlocks - 1).forEach((i) => dataView.setUint16(i * 2, i + 1, LITTLE_ENDIAN));
dataView.setUint16((saveFile.firstBlockNumber + saveFile.fileSizeInBlocks - 1) * 2, LAST_BLOCK_IN_FILE, LITTLE_ENDIAN);
lastUnusedGameBlockNumber = saveFile.firstBlockNumber + saveFile.fileSizeInBlocks;
});
// Write out entries for data to the end of the table
let lastUnusedDataBlockNumber = SAVE_AREA_BLOCK_NUMBER;
dataFilesWithBlockInfo.forEach((saveFile) => {
ArrayUtil.createReverseSequentialArray(saveFile.firstBlockNumber, saveFile.fileSizeInBlocks - 1).forEach((i) => dataView.setUint16(i * 2, i - 1, LITTLE_ENDIAN));
dataView.setUint16((saveFile.firstBlockNumber - saveFile.fileSizeInBlocks + 1) * 2, LAST_BLOCK_IN_FILE, LITTLE_ENDIAN);
lastUnusedDataBlockNumber = saveFile.firstBlockNumber - saveFile.fileSizeInBlocks;
});
const numUnallocatedBlocks = lastUnusedDataBlockNumber - lastUnusedGameBlockNumber + 1;
ArrayUtil.createReverseSequentialArray(lastUnusedDataBlockNumber, numUnallocatedBlocks).forEach((i) => dataView.setUint16(i * 2, UNALLOCATED_BLOCK, LITTLE_ENDIAN));
return arrayBuffer;
}
static readFileAllocationTable(arrayBuffer) {
const numEntries = arrayBuffer.byteLength / 2;
const dataView = new DataView(arrayBuffer);
const nextBlockInFile = new Array(numEntries);
for (let i = 0; i < numEntries; i += 1) {
const offset = i * 2;
nextBlockInFile[i] = dataView.getUint16(offset, LITTLE_ENDIAN);
if ((nextBlockInFile[i] >= numEntries)
&& (nextBlockInFile[i] !== UNALLOCATED_BLOCK)
&& (nextBlockInFile[i] !== LAST_BLOCK_IN_FILE)
&& (nextBlockInFile[i] !== BLOCK_PHYSICALLY_DAMAGED)) {
throw new Error(`Found invalid value 0x${nextBlockInFile[i].toString(16)} in file allocation table at offset ${offset}`);
}
}
return nextBlockInFile;
}
}
================================================
FILE: frontend/src/save-formats/Dreamcast/Components/SystemInfo.js
================================================
/* eslint-disable no-bitwise */
/*
Dreamcast system info block
Format taken from:
- https://vmu.falcogirgis.net/filesystem.html
- https://mc.pp.se/dc/vms/flashmem.html
- https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fat.h#L120
- https://segaxtreme.net/resources/maple-bus-1-0-function-type-specifications-ft1-storage-function.195/
- https://github.com/flyinghead/flycast/blob/33833cfd1ed2d94d907223442fdb8cdafd8d5d80/core/hw/maple/maple_devs.cpp#L510
0x00-0x0f : All these bytes contain 0x55 to indicate a properly formatted card.
0x10 : custom VMS colour (1 = use custom colours below, 0 = standard colour)
0x11 : VMS colour blue component
0x12 : VMS colour green component
0x13 : VMS colour red component
0x14 : VMS colour alpha component (use 100 for semi-transparent, 255 for opaque)
0x15-0x2f : not used (all zeroes)
0x30-0x37 : BCD timestamp (see Directory below)
0x38-0x3f : not used (all zeroes)
0x40-0x41 : largest block number (255): https://github.com/flyinghead/flycast/blob/33833cfd1ed2d94d907223442fdb8cdafd8d5d80/core/hw/maple/maple_devs.cpp#L512
0x42-0x43 : partitian number (0): the official docs indicate there were plans for this but in practice it seems to always be zero
0x44-0x45 : location of system area block (255)
0x46-0x47 : location of FAT block (254)
0x48-0x49 : size of FAT in blocks (1)
0x4a-0x4b : location of directory (253)
0x4c-0x4d : size of directory in blocks (13)
0x4e : icon shape for this VMS (0-123)
0x4f : unknown (sort flag? https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fat.h#L145 reserved? https://github.com/flyinghead/flycast/blob/33833cfd1ed2d94d907223442fdb8cdafd8d5d80/core/hw/maple/maple_devs.cpp#L525)
0x50-0x51 : size of the save area (200) (libevum describes it as the location of the extra region, but the other regions are specified by their ending block number not beginning) https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fat.h#L146 (the official docs say that this is the block number of the start of the save area, however that would be 199 and this contains 200)
0x52-0x53 : size of the extra area (41) https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fat.h#L147 listed in flycast as "number of save blocks" and set at 31 which doesn't match the size or location of the save area, nor the number of empty blocks between the save area and the system blocks. https://github.com/flyinghead/flycast/blob/33833cfd1ed2d94d907223442fdb8cdafd8d5d80/core/hw/maple/maple_devs.cpp#L531. It's listed in the official docs along with the previous value as specifying the save area: start block number and then number of blocks. However, that would be the values 199 and 200 respectively
0x54-0x55 : game block (0) (starting location for game file): https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fat.h#L148
0x56-0x57 : game size (128) (maximum size for game file): https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fat.h#L149
Notes:
0x10-0x2f : the official docs say that this is the volume label "The contents of this field can be anything.".
https://vmu.falcogirgis.net/filesystem.html says it's the VMU color then unused bytes as listed above, as does libevmu. The sample file I found in practice conformed to this format.
*/
import Util from '../../../util/util';
import DreamcastBasics from './Basics';
import DreamcastUtil from '../Util';
const {
LITTLE_ENDIAN,
} = DreamcastBasics;
const MAGIC_OFFSET = 0;
const MAGIC_LENGTH = 0x10;
const MAGIC_VALUE = 0x55;
const MAGIC_BYTES = new Uint8Array(Util.getFilledArrayBuffer(MAGIC_LENGTH, MAGIC_VALUE));
const USE_CUSTOM_COLOR_OFFSET = 0x10;
const CUSTOM_COLOR_BLUE_OFFSET = 0x11;
const CUSTOM_COLOR_GREEN_OFFSET = 0x12;
const CUSTOM_COLOR_RED_OFFSET = 0x13;
const CUSTOM_COLOR_ALPHA_OFFSET = 0x14;
const TIMESTAMP_OFFSET = 0x30;
const LARGEST_BLOCK_NUMBER_OFFSET = 0x40;
const PARTITION_NUMBER_OFFSET = 0x42;
const SYSTEM_INFO_BLOCK_NUMBER_OFFSET = 0x44;
const FILE_ALLOCATION_TABLE_BLOCK_NUMBER_OFFSET = 0x46;
const FILE_ALLOCATION_TABLE_SIZE_IN_BLOCKS_OFFSET = 0x48;
const DIRECTORY_BLOCK_NUMBER_OFFSET = 0x4A;
const DIRECTORY_SIZE_IN_BLOCKS_OFFSET = 0x4C;
const ICON_SHAPE_OFFSET = 0x4E;
const SAVE_AREA_SIZE_IN_BLOCKS_OFFSET = 0x50;
const EXTRA_AREA_SIZE_IN_BLOCKS_OFFSET = 0x52;
const GAME_BLOCK_OFFSET = 0x54;
const MAX_GAME_SIZE_OFFSET = 0x56;
const DEFAULT_PARTITION_NUMBER = 0;
const PADDING_VALUE = 0x00;
export default class DreamcastSystemInfo {
static writeSystemInfo(volumeInfo) {
// Many of the fields within the system are set to fixed, hardcoded, values in real VMU data.
// So we don't require them to be set in the volumeInfo that we're passed
let arrayBuffer = Util.getFilledArrayBuffer(DreamcastBasics.BLOCK_SIZE, PADDING_VALUE);
arrayBuffer = Util.setMagicBytes(arrayBuffer, MAGIC_OFFSET, MAGIC_BYTES);
arrayBuffer = DreamcastUtil.writeBcdTimestamp(arrayBuffer, TIMESTAMP_OFFSET, volumeInfo.timestamp);
const dataView = new DataView(arrayBuffer);
dataView.setUint8(USE_CUSTOM_COLOR_OFFSET, volumeInfo.useCustomColor ? 1 : 0);
if (Object.hasOwn(volumeInfo, 'customColor')) {
dataView.setUint8(CUSTOM_COLOR_BLUE_OFFSET, volumeInfo.customColor.blue);
dataView.setUint8(CUSTOM_COLOR_GREEN_OFFSET, volumeInfo.customColor.green);
dataView.setUint8(CUSTOM_COLOR_RED_OFFSET, volumeInfo.customColor.red);
dataView.setUint8(CUSTOM_COLOR_ALPHA_OFFSET, volumeInfo.customColor.alpha);
} else {
dataView.setUint8(CUSTOM_COLOR_BLUE_OFFSET, 0);
dataView.setUint8(CUSTOM_COLOR_GREEN_OFFSET, 0);
dataView.setUint8(CUSTOM_COLOR_RED_OFFSET, 0);
dataView.setUint8(CUSTOM_COLOR_ALPHA_OFFSET, 0);
}
dataView.setUint16(LARGEST_BLOCK_NUMBER_OFFSET, DreamcastBasics.NUM_BLOCKS - 1, LITTLE_ENDIAN);
dataView.setUint16(PARTITION_NUMBER_OFFSET, DEFAULT_PARTITION_NUMBER, LITTLE_ENDIAN);
dataView.setUint16(SYSTEM_INFO_BLOCK_NUMBER_OFFSET, DreamcastBasics.SYSTEM_INFO_BLOCK_NUMBER, LITTLE_ENDIAN);
dataView.setUint16(FILE_ALLOCATION_TABLE_BLOCK_NUMBER_OFFSET, DreamcastBasics.FILE_ALLOCATION_TABLE_BLOCK_NUMBER, LITTLE_ENDIAN);
dataView.setUint16(FILE_ALLOCATION_TABLE_SIZE_IN_BLOCKS_OFFSET, DreamcastBasics.FILE_ALLOCATION_TABLE_SIZE_IN_BLOCKS, LITTLE_ENDIAN);
dataView.setUint16(DIRECTORY_BLOCK_NUMBER_OFFSET, DreamcastBasics.DIRECTORY_BLOCK_NUMBER, LITTLE_ENDIAN);
dataView.setUint16(DIRECTORY_SIZE_IN_BLOCKS_OFFSET, DreamcastBasics.DIRECTORY_SIZE_IN_BLOCKS, LITTLE_ENDIAN);
dataView.setUint8(ICON_SHAPE_OFFSET, volumeInfo.iconShape);
dataView.setUint16(SAVE_AREA_SIZE_IN_BLOCKS_OFFSET, DreamcastBasics.SAVE_AREA_SIZE_IN_BLOCKS, LITTLE_ENDIAN);
dataView.setUint16(EXTRA_AREA_SIZE_IN_BLOCKS_OFFSET, DreamcastBasics.EXTRA_AREA_SIZE_IN_BLOCKS, LITTLE_ENDIAN);
dataView.setUint16(GAME_BLOCK_OFFSET, DreamcastBasics.DEFAULT_GAME_BLOCK, LITTLE_ENDIAN);
dataView.setUint16(MAX_GAME_SIZE_OFFSET, DreamcastBasics.DEFAULT_MAX_GAME_SIZE, LITTLE_ENDIAN);
return arrayBuffer;
}
static readSystemInfo(arrayBuffer) {
Util.checkMagicBytes(arrayBuffer, MAGIC_OFFSET, MAGIC_BYTES);
const dataView = new DataView(arrayBuffer);
const useCustomColor = (dataView.getUint8(USE_CUSTOM_COLOR_OFFSET) !== 0);
const customColor = {
blue: dataView.getUint8(CUSTOM_COLOR_BLUE_OFFSET),
green: dataView.getUint8(CUSTOM_COLOR_GREEN_OFFSET),
red: dataView.getUint8(CUSTOM_COLOR_RED_OFFSET),
alpha: dataView.getUint8(CUSTOM_COLOR_ALPHA_OFFSET),
};
const timestamp = DreamcastUtil.readBcdTimestamp(arrayBuffer, TIMESTAMP_OFFSET);
const largestBlockNumber = dataView.getUint16(LARGEST_BLOCK_NUMBER_OFFSET, LITTLE_ENDIAN);
const partitionNumber = dataView.getUint16(PARTITION_NUMBER_OFFSET, LITTLE_ENDIAN);
const systemInfoBlockNumber = dataView.getUint16(SYSTEM_INFO_BLOCK_NUMBER_OFFSET, LITTLE_ENDIAN);
const fileAllocationTableBlockNumber = dataView.getUint16(FILE_ALLOCATION_TABLE_BLOCK_NUMBER_OFFSET, LITTLE_ENDIAN);
const fileAllocationTableSizeInBlocks = dataView.getUint16(FILE_ALLOCATION_TABLE_SIZE_IN_BLOCKS_OFFSET, LITTLE_ENDIAN);
const directoryBlockNumber = dataView.getUint16(DIRECTORY_BLOCK_NUMBER_OFFSET, LITTLE_ENDIAN);
const directorySizeInBlocks = dataView.getUint16(DIRECTORY_SIZE_IN_BLOCKS_OFFSET, LITTLE_ENDIAN);
const iconShape = dataView.getUint8(ICON_SHAPE_OFFSET);
const saveAreaSizeInBlocks = dataView.getUint16(SAVE_AREA_SIZE_IN_BLOCKS_OFFSET, LITTLE_ENDIAN);
const extraAreaSizeInBlocks = dataView.getUint16(EXTRA_AREA_SIZE_IN_BLOCKS_OFFSET, LITTLE_ENDIAN);
const gameBlock = dataView.getUint16(GAME_BLOCK_OFFSET, LITTLE_ENDIAN);
const maxGameSize = dataView.getUint16(MAX_GAME_SIZE_OFFSET, LITTLE_ENDIAN);
return {
useCustomColor,
customColor,
timestamp,
largestBlockNumber,
partitionNumber,
systemInfo: {
blockNumber: systemInfoBlockNumber,
sizeInBlocks: DreamcastBasics.SYSTEM_INFO_SIZE_IN_BLOCKS,
},
fileAllocationTable: {
blockNumber: fileAllocationTableBlockNumber,
sizeInBlocks: fileAllocationTableSizeInBlocks,
},
directory: {
blockNumber: directoryBlockNumber,
sizeInBlocks: directorySizeInBlocks,
},
iconShape,
extraArea: {
blockNumber: DreamcastBasics.EXTRA_AREA_BLOCK_NUMBER,
sizeInBlocks: extraAreaSizeInBlocks,
},
saveArea: {
blockNumber: DreamcastBasics.SAVE_AREA_BLOCK_NUMBER,
sizeInBlocks: saveAreaSizeInBlocks,
},
gameBlock,
maxGameSize,
};
}
}
================================================
FILE: frontend/src/save-formats/Dreamcast/Dreamcast.js
================================================
/* eslint-disable no-bitwise */
/*
The format for a memory card image
Taken from https://mc.pp.se/dc/vms/flashmem.html
Official documentation here: https://segaxtreme.net/resources/maple-bus-1-0-function-type-specifications-ft1-storage-function.195/
Blocks 0-199: User save area
Blocks 200-240: Not used
Blocks 241-253: Directory
Block 254: File allocation table
Block 255: System information
Note that the file is written back-to-front: we first write the blocks nearest the end of the file.
But within each block we write it from the end of the block closest to the start of the file.
This applies to all sections, but most notably to the Directory and User save area sections.
The exception is writing a game to the user save area: it starts at block 0 and must be written contiguously going forward
*/
import Util from '../../util/util';
import ArrayUtil from '../../util/Array';
import DreamcastBasics from './Components/Basics';
import DreamcastSystemInfo from './Components/SystemInfo';
import DreamcastFileAllocationTable from './Components/FileAllocationTable';
import DreamcastDirectory from './Components/Directory';
import DreamcastDirectoryEntry from './Components/DirectoryEntry';
const {
BLOCK_SIZE,
TOTAL_SIZE,
MAX_DIRECTORY_ENTRIES,
DIRECTORY_END_BLOCK_NUMBER,
SAVE_AREA_BLOCK_NUMBER,
SAVE_AREA_SIZE_IN_BLOCKS,
SYSTEM_INFO_SIZE_IN_BLOCKS,
EXTRA_AREA_SIZE_IN_BLOCKS,
FILE_TYPE_GAME,
MAX_NUM_GAMES,
DEFAULT_GAME_BLOCK,
} = DreamcastBasics;
const FILL_VALUE = 0x00;
function concatBlocks(blockNumbers, arrayBuffer) {
const blocks = blockNumbers.map((i) => arrayBuffer.slice(i * BLOCK_SIZE, (i + 1) * BLOCK_SIZE));
return Util.concatArrayBuffers(blocks);
}
function getBlocks(blockNumber, sizeInBlocks, arrayBuffer) {
// The starting block is the one closest to the end of the file.
// However, we fill each block starting at the end of the block closest to the beginning of the file.
// So to make a contiguous blob of data here we need to concat our blocks starting from the end of the file
const blockNumbers = ArrayUtil.createReverseSequentialArray(blockNumber, sizeInBlocks);
return concatBlocks(blockNumbers, arrayBuffer);
}
function getBlocksForward(blockNumber, sizeInBlocks, arrayBuffer) {
// Some files are built incorrectly and arrange their blocks starting at the one closest to the beginning of the file
return arrayBuffer.slice(blockNumber * BLOCK_SIZE, (blockNumber + sizeInBlocks) * BLOCK_SIZE);
}
function createBlocksForward(arrayBuffer) {
// Split our array buffer into blocks
const numBlocks = Math.ceil(arrayBuffer.byteLength / BLOCK_SIZE);
const blocks = ArrayUtil.createSequentialArray(0, numBlocks).map((i) => arrayBuffer.slice(i * BLOCK_SIZE, (i + 1) * BLOCK_SIZE));
if (blocks[blocks.length - 1].byteLength < BLOCK_SIZE) {
const paddingLength = BLOCK_SIZE - blocks[blocks.length - 1].byteLength;
blocks[blocks.length - 1] = Util.concatArrayBuffers([blocks[blocks.length - 1], Util.getFilledArrayBuffer(paddingLength, FILL_VALUE)]);
}
return blocks;
}
function createBlocks(arrayBuffer) {
// Similar to getBlocks() we will split our array buffer into blocks and then reverse them
return createBlocksForward(arrayBuffer).reverse();
}
function getBlockNumbers(directoryEntry, fileAllocationTable) {
const blockNumbers = [];
let currentBlockNumber = directoryEntry.firstBlockNumber;
do {
if ((currentBlockNumber < 0) || (currentBlockNumber >= fileAllocationTable.length)) {
throw new Error(`Save file ${directoryEntry.filename} appears to be corrupted: it references invalid block ${currentBlockNumber}`);
}
if (fileAllocationTable[currentBlockNumber] === DreamcastFileAllocationTable.UNALLOCATED_BLOCK) {
throw new Error(`Save file ${directoryEntry.filename} appears to be corrupted: save file appears to contain a block that is unallocated`);
}
if (fileAllocationTable[currentBlockNumber] === DreamcastFileAllocationTable.BLOCK_PHYSICALLY_DAMAGED) {
throw new Error(`Save file ${directoryEntry.filename} appears to be corrupted: save file appears to contain a block that is physically damaged`);
}
blockNumbers.push(currentBlockNumber);
currentBlockNumber = fileAllocationTable[currentBlockNumber];
} while (currentBlockNumber !== DreamcastFileAllocationTable.LAST_BLOCK_IN_FILE);
if (blockNumbers.length !== directoryEntry.fileSizeInBlocks) {
throw new Error(`Save file ${directoryEntry.filename} appears to be corrupted: expected to find ${directoryEntry.fileSizeInBlocks} blocks `
+ `but instead found ${blockNumbers.length} blocks when traversing file allocation table`);
}
return blockNumbers;
}
function getSaveFileWithBlockInfo(saveFile, currentBlockNumber) {
const fileSizeInBlocks = Math.ceil(saveFile.rawData.byteLength / BLOCK_SIZE);
return {
...saveFile,
firstBlockNumber: currentBlockNumber,
fileSizeInBlocks,
};
}
function getGameFilesWithBlockInfo(gameFiles) {
let currentBlockNumber = DEFAULT_GAME_BLOCK; // Start at the beginning of the save area and work towards the end of the file
return gameFiles.map((saveFile) => {
const saveFileWithBlockInfo = getSaveFileWithBlockInfo(saveFile, currentBlockNumber);
currentBlockNumber += saveFileWithBlockInfo.fileSizeInBlocks;
return saveFileWithBlockInfo;
});
}
function getDataFilesWithBlockInfo(dataFiles) {
let currentBlockNumber = SAVE_AREA_BLOCK_NUMBER; // Start at the end of the save area and work towards the beginning of the file
return dataFiles.map((saveFile) => {
const saveFileWithBlockInfo = getSaveFileWithBlockInfo(saveFile, currentBlockNumber);
currentBlockNumber -= saveFileWithBlockInfo.fileSizeInBlocks;
return saveFileWithBlockInfo;
});
}
function splitArray(array, predicate) {
return array.reduce(
(accumulator, currentValue) => {
if (predicate(currentValue)) {
accumulator[0].push(currentValue); // Add to the first array if predicate is true
} else {
accumulator[1].push(currentValue); // Add to the second array if predicate is false
}
return accumulator;
},
[[], []],
);
}
export default class DreamcastSaveData {
static createFromDreamcastData(arrayBuffer) {
if ((arrayBuffer.byteLength < TOTAL_SIZE) || ((arrayBuffer.byteLength % BLOCK_SIZE) !== 0)) {
throw new Error('This does not appear to be a Dreamcast VMU image');
}
// In theory a dreamcast image can be any number of blocks, with the final block specifying the system info and thus
// how the rest of the file is laid out.
// In practice I think all dreamcast images are the same size, but we may as well not assume they are
const finalBlockNumber = Math.floor(arrayBuffer.byteLength / BLOCK_SIZE) - 1;
const volumeInfo = DreamcastSystemInfo.readSystemInfo(getBlocks(finalBlockNumber, SYSTEM_INFO_SIZE_IN_BLOCKS, arrayBuffer));
const fileIsLaidOutForward = (volumeInfo.directory.blockNumber === DIRECTORY_END_BLOCK_NUMBER); // The file allocation table is typically just a single block long so it's not helpful as a hint. Thus we use the directory as a hint: if the last block is how it's specified then we know the file is laid out non-standard aka forwards
let fileAllocationTableBlocks = getBlocks(volumeInfo.fileAllocationTable.blockNumber, volumeInfo.fileAllocationTable.sizeInBlocks, arrayBuffer);
let directoryBlocks = getBlocks(volumeInfo.directory.blockNumber, volumeInfo.directory.sizeInBlocks, arrayBuffer);
if (fileIsLaidOutForward) {
fileAllocationTableBlocks = getBlocksForward(volumeInfo.fileAllocationTable.blockNumber, volumeInfo.fileAllocationTable.sizeInBlocks, arrayBuffer);
directoryBlocks = getBlocksForward(volumeInfo.directory.blockNumber, volumeInfo.directory.sizeInBlocks, arrayBuffer);
}
const fileAllocationTable = DreamcastFileAllocationTable.readFileAllocationTable(fileAllocationTableBlocks);
const directoryEntries = DreamcastDirectory.readDirectory(directoryBlocks);
const saveFiles = directoryEntries.map((directoryEntry) => {
const blockNumberList = getBlockNumbers(directoryEntry, fileAllocationTable);
const rawData = concatBlocks(blockNumberList, arrayBuffer);
const comments = DreamcastDirectoryEntry.getComments(directoryEntry.fileHeaderBlockNumber, rawData);
return {
...directoryEntry,
...comments,
blockNumberList,
rawData,
};
});
return new DreamcastSaveData(arrayBuffer, saveFiles, volumeInfo);
}
static createFromSaveFiles(saveFiles, volumeInfo) {
if (saveFiles.length > MAX_DIRECTORY_ENTRIES) {
throw new Error(`Unable to fit ${saveFiles.length} saves into a single VMU image. Max is ${MAX_DIRECTORY_ENTRIES}`);
}
const [gameFiles, dataFiles] = splitArray(saveFiles, (saveFile) => saveFile.fileType === FILE_TYPE_GAME);
if (gameFiles.length > MAX_NUM_GAMES) {
throw new Error(`Unable to fit ${gameFiles.length} games into a single VMU image. Max is ${MAX_NUM_GAMES}`);
}
// We may want to check that the size of the game is <= DEFAULT_MAX_GAME_SIZE as well? Or do we want to allow homebrew
// games that are larger for devices like the VMU Pro?
const gameFilesWithBlockInfo = getGameFilesWithBlockInfo(gameFiles);
const dataFilesWithBlockInfo = getDataFilesWithBlockInfo(dataFiles);
const saveFilesWithBlockInfo = [...gameFilesWithBlockInfo, ...dataFilesWithBlockInfo];
const totalBlocks = saveFilesWithBlockInfo.reduce(((accumulator, x) => accumulator + x.fileSizeInBlocks), 0);
if (totalBlocks > SAVE_AREA_SIZE_IN_BLOCKS) {
throw new Error(`Save files contain a total of ${totalBlocks} blocks of data but a VMU image can only hold ${SAVE_AREA_SIZE_IN_BLOCKS} blocks`);
}
const systemInfo = DreamcastSystemInfo.writeSystemInfo(volumeInfo);
const fileAllocationTable = DreamcastFileAllocationTable.writeFileAllocationTable(gameFilesWithBlockInfo, dataFilesWithBlockInfo);
const directory = DreamcastDirectory.writeDirectory(saveFilesWithBlockInfo);
const systemInfoBlocks = createBlocks(systemInfo);
const fileAllocationTableBlocks = createBlocks(fileAllocationTable);
const directoryBlocks = createBlocks(directory);
const extraAreaArrayBuffer = Util.getFilledArrayBuffer(EXTRA_AREA_SIZE_IN_BLOCKS * BLOCK_SIZE, FILL_VALUE); // Blocks 0 - 199 are for the save area, but the directory begins on block 241 leaving 41 blocks unused in between
const gameFileBlocks = gameFiles.map((saveFile) => createBlocksForward(saveFile.rawData)).flat(); // We write our game files from the front of the file to the back
const dataFileBlocks = dataFiles.reverse().map((saveFile) => createBlocks(saveFile.rawData)).flat(); // We write our data files from the back of the file to the front
const blocksUsed = systemInfoBlocks.length + fileAllocationTableBlocks.length + directoryBlocks.length + EXTRA_AREA_SIZE_IN_BLOCKS + dataFileBlocks.length + gameFileBlocks.length;
const paddingArrayBuffer = Util.getFilledArrayBuffer(TOTAL_SIZE - (BLOCK_SIZE * blocksUsed), FILL_VALUE);
const memcardArrayBuffer = Util.concatArrayBuffers([
...gameFileBlocks,
paddingArrayBuffer,
...dataFileBlocks,
extraAreaArrayBuffer,
...directoryBlocks,
...fileAllocationTableBlocks,
...systemInfoBlocks,
]);
return new DreamcastSaveData(memcardArrayBuffer, saveFiles, volumeInfo);
}
constructor(arrayBuffer, saveFiles, volumeInfo) {
this.arrayBuffer = arrayBuffer;
this.saveFiles = saveFiles;
this.volumeInfo = volumeInfo;
}
getSaveFiles() {
return this.saveFiles;
}
getVolumeInfo() {
return this.volumeInfo;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Dreamcast/IndividualSaves/Dci.js
================================================
/* eslint-disable no-bitwise */
/*
.DCI is a directory entry concatenated with the raw save data
Note that the starting block number in the directory entry is irrelevant here
0x00-0x3F: Directory entry
0x40-EOF: Game data (stored endian-sweapped)
*/
import Util from '../../../util/util';
import EndianUtil from '../../../util/Endian';
import DreamcastBasics from '../Components/Basics';
import DreamcastDirectoryEntry from '../Components/DirectoryEntry';
const {
BLOCK_SIZE,
WORD_SIZE_IN_BYTES,
} = DreamcastBasics;
const DATA_OFFSET = DreamcastDirectoryEntry.LENGTH;
const DEFAULT_FIRST_BLOCK_NUMBER = 0; // Doesn't matter: the concept of where the save is located doesn't mean anything in this format
export default class DreamcastDciSaveData {
static convertSaveFileToDci(saveFile) {
const saveFileWithBlockInfo = {
...saveFile,
firstBlockNumber: DEFAULT_FIRST_BLOCK_NUMBER,
fileSizeInBlocks: Math.ceil(saveFile.rawData.byteLength / BLOCK_SIZE),
};
const directoryEntryArrayBuffer = DreamcastDirectoryEntry.writeDirectoryEntry(saveFileWithBlockInfo);
return Util.concatArrayBuffers([directoryEntryArrayBuffer, EndianUtil.swap(saveFileWithBlockInfo.rawData, WORD_SIZE_IN_BYTES)]);
}
static convertIndividualSaveToSaveFile(arrayBuffer, checkSaveSize = false) {
const directoryEntry = DreamcastDirectoryEntry.readDirectoryEntry(arrayBuffer);
const rawData = EndianUtil.swap(arrayBuffer.slice(DATA_OFFSET), WORD_SIZE_IN_BYTES);
if (checkSaveSize && (rawData.byteLength !== (directoryEntry.fileSizeInBlocks * BLOCK_SIZE))) {
throw new Error('This file appears to be corrupt');
}
const comments = DreamcastDirectoryEntry.getComments(directoryEntry.fileHeaderBlockNumber, rawData);
return {
...directoryEntry,
...comments,
rawData,
};
}
}
================================================
FILE: frontend/src/save-formats/Dreamcast/IndividualSaves/VmiVms.js
================================================
/* eslint-disable no-bitwise */
/*
The standard format for individual saves on the Dreamcast appears to be the .VMI/.VMS file pair. The .VMI file contains
the file metadata while the .VMS file contains the actual file data
.VMI file structure
0x00-0x03: Checksum
0x04-0x23: Description (padded with spaces)
0x24-0x43: Copyright (padded with spaces)
0x44-0x4B: Timestamp
0x4C-0x4D: Version
0x4E-0x4F: File number
0x50-0x57: Resource name
0x58-0x63: Filename
0x64-0x65: File mode
0x66-0x67: Padding
0x68-0x6B: File size
*/
import DreamcastBasics from '../Components/Basics';
import DreamcastDirectoryEntry from '../Components/DirectoryEntry';
import DreamcastUtil from '../Util';
import Util from '../../../util/util';
const {
LITTLE_ENDIAN,
BLOCK_SIZE,
FILE_TYPE_DATA,
FILE_TYPE_GAME,
} = DreamcastBasics;
const ENCODING = 'shift-jis'; // https://github.com/gyrovorbis/libevmu/blob/9d1bf63983d40b81b03ac0bcf887a9a3c114ed86/lib/api/evmu/fs/evmu_vmi.h#L80
// Based on https://mc.pp.se/dc/vms/vmi.html
// Same struct found here: https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_vmi.h#L86
// Same struct found here: https://github.com/bucanero/dc-save-converter/blob/master/vmufs.h#L44
// Same struct found here: https://github.com/DC-SWAT/DreamShell/blob/0eb2ebe888b31438a131f20ec15abdd66964505a/applications/vmu_manager/modules/module.c#L149
const HEADER_LENGTH = 108;
const CHECKSUM_OFFSET = 0;
const CHECKSUM_LITTLE_ENDIAN = false; // The checksum isn't really a number per se, but instead the combination of 2 strings so it's better read from left to right
const CHECKSUM_MASK = 'SEGA';
const DESCRIPTION_OFFSET = 0x04;
const DESCRIPTION_LENGTH = 32;
const COPYRIGHT_OFFSET = 0x24;
const COPYRIGHT_LENGTH = 32;
const TIMESTAMP_OFFSET = 0x44;
const VERSION_OFFSET = 0x4C;
const FILE_NUMBER_OFFSET = 0x4E;
const RESOURCE_NAME_OFFSET = 0x50;
const RESOURCE_NAME_LENGTH = 8;
const FILE_NAME_OFFSET = 0x58;
const FILE_NAME_LENGTH = 12;
const FILE_MODE_OFFSET = 0x64;
const FILE_SIZE_OFFSET = 0x68;
const FILE_MODE_GAME = 0x02;
const FILE_MODE_COPY_PROTECTED = 0x01;
const FILE_MODE_NONE = 0x00;
const DEFAULT_HEADER_FILL_VALUE = 0x00;
const DEFAULT_VERSION = 0; // Not sure what this represents. Most files I've seen have 0 here
const DEFAULT_FILE_NUMBER = 1; // Not sure what this represents. Most files I've seen have 1 here
const DEFAULT_FIRST_BLOCK_NUMBER = 0; // Doesn't matter: the concept of where the save is located doesn't mean anything in this format
const HEADER_BLOCK_NUMBER_FOR_DATA = 0; // For save data, the header block is the first one
const HEADER_BLOCK_NUMBER_FOR_GAME = 1; // But for minigames it's in block 1 because "block 0 of mini games had to have IRQ code so it can’t have that header": https://github.com/gyrovorbis/libevmu/blob/libgimbal-refactor/lib/api/evmu/fs/evmu_fs_utils.h#L76
// Based on https://github.com/bucanero/dc-save-converter/blob/a19fc3361805358d474acd772cdb20a328453d5b/dcvmu.cpp#L428
function calculateChecksum(resourceName) {
let checksum = 0;
let currentChar = 0;
do {
checksum <<= 8;
checksum |= (resourceName.charCodeAt(currentChar) & CHECKSUM_MASK.charCodeAt(currentChar));
currentChar += 1;
} while (currentChar < CHECKSUM_MASK.length);
return checksum;
}
export default class DreamcastVmiVmsSaveData {
static FILE_MODE_GAME = FILE_MODE_GAME;
static FILE_MODE_COPY_PROTECTED = FILE_MODE_COPY_PROTECTED;
static FILE_MODE_NONE = FILE_MODE_NONE;
// saveFile needs to set the additional fields:
// - description
// - copyright
// - resourceName
static convertSaveFileToVmiVms(saveFile) {
let vmiArrayBuffer = Util.getFilledArrayBuffer(HEADER_LENGTH, DEFAULT_HEADER_FILL_VALUE);
const vmsArrayBuffer = saveFile.rawData;
vmiArrayBuffer = Util.setString(vmiArrayBuffer, DESCRIPTION_OFFSET, saveFile.description, ENCODING, DESCRIPTION_LENGTH);
vmiArrayBuffer = Util.setString(vmiArrayBuffer, COPYRIGHT_OFFSET, saveFile.copyright, ENCODING, COPYRIGHT_LENGTH);
vmiArrayBuffer = Util.setString(vmiArrayBuffer, RESOURCE_NAME_OFFSET, saveFile.resourceName, ENCODING, RESOURCE_NAME_LENGTH);
vmiArrayBuffer = Util.setString(vmiArrayBuffer, FILE_NAME_OFFSET, saveFile.filename, ENCODING, FILE_NAME_LENGTH);
vmiArrayBuffer = DreamcastUtil.writeTimestamp(vmiArrayBuffer, TIMESTAMP_OFFSET, saveFile.fileCreationTime);
const vmiDataView = new DataView(vmiArrayBuffer);
const checksum = calculateChecksum(saveFile.resourceName);
let fileMode = 0;
if (saveFile.copyProtected) {
fileMode |= FILE_MODE_COPY_PROTECTED;
}
if (saveFile.fileType === FILE_TYPE_GAME) {
fileMode |= FILE_MODE_GAME;
}
vmiDataView.setUint32(CHECKSUM_OFFSET, checksum, CHECKSUM_LITTLE_ENDIAN);
vmiDataView.setUint16(VERSION_OFFSET, DEFAULT_VERSION, LITTLE_ENDIAN);
vmiDataView.setUint16(FILE_NUMBER_OFFSET, DEFAULT_FILE_NUMBER, LITTLE_ENDIAN);
vmiDataView.setUint16(FILE_MODE_OFFSET, fileMode, LITTLE_ENDIAN);
vmiDataView.setUint32(FILE_SIZE_OFFSET, vmsArrayBuffer.byteLength, LITTLE_ENDIAN);
return {
vmiArrayBuffer,
vmsArrayBuffer,
};
}
// Based on https://github.com/bucanero/dc-save-converter/blob/a19fc3361805358d474acd772cdb20a328453d5b/dcvmu.cpp#L388
static convertIndividualSaveToSaveFile(vmiArrayBuffer, vmsArrayBuffer) {
if (vmiArrayBuffer.byteLength !== HEADER_LENGTH) {
throw new Error('This does not appear to be a Dreamcast individual save: header is the wrong size');
}
const vmiDataView = new DataView(vmiArrayBuffer);
const vmiUint8Array = new Uint8Array(vmiArrayBuffer);
// Read what's stored in the file
const checksum = vmiDataView.getUint32(CHECKSUM_OFFSET, CHECKSUM_LITTLE_ENDIAN);
const description = Util.readNullTerminatedString(vmiUint8Array, DESCRIPTION_OFFSET, ENCODING, DESCRIPTION_LENGTH);
const copyright = Util.readNullTerminatedString(vmiUint8Array, COPYRIGHT_OFFSET, ENCODING, COPYRIGHT_LENGTH);
const fileCreationTime = DreamcastUtil.readTimestamp(vmiArrayBuffer, TIMESTAMP_OFFSET);
const version = vmiDataView.getUint16(VERSION_OFFSET, LITTLE_ENDIAN);
const fileNumber = vmiDataView.getUint16(FILE_NUMBER_OFFSET, LITTLE_ENDIAN);
const resourceName = Util.readNullTerminatedString(vmiUint8Array, RESOURCE_NAME_OFFSET, ENCODING, RESOURCE_NAME_LENGTH);
const filename = Util.readNullTerminatedString(vmiUint8Array, FILE_NAME_OFFSET, ENCODING, FILE_NAME_LENGTH);
const fileMode = vmiDataView.getUint16(FILE_MODE_OFFSET, LITTLE_ENDIAN);
const fileSize = vmiDataView.getUint32(FILE_SIZE_OFFSET, LITTLE_ENDIAN);
if (fileSize !== vmsArrayBuffer.byteLength) {
throw new Error(`This does not appear to be a Dreamcast individual save: file size in header ${fileSize} does not match .VMS file size ${vmsArrayBuffer.byteLength}`);
}
// Calculate the parts common to all dreamcast saves
const isGame = ((fileMode & FILE_MODE_GAME) !== 0);
const fileSizeInBlocks = Math.ceil(fileSize / BLOCK_SIZE);
const firstBlockNumber = DEFAULT_FIRST_BLOCK_NUMBER;
const fileType = isGame ? FILE_TYPE_GAME : FILE_TYPE_DATA;
const copyProtected = ((fileMode & FILE_MODE_COPY_PROTECTED) !== 0);
const fileHeaderBlockNumber = isGame ? HEADER_BLOCK_NUMBER_FOR_GAME : HEADER_BLOCK_NUMBER_FOR_DATA;
const comments = DreamcastDirectoryEntry.getComments(fileHeaderBlockNumber, vmsArrayBuffer);
return {
// These parts are specific to the .vmi/.vms format
checksum,
description,
copyright,
version,
fileNumber,
resourceName,
fileMode,
fileSize,
// These parts are common to all dreamcast saves
filename,
fileType,
fileCreationTime,
copyProtected,
fileSizeInBlocks,
firstBlockNumber,
fileHeaderBlockNumber,
...comments,
rawData: vmsArrayBuffer,
};
}
}
================================================
FILE: frontend/src/save-formats/Dreamcast/Util.js
================================================
/* eslint-disable no-bitwise */
import DreamcastBasics from './Components/Basics';
import Util from '../../util/util';
const { LITTLE_ENDIAN } = DreamcastBasics;
const TIMESTAMP_YEAR_OFFSET = 0;
const TIMESTAMP_MONTH_OFFSET = 2;
const TIMESTAMP_DAY_OFFSET = 3;
const TIMESTAMP_HOUR_OFFSET = 4;
const TIMESTAMP_MINUTE_OFFSET = 5;
const TIMESTAMP_SECOND_OFFSET = 6;
const TIMESTAMP_DAY_OF_WEEK_OFFSET = 7;
const BCD_TIMESTAMP_CENTURY_OFFSET = 0;
const BCD_TIMESTAMP_YEAR_WITHIN_CENTURY_OFFSET = 1;
const TIMESTAMP_LENGTH = 8;
function readBcdByte(val) {
const tens = Math.floor(val / 16);
const ones = val % 16;
return (tens * 10) + ones;
}
function writeBcdByte(val) {
const highByte = Math.floor(val / 10);
const lowByte = val % 10;
return (highByte << 4) | lowByte;
}
function formatDateWithoutTimezone(date) {
const pad = (n) => String(n).padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1); // Months are zero-indexed
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
export default class DreamcastUtil {
static readTimestamp(arrayBuffer, offset) {
// Date conversion based on https://mc.pp.se/dc/vms/vmi.html
const dataView = new DataView(arrayBuffer.slice(offset, offset + TIMESTAMP_LENGTH));
const year = dataView.getUint16(TIMESTAMP_YEAR_OFFSET, LITTLE_ENDIAN);
const month = dataView.getUint8(TIMESTAMP_MONTH_OFFSET) - 1; // Dreamcast months are 1-12, Javascript months are 0-11
const day = dataView.getUint8(TIMESTAMP_DAY_OFFSET);
const hour = dataView.getUint8(TIMESTAMP_HOUR_OFFSET);
const minute = dataView.getUint8(TIMESTAMP_MINUTE_OFFSET);
const second = dataView.getUint8(TIMESTAMP_SECOND_OFFSET);
const date = new Date(year, month, day, hour, minute, second); // No timezone information is given in the Dreamcast format. This Date object is in the local timezone
return date;
}
static writeTimestamp(arrayBuffer, offset, date) {
const timestampArrayBuffer = new ArrayBuffer(TIMESTAMP_LENGTH);
const dataView = new DataView(timestampArrayBuffer);
dataView.setUint16(TIMESTAMP_YEAR_OFFSET, date.getFullYear(), LITTLE_ENDIAN);
dataView.setUint8(TIMESTAMP_MONTH_OFFSET, date.getMonth() + 1); // Dreamcast months are 1-12, Javascript months are 0-11
dataView.setUint8(TIMESTAMP_DAY_OFFSET, date.getDate());
dataView.setUint8(TIMESTAMP_HOUR_OFFSET, date.getHours());
dataView.setUint8(TIMESTAMP_MINUTE_OFFSET, date.getMinutes());
dataView.setUint8(TIMESTAMP_SECOND_OFFSET, date.getSeconds());
dataView.setUint8(TIMESTAMP_DAY_OF_WEEK_OFFSET, date.getDay());
// For the day of week, different files appear to be inconsistent. In some 0 represents Sunday and in some 0 represents Monday.
// The official dreamcast docs say that 0 represents Sunday. In a Javascript Date, 0 represents Sunday
// https://mc.pp.se/dc/vms/vmi.html says that Sunday is 0
return Util.setArrayBufferPortion(arrayBuffer, timestampArrayBuffer, offset, 0, TIMESTAMP_LENGTH);
}
static readBcdTimestamp(arrayBuffer, offset) {
// Date conversion based on https://mc.pp.se/dc/vms/flashmem.html
const dataView = new DataView(arrayBuffer.slice(offset, offset + TIMESTAMP_LENGTH));
const century = readBcdByte(dataView.getUint8(BCD_TIMESTAMP_CENTURY_OFFSET));
const yearWithinCentury = readBcdByte(dataView.getUint8(BCD_TIMESTAMP_YEAR_WITHIN_CENTURY_OFFSET));
const year = (century * 100) + yearWithinCentury;
const month = readBcdByte(dataView.getUint8(TIMESTAMP_MONTH_OFFSET)) - 1; // Dreamcast months are 1-12, Javascript months are 0-11
const day = readBcdByte(dataView.getUint8(TIMESTAMP_DAY_OFFSET));
const hour = readBcdByte(dataView.getUint8(TIMESTAMP_HOUR_OFFSET));
const minute = readBcdByte(dataView.getUint8(TIMESTAMP_MINUTE_OFFSET));
const second = readBcdByte(dataView.getUint8(TIMESTAMP_SECOND_OFFSET));
// For the day of week, different files appear to be inconsistent. In some 0 represents Sunday and in some 0 represents Monday.
// The official dreamcast docs say that 0 represents Sunday. In a Javascript Date, 0 represents Sunday
// https://mc.pp.se/dc/vms/flashmem.html says that Monday is 0
const date = new Date(year, month, day, hour, minute, second); // No timezone information is given in the Dreamcast format. This Date object is in the local timezone
return date;
}
static writeBcdTimestamp(arrayBuffer, offset, date) {
const timestampArrayBuffer = new ArrayBuffer(TIMESTAMP_LENGTH);
const dataView = new DataView(timestampArrayBuffer);
const century = Math.floor(date.getFullYear() / 100);
const yearWithinCentury = date.getFullYear() % 100;
dataView.setUint8(BCD_TIMESTAMP_CENTURY_OFFSET, writeBcdByte(century));
dataView.setUint8(BCD_TIMESTAMP_YEAR_WITHIN_CENTURY_OFFSET, writeBcdByte(yearWithinCentury));
dataView.setUint8(TIMESTAMP_MONTH_OFFSET, writeBcdByte(date.getMonth() + 1)); // Dreamcast months are 1-12, Javascript months are 0-11
dataView.setUint8(TIMESTAMP_DAY_OFFSET, writeBcdByte(date.getDate()));
dataView.setUint8(TIMESTAMP_HOUR_OFFSET, writeBcdByte(date.getHours()));
dataView.setUint8(TIMESTAMP_MINUTE_OFFSET, writeBcdByte(date.getMinutes()));
dataView.setUint8(TIMESTAMP_SECOND_OFFSET, writeBcdByte(date.getSeconds()));
dataView.setUint8(TIMESTAMP_DAY_OF_WEEK_OFFSET, writeBcdByte(date.getDay())); // The official docs say that Dreamcast Sunday is 0, and JavaScript Sunday is 0 so we're going with that. https://mc.pp.se/dc/vms/flashmem.html says that Monday is 0
return Util.setArrayBufferPortion(arrayBuffer, timestampArrayBuffer, offset, 0, TIMESTAMP_LENGTH);
}
// Dreamcast timestamps do not have a timezone, so are all in local time. So we want to print them without timezone information so that the tests work wherever they are executed
static formatDateWithoutTimezone(date) {
return formatDateWithoutTimezone(date);
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/GB.js
================================================
import SaveFilesUtil from '../../util/SaveFiles';
export default class GbFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
return new GbFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new GbFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(gbFlashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(gbFlashCartSaveData.getRawArrayBuffer(), newSize);
return GbFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return null; // GB/C saves have many possible extensions, and we just want to keep whatever the original extension was
}
static getRawFileExtension() {
return null; // GB/C saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'gb';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/GBA/EmulatorBase.js
================================================
/* eslint-disable no-bitwise */
/*
Based on the Goomba Save Manager, specifically:
- Ignore the first 4 bytes of the file (they specify the type of save): https://github.com/libertyernie/goombasav/blob/master/main.c#L127
- Format for the header: https://github.com/libertyernie/goombasav/blob/master/goombasav.h#L101
- Criteria for "cleaning" the data first: https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L330
*/
import Util from '../../../util/util';
import PaddingUtil from '../../../util/Padding';
import CompressionLzoUtil from '../../../util/CompressionLzo';
const LITTLE_ENDIAN = true;
const MAGIC_OFFSET = 0; // Offset relative to the start of the file
const MAGIC_LENGTH = 4;
// These offsets are relative to the start of a "state header"
const SIZE_OFFSET = 0; // Header + data size
const TYPE_OFFSET = 2;
const TYPE_SAVE_STATE = 0;
const TYPE_SRAM_SAVE = 1;
const TYPE_CONFIG_DATA = 2;
const TYPE_PALETTE = 5;
const UNCOMPRESSED_SIZE_OFFSET = 4;
const FRAME_COUNT_OFFSET = 8; // Number of ingame frames that have passed since the game was started. Seems to only be set in SMSAdvance
const ROM_CHECKSUM_OFFSET = 12;
const GAME_TITLE_OFFSET = 16;
const GAME_TITLE_LENGTH = 32;
const GAME_TITLE_ENCODING = 'US-ASCII';
const STATE_HEADER_TYPE_NAMES = {
0: 'Save state', // TYPE_SAVE_STATE
1: 'SRAM save', // TYPE_SRAM_SAVE
2: 'Config data', // TYPE_CONFIG_DATA
5: 'Palette', // TYPE_PALETTE
};
const STATE_HEADER_LENGTH = GAME_TITLE_OFFSET + GAME_TITLE_LENGTH;
const LARGEST_GBC_SAVE_SIZE = 0x10000; // This is just used as a hint to the decompression algorithm so it can allocate memory. Value copied from https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L349
// Not sure why this is named this, but when a file is "unclean" then there's uncompressed data here:
// Value copied from https://github.com/libertyernie/goombasav/blob/master/goombasav.h#L29
//
// Note that different builds of Goomba and PocketNES use different offsets here:
// https://github.com/masterhou/goombacolor/blob/master/src/sram.c#L22
// https://github.com/Dwedit/PocketNES/blob/main/src/sram.c#L32
//
// SMSAdvance appears to not do this, and only seems to have compressed SRAM in its files (based on source code found here: https://github.com/Dwedit/PocketNES/issues/2)
//
// The default is 0xE000: https://github.com/masterhou/goombacolor/blob/82505813da728bfe88902e48096246a61fbccf79/src/config.h#L6
// This may be related to difficulties re save sizes on an Everdrive? https://www.dwedit.org/dwedit_board/viewtopic.php?pid=3736#p3736
//
// Some save files are 32kB long, which is 0x8000 bytes. So, if the filesize is < 0xE000 then we use 0x6000 as the offset.
//
// Note that Goomba and PocketNES use a fairly complex algorithm to determine whether to use 0xE000 or 0x6000:
// https://github.com/Dwedit/goombacolor/blob/master/src/sram.c#L164
// https://github.com/Dwedit/PocketNES/blob/main/src/sram.c#L193
const GOOMBA_COLOR_AVAILABLE_SIZE = 0xE000;
const GOOMBA_COLOR_SMALLER_AVAILABLE_SIZE = 0x6000;
const GOOMBA_COLOR_SRAM_SIZE = 0x10000; // Value copied from https://github.com/libertyernie/goombasav/blob/master/goombasav.h#L28
function readStateHeader(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
const uint8Array = new Uint8Array(arrayBuffer);
return {
size: dataView.getUint16(SIZE_OFFSET, LITTLE_ENDIAN),
type: dataView.getUint16(TYPE_OFFSET, LITTLE_ENDIAN),
uncompressedSize: dataView.getUint32(UNCOMPRESSED_SIZE_OFFSET, LITTLE_ENDIAN),
frameCount: dataView.getUint32(FRAME_COUNT_OFFSET, LITTLE_ENDIAN),
romChecksum: dataView.getUint32(ROM_CHECKSUM_OFFSET, LITTLE_ENDIAN),
gameTitle: Util.readNullTerminatedString(uint8Array, GAME_TITLE_OFFSET, GAME_TITLE_ENCODING, GAME_TITLE_LENGTH),
};
}
function createStateHeaderArrayBuffer(stateHeader) {
const arrayBuffer = new ArrayBuffer(STATE_HEADER_LENGTH);
const dataView = new DataView(arrayBuffer);
const uint8Array = new Uint8Array(arrayBuffer);
const textEncoder = new TextEncoder(GAME_TITLE_ENCODING);
uint8Array.fill(0);
dataView.setUint16(SIZE_OFFSET, stateHeader.size, LITTLE_ENDIAN);
dataView.setUint16(TYPE_OFFSET, stateHeader.type, LITTLE_ENDIAN);
dataView.setUint32(UNCOMPRESSED_SIZE_OFFSET, stateHeader.uncompressedSize, LITTLE_ENDIAN);
dataView.setUint32(FRAME_COUNT_OFFSET, stateHeader.frameCount, LITTLE_ENDIAN);
dataView.setUint32(ROM_CHECKSUM_OFFSET, stateHeader.romChecksum, LITTLE_ENDIAN);
const encodedGameTitle = textEncoder.encode(stateHeader.gameTitle).slice(0, GAME_TITLE_LENGTH - 1);
uint8Array.set(encodedGameTitle, GAME_TITLE_OFFSET);
return arrayBuffer;
}
// Taken from https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L155
function stateHeaderIsPlausible(stateHeader) {
switch (stateHeader.type) {
case TYPE_SAVE_STATE:
case TYPE_SRAM_SAVE:
case TYPE_CONFIG_DATA:
case TYPE_PALETTE:
// Type is okay: fall through
break;
default:
return false;
}
if (stateHeader.size < STATE_HEADER_LENGTH) {
return false;
}
if ((stateHeader.uncompressedSize === 0) && (stateHeader.type !== TYPE_CONFIG_DATA)) {
return false;
}
return true;
}
function createMagicArrayBuffer(magic, length) {
const arrayBuffer = new ArrayBuffer(length);
const dataView = new DataView(arrayBuffer);
dataView.setUint32(0, magic, LITTLE_ENDIAN);
return arrayBuffer;
}
function createEmulatorArrayBuffer(rawArrayBuffer, romInternalName, romChecksum, clazz) {
const magicArrayBuffer = createMagicArrayBuffer(clazz.getMagic(), MAGIC_LENGTH);
const compressedSaveDataArrayBuffer = CompressionLzoUtil.compress(rawArrayBuffer);
const stateHeader = {
size: compressedSaveDataArrayBuffer.byteLength + STATE_HEADER_LENGTH,
type: TYPE_SRAM_SAVE,
uncompressedSize: rawArrayBuffer.byteLength,
frameCount: 0,
romChecksum,
gameTitle: romInternalName,
};
const stateHeaderArrayBuffer = createStateHeaderArrayBuffer(stateHeader);
const configDataArrayBuffer = clazz.createEmptyConfigDataArrayBuffer();
const unpaddedEmulatorArrayBuffer = clazz.concatEmulatorArrayBuffer(magicArrayBuffer, stateHeaderArrayBuffer, compressedSaveDataArrayBuffer, configDataArrayBuffer);
const padding = {
count: Math.max(GOOMBA_COLOR_SRAM_SIZE - unpaddedEmulatorArrayBuffer.byteLength, 0),
value: 0,
};
return PaddingUtil.addPaddingToEnd(unpaddedEmulatorArrayBuffer, padding);
}
// Based on https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L249
function findStateHeaderOfType(arrayBuffer, stateHeaderType) {
let currentByte = MAGIC_LENGTH;
if (arrayBuffer.byteLength < (MAGIC_LENGTH + STATE_HEADER_LENGTH)) {
throw new Error('File is too short to contain a state header');
}
let stateHeader = readStateHeader(arrayBuffer.slice(currentByte, currentByte + STATE_HEADER_LENGTH));
while (stateHeaderIsPlausible(stateHeader)) {
if (stateHeader.type === stateHeaderType) {
return {
stateHeader,
offset: currentByte,
};
}
currentByte += stateHeader.size;
if ((currentByte + STATE_HEADER_LENGTH) > arrayBuffer.byteLength) {
break;
}
stateHeader = readStateHeader(arrayBuffer.slice(currentByte, currentByte + STATE_HEADER_LENGTH));
}
throw new Error(`No state header of type ${STATE_HEADER_TYPE_NAMES[stateHeaderType]} found in file`);
}
export default class EmulatorBaseSaveData {
static LITTLE_ENDIAN = LITTLE_ENDIAN;
static TYPE_CONFIG_DATA = TYPE_CONFIG_DATA;
// This function split out so that we can call it from tests. We can't include a retail ROM
// with our tests (we need the entire ROM to calculate the checksum), so this allows us to fill in those values
static createFromRawDataInternal(rawArrayBuffer, romInternalName, romChecksum, clazz) {
const emulatorArrayBuffer = createEmulatorArrayBuffer(rawArrayBuffer, romInternalName, romChecksum, clazz);
return new clazz(emulatorArrayBuffer); // eslint-disable-line new-cap
}
static getFlashCartFileExtension() {
return 'esv';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return null;
}
constructor(emulatorArrayBuffer) {
const emulatorDataView = new DataView(emulatorArrayBuffer);
const magic = emulatorDataView.getUint32(MAGIC_OFFSET, LITTLE_ENDIAN);
const expectedMagic = this.constructor.getMagic();
if (magic !== expectedMagic) {
throw new Error(`File appears to be corrupted: expected 0x${expectedMagic.toString(16)} but found 0x${magic.toString(16)}`);
}
const { stateHeader, offset } = findStateHeaderOfType(emulatorArrayBuffer, TYPE_SRAM_SAVE);
// The save editor makes the case that compressed size is the size stored in the file minus the header:
// https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L348
// https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L405
//
// And that uncompressed size is incorrectly stored in goomba (but not goomba color) files:
// https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L405
const sramRomChecksum = this.getSramRomChecksumFromConfigData(emulatorArrayBuffer);
let needsCleaning = false;
if (sramRomChecksum === 0) {
// File is clean
} else if (sramRomChecksum === stateHeader.romChecksum) {
// File is unclean, and needs to be cleaned
// https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L342
needsCleaning = true;
} else {
// File is unclean, but it shouldn't affect the data we're interested in
// https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L345
}
this.compressedSize = stateHeader.size - STATE_HEADER_LENGTH;
this.uncompressedSize = stateHeader.uncompressedSize;
this.frameCount = stateHeader.frameCount;
this.romChecksum = stateHeader.romChecksum;
this.gameTitle = stateHeader.gameTitle;
// It seems that the compressed and/or uncompressed size might be incorrect for goomba (rather than goomba color) files. The save editor just goes until the end of the file: https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L350
const compressedDataOffset = offset + STATE_HEADER_LENGTH;
if (needsCleaning) {
const uncompressedDataOffset = (emulatorArrayBuffer.byteLength < GOOMBA_COLOR_AVAILABLE_SIZE) ? GOOMBA_COLOR_SMALLER_AVAILABLE_SIZE : GOOMBA_COLOR_AVAILABLE_SIZE;
this.rawArrayBuffer = emulatorArrayBuffer.slice(uncompressedDataOffset, GOOMBA_COLOR_SRAM_SIZE); // Based on https://github.com/libertyernie/goombasav/blob/master/goombasav.c#L308
} else {
this.rawArrayBuffer = CompressionLzoUtil.decompress(
emulatorArrayBuffer.slice(
compressedDataOffset,
// compressedDataOffset + this.compressedSize,
),
LARGEST_GBC_SAVE_SIZE, // this.uncompressedSize,
);
}
this.flashCartArrayBuffer = emulatorArrayBuffer;
}
// This is described as "checksum of rom using SRAM e000-ffff"
// here https://github.com/libertyernie/goombasav/blob/master/goombasav.h#L80
//
// So it's a checksum of the ROM, but from a portion of the SRAM towards the end?
getSramRomChecksumFromConfigData(arrayBuffer) {
const { stateHeader, offset } = findStateHeaderOfType(arrayBuffer, TYPE_CONFIG_DATA);
if (stateHeader.size !== this.constructor.getConfigDataLength()) {
throw new Error(`Unrecognized config data type: size of ${stateHeader.size} is unknown`);
}
return this.constructor.getPlatformSramRomChecksumFromConfigData(arrayBuffer, offset);
}
// Taken from https://github.com/masterhou/goombacolor/blob/master/src/sram.c#L258
//
// Note that this only looks (sporadically) at the first 16kB of the file
static calculateRomChecksum(romArrayBuffer) {
let sum = 0;
let currentByte = 0;
const totalBytes = romArrayBuffer.byteLength;
const romUint8Array = new Uint8Array(romArrayBuffer);
const lastByte = romUint8Array[totalBytes - 1];
for (let i = 0; i < 128; i += 1) {
if (currentByte < totalBytes) {
sum += (romUint8Array[currentByte] | (romUint8Array[currentByte + 1] << 8) | (romUint8Array[currentByte + 2] << 16) | (romUint8Array[currentByte + 3] << 24));
} else {
sum += (lastByte | (lastByte << 8) | (lastByte << 16) | (lastByte << 24));
}
sum >>>= 0; // Convert to unsigned: https://stackoverflow.com/a/1822769
currentByte += 128;
}
return sum;
}
static saveDataIsCompressed() {
return true;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
getCompressedSize() {
return this.compressedSize;
}
getUncompressedSize() {
return this.uncompressedSize;
}
getGameTitle() {
return this.gameTitle;
}
getRomChecksum() {
return this.romChecksum;
}
getFrameCount() {
return this.frameCount;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/GBA/GBA.js
================================================
import SaveFilesUtil from '../../../util/SaveFiles';
export default class GbaFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
return new GbaFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new GbaFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(gbaFlashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(gbaFlashCartSaveData.getRawArrayBuffer(), newSize);
return GbaFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return null; // GBA saves have many possible extensions, and we just want to keep whatever the original extension was
}
static getRawFileExtension() {
return null; // GBA saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'gba';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/GBA/GoombaEmulator.js
================================================
// Find it at https://github.com/Dwedit/goombacolor/releases
import EmulatorBase from './EmulatorBase';
import Util from '../../../util/util';
import GbRom from '../../../rom-formats/gb';
const GOOMBA_MAGIC = 0x57A731D8; // Goomba (GB/GBC) save
const GOOMBA_CONFIG_DATA_SIZE_OFFSET = 0;
const GOOMBA_CONFIG_DATA_TYPE_OFFSET = 2;
const GOOMBA_CONFIG_DATA_BORDER_COLOR_OFFSET = 4;
const GOOMBA_CONFIG_DATA_PALETTE_BANK_OFFSET = 5;
const GOOMBA_CONFIG_DATA_SRAM_ROM_CHECKSUM_OFFSET = 8;
const GOOMBA_CONFIG_DATA_RESERVED_OFFSET = 16;
const GOOMBA_CONFIG_DATA_RESERVED_LENGTH = 32;
const GOOMBA_CONFIG_DATA_RESERVED_DATA = 'CFG';
const GOOMBA_CONFIG_DATA_RESERVED_ENCODING = 'US-ASCII';
const GOOMBA_CONFIG_DATA_LENGTH = GOOMBA_CONFIG_DATA_RESERVED_OFFSET + GOOMBA_CONFIG_DATA_RESERVED_LENGTH;
const GOOMBA_CONFIG_DATA_DEFAULT_BORDER_COLOR = 0;
const GOOMBA_CONFIG_DATA_DEFAULT_PALETTE_BANK = 0;
export default class GoombaEmulatorSaveData extends EmulatorBase {
static getMagic() {
return GOOMBA_MAGIC;
}
static getConfigDataLength() {
return GOOMBA_CONFIG_DATA_LENGTH;
}
static createFromRawData(rawArrayBuffer, romArrayBuffer) {
const gbRom = new GbRom(romArrayBuffer);
const romInternalName = gbRom.getInternalName();
const romChecksum = super.calculateRomChecksum(gbRom.getRomArrayBuffer());
return super.createFromRawDataInternal(rawArrayBuffer, romInternalName, romChecksum, GoombaEmulatorSaveData);
}
static createFromRawDataInternal(rawArrayBuffer, romInternalName, romChecksum) {
return super.createFromRawDataInternal(rawArrayBuffer, romInternalName, romChecksum, GoombaEmulatorSaveData);
}
static createFromFlashCartData(goombaArrayBuffer) {
return new GoombaEmulatorSaveData(goombaArrayBuffer);
}
static requiresRom() {
return {
clazz: GbRom,
requiredToConvert: ['convertToFormat'],
};
}
// Based on https://github.com/libertyernie/goombasav/blob/master/goombasav.h#L61
static createEmptyConfigDataArrayBuffer() {
const arrayBuffer = new ArrayBuffer(GOOMBA_CONFIG_DATA_LENGTH);
const dataView = new DataView(arrayBuffer);
const uint8Array = new Uint8Array(arrayBuffer);
const textEncoder = new TextEncoder(GOOMBA_CONFIG_DATA_RESERVED_ENCODING);
uint8Array.fill(0);
dataView.setUint16(GOOMBA_CONFIG_DATA_SIZE_OFFSET, GOOMBA_CONFIG_DATA_LENGTH, super.LITTLE_ENDIAN);
dataView.setUint16(GOOMBA_CONFIG_DATA_TYPE_OFFSET, super.TYPE_CONFIG_DATA, super.LITTLE_ENDIAN);
dataView.setUint8(GOOMBA_CONFIG_DATA_BORDER_COLOR_OFFSET, GOOMBA_CONFIG_DATA_DEFAULT_BORDER_COLOR);
dataView.setUint8(GOOMBA_CONFIG_DATA_PALETTE_BANK_OFFSET, GOOMBA_CONFIG_DATA_DEFAULT_PALETTE_BANK);
dataView.setUint32(GOOMBA_CONFIG_DATA_SRAM_ROM_CHECKSUM_OFFSET, 0, super.LITTLE_ENDIAN); // Checksum here gets set to 0 so that the file is "clean"
const encodedReservedData = textEncoder.encode(GOOMBA_CONFIG_DATA_RESERVED_DATA).slice(0, GOOMBA_CONFIG_DATA_RESERVED_LENGTH - 1);
uint8Array.set(encodedReservedData, GOOMBA_CONFIG_DATA_RESERVED_LENGTH);
return arrayBuffer;
}
static getPlatformSramRomChecksumFromConfigData(arrayBuffer, currentByte) {
const dataView = new DataView(arrayBuffer);
return dataView.getUint32(currentByte + GOOMBA_CONFIG_DATA_SRAM_ROM_CHECKSUM_OFFSET, super.LITTLE_ENDIAN);
}
static concatEmulatorArrayBuffer(magicArrayBuffer, stateHeaderArrayBuffer, compressedSaveDataArrayBuffer, configDataArrayBuffer) {
return Util.concatArrayBuffers([magicArrayBuffer, stateHeaderArrayBuffer, compressedSaveDataArrayBuffer, configDataArrayBuffer]);
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/GBA/PocketNesEmulator.js
================================================
/* eslint-disable no-bitwise */
// Find it at https://github.com/Dwedit/PocketNES/releases
import EmulatorBase from './EmulatorBase';
import NesRom from '../../../rom-formats/nes';
import Util from '../../../util/util';
const POCKETNES_MAGIC = 0x57A731D7; // Pocket NES save
const POCKETNES_CONFIG_DATA_SIZE_OFFSET = 0;
const POCKETNES_CONFIG_DATA_TYPE_OFFSET = 2;
const POCKETNES_CONFIG_DATA_DISPLAY_TYPE_OFFSET = 4;
const POCKETNES_CONFIG_DATA_MISC_OFFSET = 5;
const POCKETNES_CONFIG_DATA_SRAM_ROM_CHECKSUM_OFFSET = 8;
const POCKETNES_CONFIG_DATA_RESERVED_OFFSET = 16;
const POCKETNES_CONFIG_DATA_RESERVED_LENGTH = 32;
const POCKETNES_CONFIG_DATA_RESERVED_DATA = 'CFG';
const POCKETNES_CONFIG_DATA_RESERVED_ENCODING = 'US-ASCII';
const POCKETNES_CONFIG_DATA_LENGTH = POCKETNES_CONFIG_DATA_RESERVED_OFFSET + POCKETNES_CONFIG_DATA_RESERVED_LENGTH;
// DisplayType explanation:
//
// The lower bits are the scaling type and the upper bits are the gamma: https://github.com/Dwedit/PocketNES/blob/master/src/sram.c#L1119
//
// The default scaling value is 3 (everything scaled): https://github.com/Dwedit/PocketNES/blob/master/src/main.c#L83
// The various values are described here: https://github.com/Dwedit/PocketNES/blob/master/src/ui.c#L194
// The default gamma is 0
//
// The default value of flicker is 1, and it is stored in the misc field: https://github.com/Dwedit/PocketNES/blob/master/src/sram.c#L1044
//
// The default follow by value is 0, and the default follow sprite is 0. I don't see an obvious place where they're stored
const DEFAULT_SCALING = 3;
const DEFAULT_GAMMA = 0;
const DEFAULT_AUTOSLEEP_TIME = 0;
const DEFAULT_AUTOSTATE = 0;
const DEFAULT_FLICKER = 1;
const POCKETNES_CONFIG_DATA_DEFAULT_DISPLAY_TYPE = ((DEFAULT_GAMMA & 0x7) << 5) | (DEFAULT_SCALING & 0xF); // https://github.com/Dwedit/PocketNES/blob/master/src/sram.c#L1037
const POCKETNES_CONFIG_DATA_DEFAULT_MISC = (((DEFAULT_FLICKER & 0x1) ^ 1) << 4) | ((DEFAULT_AUTOSTATE & 0x3) << 5) | (DEFAULT_AUTOSLEEP_TIME & 0x3); // https://github.com/Dwedit/PocketNES/blob/master/src/sram.c#L1045
const GAME_TITLE = 'SAVE'; // No game title is listed in an NES ROM, so everything is just called this
export default class PocketNesEmulatorSaveData extends EmulatorBase {
static GAME_TITLE = GAME_TITLE;
static getMagic() {
return POCKETNES_MAGIC;
}
static getConfigDataLength() {
return POCKETNES_CONFIG_DATA_LENGTH;
}
static createFromRawData(rawArrayBuffer, romArrayBuffer) {
const romChecksum = PocketNesEmulatorSaveData.calculateRomChecksum(romArrayBuffer);
return super.createFromRawDataInternal(rawArrayBuffer, GAME_TITLE, romChecksum, PocketNesEmulatorSaveData);
}
static createFromRawDataInternal(rawArrayBuffer, romChecksum) { // es-lint-disable no-dupe-class-members (bug in eslint?)
return super.createFromRawDataInternal(rawArrayBuffer, GAME_TITLE, romChecksum, PocketNesEmulatorSaveData);
}
static createFromFlashCartData(pocketNesArrayBuffer) {
return new PocketNesEmulatorSaveData(pocketNesArrayBuffer);
}
static requiresRom() {
return {
clazz: NesRom,
requiredToConvert: ['convertToFormat'],
};
}
// Based on https://github.com/libertyernie/POCKETNESsav/blob/master/POCKETNESsav.h#L73
static createEmptyConfigDataArrayBuffer() {
const arrayBuffer = new ArrayBuffer(POCKETNES_CONFIG_DATA_LENGTH);
const dataView = new DataView(arrayBuffer);
const uint8Array = new Uint8Array(arrayBuffer);
const textEncoder = new TextEncoder(POCKETNES_CONFIG_DATA_RESERVED_ENCODING);
uint8Array.fill(0);
dataView.setUint16(POCKETNES_CONFIG_DATA_SIZE_OFFSET, POCKETNES_CONFIG_DATA_LENGTH, super.LITTLE_ENDIAN);
dataView.setUint16(POCKETNES_CONFIG_DATA_TYPE_OFFSET, super.TYPE_CONFIG_DATA, super.LITTLE_ENDIAN);
dataView.setUint8(POCKETNES_CONFIG_DATA_DISPLAY_TYPE_OFFSET, POCKETNES_CONFIG_DATA_DEFAULT_DISPLAY_TYPE);
dataView.setUint8(POCKETNES_CONFIG_DATA_MISC_OFFSET, POCKETNES_CONFIG_DATA_DEFAULT_MISC);
dataView.setUint32(POCKETNES_CONFIG_DATA_SRAM_ROM_CHECKSUM_OFFSET, 0, super.LITTLE_ENDIAN); // Checksum here gets set to 0 so that the file is "clean"
const encodedReservedData = textEncoder.encode(POCKETNES_CONFIG_DATA_RESERVED_DATA).slice(0, POCKETNES_CONFIG_DATA_RESERVED_LENGTH - 1);
uint8Array.set(encodedReservedData, POCKETNES_CONFIG_DATA_RESERVED_LENGTH);
return arrayBuffer;
}
static getPlatformSramRomChecksumFromConfigData(arrayBuffer, currentByte) {
const dataView = new DataView(arrayBuffer);
return dataView.getUint32(currentByte + POCKETNES_CONFIG_DATA_SRAM_ROM_CHECKSUM_OFFSET, super.LITTLE_ENDIAN);
}
static calculateRomChecksum(romArrayBuffer) {
// It's tricky to determine where the ROM "starts":
//
// When the emulator returns a pointer to the ROM, it includes the romheader struct: https://github.com/Dwedit/PocketNES/blob/master/src/rommenu.c#L281
// When the emulator calculates the checksum of the ROM, it moves past the romheader struct, plus 16 bytes (which is the length of the header in the ROM file): https://github.com/Dwedit/PocketNES/blob/master/src/sram.c#L495
//
// So here we will calculate the checksum of the file after the header
const nesRom = new NesRom(romArrayBuffer);
return super.calculateRomChecksum(nesRom.getRomArrayBufferWithoutHeader());
}
static concatEmulatorArrayBuffer(magicArrayBuffer, stateHeaderArrayBuffer, compressedSaveDataArrayBuffer, configDataArrayBuffer) {
// From the test files I've made, PocketNES appears to put the portions of the file in a different order than Goomba(Color),
// despite this not being reflected in GoombaSaveManager.
return Util.concatArrayBuffers([magicArrayBuffer, configDataArrayBuffer, stateHeaderArrayBuffer, compressedSaveDataArrayBuffer]);
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/GBA/SmsAdvanceEmulator.js
================================================
/* eslint-disable no-bitwise */
// Find it at https://archive.org/details/smsadvance-25-bin
import EmulatorBase from './EmulatorBase';
import SmsRom from '../../../rom-formats/sms';
import Util from '../../../util/util';
const SMSADVANCE_MAGIC = 0x57A731DC; // SMS Advance save
const SMSADVANCE_CONFIG_DATA_SIZE_OFFSET = 0;
const SMSADVANCE_CONFIG_DATA_TYPE_OFFSET = 2;
const SMSADVANCE_CONFIG_DATA_DISPLAY_TYPE_OFFSET = 4;
const SMSADVANCE_CONFIG_DATA_GAMMA_VALUE_OFFSET = 5;
const SMSADVANCE_CONFIG_DATA_REGION_OFFSET = 6;
const SMSADVANCE_CONFIG_DATA_SLEEP_FLICK_OFFSET = 7;
const SMSADVANCE_CONFIG_DATA_CONFIG_OFFSET = 8;
const SMSADVANCE_CONFIG_DATA_BORDER_COLOR_OFFSET = 9;
const SMSADVANCE_CONFIG_DATA_SRAM_ROM_CHECKSUM_OFFSET = 12;
const SMSADVANCE_CONFIG_DATA_RESERVED_OFFSET = 20;
const SMSADVANCE_CONFIG_DATA_RESERVED_LENGTH = 32;
const SMSADVANCE_CONFIG_DATA_RESERVED_DATA = 'CFG';
const SMSADVANCE_CONFIG_DATA_RESERVED_ENCODING = 'US-ASCII';
const SMSADVANCE_CONFIG_DATA_LENGTH = SMSADVANCE_CONFIG_DATA_RESERVED_OFFSET + SMSADVANCE_CONFIG_DATA_RESERVED_LENGTH;
// These all read from sample save files
const SMSADVANCE_CONFIG_DATA_DEFAULT_DISPLAY_TYPE = 3;
const SMSADVANCE_CONFIG_DATA_DEFAULT_GAMMA_VALUE = 2;
const SMSADVANCE_CONFIG_DATA_DEFAULT_REGION = 0;
const SMSADVANCE_CONFIG_DATA_DEFAULT_SLEEP_FLICK = 0;
const SMSADVANCE_CONFIG_DATA_DEFAULT_CONFIG = 0x40;
const SMSADVANCE_CONFIG_DATA_DEFAULT_BORDER_COLOR = 0;
const GAME_TITLE = 'Made with savefileconverter.com'; // No game title is listed in an SMS ROM. The emulator appears to insert a filename of the ROM here, either when running in standalone mode or when integrated into the GBA OS
export default class SmsAdvanceEmulatorSaveData extends EmulatorBase {
static GAME_TITLE = GAME_TITLE;
static getMagic() {
return SMSADVANCE_MAGIC;
}
static getConfigDataLength() {
return SMSADVANCE_CONFIG_DATA_LENGTH;
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return 'srm';
}
static createFromRawData(rawArrayBuffer, romArrayBuffer) {
const romChecksum = super.calculateRomChecksum(romArrayBuffer);
return super.createFromRawDataInternal(rawArrayBuffer, GAME_TITLE, romChecksum, SmsAdvanceEmulatorSaveData);
}
static createFromRawDataInternal(rawArrayBuffer, romChecksum) { // es-lint-disable no-dupe-class-members (bug in eslint?)
return super.createFromRawDataInternal(rawArrayBuffer, GAME_TITLE, romChecksum, SmsAdvanceEmulatorSaveData);
}
static createFromFlashCartData(flashCartArrayBuffer) {
return new SmsAdvanceEmulatorSaveData(flashCartArrayBuffer);
}
static requiresRom() {
return {
clazz: SmsRom,
requiredToConvert: ['convertToFormat'],
};
}
// Based on https://github.com/libertyernie/goombasav/blob/master/goombasav.h#L85
static createEmptyConfigDataArrayBuffer() {
const arrayBuffer = new ArrayBuffer(SMSADVANCE_CONFIG_DATA_LENGTH);
const dataView = new DataView(arrayBuffer);
const uint8Array = new Uint8Array(arrayBuffer);
const textEncoder = new TextEncoder(SMSADVANCE_CONFIG_DATA_RESERVED_ENCODING);
uint8Array.fill(0);
dataView.setUint16(SMSADVANCE_CONFIG_DATA_SIZE_OFFSET, SMSADVANCE_CONFIG_DATA_LENGTH, super.LITTLE_ENDIAN);
dataView.setUint16(SMSADVANCE_CONFIG_DATA_TYPE_OFFSET, super.TYPE_CONFIG_DATA, super.LITTLE_ENDIAN);
dataView.setUint8(SMSADVANCE_CONFIG_DATA_DISPLAY_TYPE_OFFSET, SMSADVANCE_CONFIG_DATA_DEFAULT_DISPLAY_TYPE);
dataView.setUint8(SMSADVANCE_CONFIG_DATA_GAMMA_VALUE_OFFSET, SMSADVANCE_CONFIG_DATA_DEFAULT_GAMMA_VALUE);
dataView.setUint8(SMSADVANCE_CONFIG_DATA_REGION_OFFSET, SMSADVANCE_CONFIG_DATA_DEFAULT_REGION);
dataView.setUint8(SMSADVANCE_CONFIG_DATA_SLEEP_FLICK_OFFSET, SMSADVANCE_CONFIG_DATA_DEFAULT_SLEEP_FLICK);
dataView.setUint8(SMSADVANCE_CONFIG_DATA_CONFIG_OFFSET, SMSADVANCE_CONFIG_DATA_DEFAULT_CONFIG);
dataView.setUint8(SMSADVANCE_CONFIG_DATA_BORDER_COLOR_OFFSET, SMSADVANCE_CONFIG_DATA_DEFAULT_BORDER_COLOR);
dataView.setUint32(SMSADVANCE_CONFIG_DATA_SRAM_ROM_CHECKSUM_OFFSET, 0, super.LITTLE_ENDIAN); // Checksum here gets set to 0 so that the file is "clean"
const encodedReservedData = textEncoder.encode(SMSADVANCE_CONFIG_DATA_RESERVED_DATA).slice(0, SMSADVANCE_CONFIG_DATA_RESERVED_LENGTH - 1);
uint8Array.set(encodedReservedData, SMSADVANCE_CONFIG_DATA_RESERVED_LENGTH);
return arrayBuffer;
}
static getPlatformSramRomChecksumFromConfigData(arrayBuffer, currentByte) {
const dataView = new DataView(arrayBuffer);
return dataView.getUint32(currentByte + SMSADVANCE_CONFIG_DATA_SRAM_ROM_CHECKSUM_OFFSET, super.LITTLE_ENDIAN);
}
static concatEmulatorArrayBuffer(magicArrayBuffer, stateHeaderArrayBuffer, compressedSaveDataArrayBuffer, configDataArrayBuffer) {
// From the test files I've made, when in standalone mode running ROMs directly from the GBA OS, the sections are arranged like this:
// When running in bundled mode,where you create a .gba file on your PC that has all the ROMs bundled into it, it has the confi data after the magic
return Util.concatArrayBuffers([magicArrayBuffer, stateHeaderArrayBuffer, compressedSaveDataArrayBuffer, configDataArrayBuffer]);
}
getUncompressedSize() {
return this.rawArrayBuffer.byteLength; // Unlike PocketNES and Goomba, SMSAdvance stores the compressed size in the state header rather than the uncompressed size. So override this to return the correct number
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/GameGear.js
================================================
import SaveFilesUtil from '../../util/SaveFiles';
export default class GameGearFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
return new GameGearFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new GameGearFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(flashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(flashCartSaveData.getRawArrayBuffer(), newSize);
return GameGearFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return null; // GG saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'gamegear';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaEverdrivePro/32X.js
================================================
import GenesisMegaEverdriveProGenesisFlashCartSaveData from './Genesis';
export default class GenesisMegaEverdrivePro32xFlashCartSaveData extends GenesisMegaEverdriveProGenesisFlashCartSaveData {
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaEverdrivePro/Genesis.js
================================================
import SaveFilesUtil from '../../../../util/SaveFiles';
import GenesisUtil from '../../../../util/Genesis';
const FILL_BYTE = 0x00; // Mega Everdrive Pro files are byte expanded with a fill byte of 0, just like emulators
export default class GenesisMegaEverdriveProGenesisFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
// Here we know that the input data comes from an everdrive, and so it's byte expanded with 0's
return new GenesisMegaEverdriveProGenesisFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
// Collapse then re-expand the data to account for cases where we're passed a Retrode-style file (with repeated bytes),
// or a Mega SD-style file (filled with 0xFF instead), and then re-fill it with 0x00 as the Mega Everdrive Pro expects
const flashCartArrayBuffer = GenesisUtil.changeFillByte(rawArrayBuffer, FILL_BYTE);
return new GenesisMegaEverdriveProGenesisFlashCartSaveData(flashCartArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(flashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(flashCartSaveData.getRawArrayBuffer(), newSize);
return GenesisMegaEverdriveProGenesisFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return null; // Genesis saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'genesis';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaEverdrivePro/NES.js
================================================
import SaveFilesUtil from '../../../../util/SaveFiles';
export default class GenesisMegaEverdriveProNesFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
return new GenesisMegaEverdriveProNesFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new GenesisMegaEverdriveProNesFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(flashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(flashCartSaveData.getRawArrayBuffer(), newSize);
return GenesisMegaEverdriveProNesFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return null; // NES saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'nes';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaEverdrivePro/SMS.js
================================================
import SaveFilesUtil from '../../../../util/SaveFiles';
import GenesisUtil from '../../../../util/Genesis';
// Some knockoff everdrives produce byte-expanded files for SMS games even though the regular everdrives don't.
// So let's just silently unexpand them when converting to raw
//
// This doesn't seem to be a major issue (only 1 report in a year) so let's not clutter the interface by offering the option
// to expand the files when converting to flash cart format. It seems much more likely that someone would be converting their
// saves *from* a knockoff cart than *to* a knockoff cart
//
// If someone does need to convert their saves to a cart that expects them byte-expanded, they can use https://savefileconverter.com/#/utilities/advanced?tab=byte-expansion
export default class GenesisMegaEverdriveProSmsFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
let rawArrayBuffer = flashCartArrayBuffer;
if (GenesisUtil.isByteExpanded(flashCartArrayBuffer) && !GenesisUtil.isEmpty(flashCartArrayBuffer)) {
rawArrayBuffer = GenesisUtil.byteCollapse(flashCartArrayBuffer);
}
return new GenesisMegaEverdriveProSmsFlashCartSaveData(flashCartArrayBuffer, rawArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new GenesisMegaEverdriveProSmsFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(flashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(flashCartSaveData.getRawArrayBuffer(), newSize);
return GenesisMegaEverdriveProSmsFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return null; // SMS saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'sms';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaEverdrivePro/SegaCd.js
================================================
import SegaCdUtil from '../../../../util/SegaCd';
export default class GenesisMegaEverdriveProSegaCdFlashCartSaveData {
static INTERNAL_MEMORY = 'internal-memory';
static RAM_CART = 'ram-cart';
static FLASH_CART_RAM_CART_SIZE = 131072; // The Mega Everdrive Pro produces RAM cart files of this size as of firmware v24.1129
static EMULATOR_RAM_CART_SIZE = 524288; // The emulators I've seen produce a RAM cart file of this size (note that the output size is changeable by the user)
static createFromFlashCartData({ flashCartInternalSaveArrayBuffer = null, flashCartRamCartSaveArrayBuffer = null }) {
let truncatedFlashCartInternalSaveBuffer = SegaCdUtil.makeEmptySave(SegaCdUtil.INTERNAL_SAVE_SIZE);
let truncatedFlashCartRamCartSaveArrayBuffer = SegaCdUtil.makeEmptySave(GenesisMegaEverdriveProSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE);
let rawRamCartSaveArrayBuffer = SegaCdUtil.makeEmptySave(GenesisMegaEverdriveProSegaCdFlashCartSaveData.EMULATOR_RAM_CART_SIZE);
if (flashCartInternalSaveArrayBuffer !== null) {
truncatedFlashCartInternalSaveBuffer = SegaCdUtil.truncateToActualSize(flashCartInternalSaveArrayBuffer);
if (truncatedFlashCartInternalSaveBuffer.byteLength !== SegaCdUtil.INTERNAL_SAVE_SIZE) {
throw new Error(`Internal save RAM is not the correct size. Must be ${SegaCdUtil.INTERNAL_SAVE_SIZE} bytes`);
}
}
if (flashCartRamCartSaveArrayBuffer !== null) {
truncatedFlashCartRamCartSaveArrayBuffer = SegaCdUtil.truncateToActualSize(flashCartRamCartSaveArrayBuffer);
rawRamCartSaveArrayBuffer = SegaCdUtil.resize(truncatedFlashCartRamCartSaveArrayBuffer, GenesisMegaEverdriveProSegaCdFlashCartSaveData.EMULATOR_RAM_CART_SIZE);
}
return new GenesisMegaEverdriveProSegaCdFlashCartSaveData(
truncatedFlashCartInternalSaveBuffer,
truncatedFlashCartRamCartSaveArrayBuffer,
truncatedFlashCartInternalSaveBuffer,
rawRamCartSaveArrayBuffer,
);
}
static createFromRawData({ rawInternalSaveArrayBuffer = null, rawRamCartSaveArrayBuffer = null }) {
let truncatedRawInternalSaveBuffer = SegaCdUtil.makeEmptySave(SegaCdUtil.INTERNAL_SAVE_SIZE);
let truncatedRawRamCartSaveArrayBuffer = SegaCdUtil.makeEmptySave(GenesisMegaEverdriveProSegaCdFlashCartSaveData.EMULATOR_RAM_CART_SIZE);
let flashCartRamCartSaveArrayBuffer = SegaCdUtil.makeEmptySave(GenesisMegaEverdriveProSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE);
if (rawInternalSaveArrayBuffer !== null) {
truncatedRawInternalSaveBuffer = SegaCdUtil.truncateToActualSize(rawInternalSaveArrayBuffer);
if (truncatedRawInternalSaveBuffer.byteLength !== SegaCdUtil.INTERNAL_SAVE_SIZE) {
throw new Error(`Internal save RAM is not the correct size. Must be ${SegaCdUtil.INTERNAL_SAVE_SIZE} bytes`);
}
}
if (rawRamCartSaveArrayBuffer !== null) {
truncatedRawRamCartSaveArrayBuffer = SegaCdUtil.truncateToActualSize(rawRamCartSaveArrayBuffer);
flashCartRamCartSaveArrayBuffer = SegaCdUtil.resize(truncatedRawRamCartSaveArrayBuffer, GenesisMegaEverdriveProSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE);
}
return new GenesisMegaEverdriveProSegaCdFlashCartSaveData(
truncatedRawInternalSaveBuffer,
flashCartRamCartSaveArrayBuffer,
truncatedRawInternalSaveBuffer,
truncatedRawRamCartSaveArrayBuffer,
);
}
static createWithNewSize(flashCartSaveData, newSize) {
const newRawRamCartArrayBuffer = SegaCdUtil.resize(flashCartSaveData.rawRamCartSaveArrayBuffer, newSize);
return new GenesisMegaEverdriveProSegaCdFlashCartSaveData(
flashCartSaveData.flashCartInternalSaveArrayBuffer,
flashCartSaveData.flashCartRamCartSaveArrayBuffer,
flashCartSaveData.rawInternalSaveArrayBuffer,
newRawRamCartArrayBuffer,
);
}
static getFlashCartFileExtension() {
return null; // See getFlashCartFileName()
}
static getFlashCartFileName(index = GenesisMegaEverdriveProSegaCdFlashCartSaveData.INTERNAL_MEMORY) {
switch (index) {
case GenesisMegaEverdriveProSegaCdFlashCartSaveData.INTERNAL_MEMORY:
return 'cd-bram.brm';
case GenesisMegaEverdriveProSegaCdFlashCartSaveData.RAM_CART:
return 'cd-cart.srm';
default:
throw new Error(`Unknown index: ${index}`);
}
}
static getRawFileExtension() {
return 'brm';
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'segacd';
}
static getRawDefaultRamCartSize() {
return GenesisMegaEverdriveProSegaCdFlashCartSaveData.EMULATOR_RAM_CART_SIZE;
}
static getFlashCartDefaultRamCartSize() {
return GenesisMegaEverdriveProSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE;
}
constructor(flashCartInternalSaveArrayBuffer, flashCartRamCartSaveArrayBuffer, rawInternalSaveArrayBuffer, rawRamCartSaveArrayBuffer) {
this.flashCartInternalSaveArrayBuffer = flashCartInternalSaveArrayBuffer;
this.flashCartRamCartSaveArrayBuffer = flashCartRamCartSaveArrayBuffer;
this.rawInternalSaveArrayBuffer = rawInternalSaveArrayBuffer;
this.rawRamCartSaveArrayBuffer = rawRamCartSaveArrayBuffer;
}
getRawArrayBuffer(index = GenesisMegaEverdriveProSegaCdFlashCartSaveData.INTERNAL_MEMORY) {
switch (index) {
case GenesisMegaEverdriveProSegaCdFlashCartSaveData.INTERNAL_MEMORY:
return this.rawInternalSaveArrayBuffer;
case GenesisMegaEverdriveProSegaCdFlashCartSaveData.RAM_CART:
return this.rawRamCartSaveArrayBuffer;
default:
throw new Error(`Unknown index: ${index}`);
}
}
getFlashCartArrayBuffer(index = GenesisMegaEverdriveProSegaCdFlashCartSaveData.INTERNAL_MEMORY) {
switch (index) {
case GenesisMegaEverdriveProSegaCdFlashCartSaveData.INTERNAL_MEMORY:
return this.flashCartInternalSaveArrayBuffer;
case GenesisMegaEverdriveProSegaCdFlashCartSaveData.RAM_CART:
return this.flashCartRamCartSaveArrayBuffer;
default:
throw new Error(`Unknown index: ${index}`);
}
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaSD/32X.js
================================================
import GenesisBase from './GenesisBase';
const MEGA_SD_NEW_STYLE_PADDING_BYTE_SRAM = 0x00; // Smaller sample size with the 32X files I was given, but all of them were padded with 0x00
const MEGA_SD_NEW_STYLE_PADDING_BYTE_EEPROM = 0x00;
const PLATFORM_INFO = {
padding: {
sram: MEGA_SD_NEW_STYLE_PADDING_BYTE_SRAM,
eeprom: MEGA_SD_NEW_STYLE_PADDING_BYTE_EEPROM,
},
};
export default class GenesisMegaSd32xFlashCartSaveData extends GenesisBase {
static createFromRawData(rawArrayBuffer) {
return super.createFromRawData(rawArrayBuffer, PLATFORM_INFO);
}
static createFromFlashCartData(flashCartArrayBuffer) {
return super.createFromFlashCartData(flashCartArrayBuffer);
}
static createWithNewSize(flashCartSaveData, newSize) {
return super.createWithNewSize(flashCartSaveData, newSize, PLATFORM_INFO);
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaSD/Genesis.js
================================================
import GenesisBase from './GenesisBase';
const MEGA_SD_NEW_STYLE_PADDING_BYTE_SRAM = 0x00; // 3/4 of the new style files I was given were padded with 0x00 but one was 0xFF
const MEGA_SD_NEW_STYLE_PADDING_BYTE_EEPROM = 0xFF; // The example new style eeprom save was padded with 0xFF
const PLATFORM_INFO = {
padding: {
sram: MEGA_SD_NEW_STYLE_PADDING_BYTE_SRAM,
eeprom: MEGA_SD_NEW_STYLE_PADDING_BYTE_EEPROM,
},
};
export default class GenesisMegaSdGenesisFlashCartSaveData extends GenesisBase {
static createFromRawData(rawArrayBuffer) {
return super.createFromRawData(rawArrayBuffer, PLATFORM_INFO);
}
static createFromFlashCartData(flashCartArrayBuffer) {
return super.createFromFlashCartData(flashCartArrayBuffer);
}
static createWithNewSize(flashCartSaveData, newSize) {
return super.createWithNewSize(flashCartSaveData, newSize, PLATFORM_INFO);
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaSD/GenesisBase.js
================================================
// There are two "styles" of save files that can be generated by a Mega SD:
// - The "old style" (from earlier firmware, it seems) which is byte expanded with 0xFF
// - The "new style" (from later firmware) which is not byte expanded and includes a "BUP2" prepended to the bgeinning
//
// I'm not sure which firmware revision changed this, nor do I know whether a Mega SD can read both types of files with the most recent firmware
//
// Here we will accept either type of file as input, and always output a "new style" file. This will allow people to use their
// old saves hopefully without making things complicated for the user.
import SaveFilesUtil from '../../../../util/SaveFiles';
import GenesisUtil from '../../../../util/Genesis';
import MathUtil from '../../../../util/Math';
import PaddingUtil from '../../../../util/Padding';
import Util from '../../../../util/util';
const MAGIC = 'BUP2';
const MAGIC_OFFSET = 0;
const MAGIC_ENCODING = 'US-ASCII';
const MEGA_SD_NEW_STYLE_PADDED_SIZE = 32768; // Both SRAM and EEPROM saves appear to always be padded out to this size
// const MEGA_SD_OLD_STYLE_FILL_BYTE = 0xFF; // "Old style" Mega SD files are byte expanded with a fill byte of 0xFF
const RAW_FILL_BYTE = 0x00;
const RAW_EEPROM_MIN_SIZE = 128; // Most EEPROM files we see (Wii VC, Everdrive) are this size for Wonder Boy in Monster World, even though the Mega SD only writes out 64 bytes (and GenesisPlus loads that fine)
const RAW_SRAM_MIN_SIZE = 16384; // 8kB, byte expanded
// When converting from raw, I'm concerned about removing padding then checking the resulting file size
// to see if it's an EEPROM save because ome games if saved early might not have much data, and so we might get an incorrect result.
function isNewStyleSave(flashCartArrayBuffer) {
try {
Util.checkMagic(flashCartArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
} catch (e) {
return false;
}
return ((flashCartArrayBuffer.byteLength > MAGIC.length) && MathUtil.isPowerOf2(flashCartArrayBuffer.byteLength - MAGIC.length));
}
function isOldStyleSave(flashCartArrayBuffer) {
// FIXME: The EEPROM part is a complete guess: we don't know how an EEPROM save in the "old sty;e" looks, since there weren't any in our set of sample files.
// See also convertFromOldStyleToRaw()
return ((GenesisUtil.isEepromSave(flashCartArrayBuffer) || GenesisUtil.isByteExpanded(flashCartArrayBuffer)) && MathUtil.isPowerOf2(flashCartArrayBuffer.byteLength));
}
function isRawSave(rawArrayBuffer) {
return ((GenesisUtil.isEepromSave(rawArrayBuffer) || GenesisUtil.isByteExpanded(rawArrayBuffer)) && MathUtil.isPowerOf2(rawArrayBuffer.byteLength));
}
function convertFromOldStyleToRaw(flashCartArrayBuffer) {
// FIXME: This is a complete guess: we don't know how an EEPROM save in the "old sty;e" looks, since there weren't any in our set of sample files.
// See also isOldStyleSave()
if (GenesisUtil.isEepromSave(flashCartArrayBuffer)) {
return PaddingUtil.padAtEndToMinimumSize(flashCartArrayBuffer, RAW_FILL_BYTE, RAW_EEPROM_MIN_SIZE); // EEPROM saves don't get byte expanded
}
const padding = PaddingUtil.getPadFromEndValueAndCount(flashCartArrayBuffer);
const unpaddedArrayBuffer = PaddingUtil.removePaddingFromEnd(flashCartArrayBuffer, padding.count);
return GenesisUtil.changeFillByte(unpaddedArrayBuffer, RAW_FILL_BYTE);
}
function convertFromNewStyleToRaw(flashCartArrayBuffer) {
// First, check if we're an EEPROM save. These have the magic on the front, and are padded out.
// For whatever reason, we've observed files in the new style padded with both 0xFF and 0x00.
const collapsedArrayBuffer = flashCartArrayBuffer.slice(MAGIC.length);
const padding = PaddingUtil.getPadFromEndValueAndCount(collapsedArrayBuffer);
const collapsedUnpaddedArrayBuffer = PaddingUtil.removePaddingFromEnd(collapsedArrayBuffer, padding.count);
if (GenesisUtil.isEepromSave(collapsedUnpaddedArrayBuffer)) {
return PaddingUtil.padAtEndToMinimumSize(collapsedUnpaddedArrayBuffer, RAW_FILL_BYTE, RAW_EEPROM_MIN_SIZE); // EEPROM saves don't get byte expanded
}
return PaddingUtil.padAtEndToMinimumSize(GenesisUtil.byteExpand(collapsedUnpaddedArrayBuffer, RAW_FILL_BYTE), RAW_FILL_BYTE, RAW_SRAM_MIN_SIZE);
}
function convertFromRawToNewStyle(rawArrayBuffer, platformInfo) {
// Remember that we may be given data in the Retrode style, with repeated bytes, or in the
// Mega Everdrive Pro/emulator-style file (filled with 0x00 instead)
const textEncoder = new TextEncoder(MAGIC_ENCODING);
const magicArrayBuffer = Util.bufferToArrayBuffer(textEncoder.encode(MAGIC));
const padding = PaddingUtil.getPadFromEndValueAndCount(rawArrayBuffer);
let unpaddedArrayBuffer = PaddingUtil.removePaddingFromEnd(rawArrayBuffer, padding.count);
let paddingByte = platformInfo.padding.eeprom;
if (!GenesisUtil.isEepromSave(unpaddedArrayBuffer)) {
unpaddedArrayBuffer = GenesisUtil.byteCollapse(rawArrayBuffer);
paddingByte = platformInfo.padding.sram;
}
return Util.concatArrayBuffers([magicArrayBuffer, PaddingUtil.padAtEndToMinimumSize(unpaddedArrayBuffer, paddingByte, MEGA_SD_NEW_STYLE_PADDED_SIZE)]);
}
export default class GenesisMegaSdGenesisFlashCartSaveData {
static NEW_STYLE_MAGIC = MAGIC;
static isRawSave(rawArrayBuffer) {
return isRawSave(rawArrayBuffer);
}
static convertFromRawToNewStyle(rawArrayBuffer, paddingByteSram, paddingByteEeprom) {
return convertFromRawToNewStyle(rawArrayBuffer, paddingByteSram, paddingByteEeprom);
}
static createFromFlashCartData(flashCartArrayBuffer) {
if (isNewStyleSave(flashCartArrayBuffer)) {
return new GenesisMegaSdGenesisFlashCartSaveData(flashCartArrayBuffer, convertFromNewStyleToRaw(flashCartArrayBuffer));
}
if (isOldStyleSave(flashCartArrayBuffer)) {
return new GenesisMegaSdGenesisFlashCartSaveData(flashCartArrayBuffer, convertFromOldStyleToRaw(flashCartArrayBuffer));
}
throw new Error('This does not appear to be a Mega SD Genesis save file');
}
static createFromRawData(rawArrayBuffer, platformInfo) {
if (isRawSave(rawArrayBuffer, platformInfo)) {
const flashCartArrayBuffer = convertFromRawToNewStyle(rawArrayBuffer, platformInfo);
return new GenesisMegaSdGenesisFlashCartSaveData(flashCartArrayBuffer, rawArrayBuffer);
}
throw new Error('This does not appear to be a raw Genesis save file');
}
static createWithNewSize(flashCartSaveData, newSize, platformInfo) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(flashCartSaveData.getRawArrayBuffer(), newSize); // Note that we're resizing the raw save here, which has a fill byte of 0x00, so it's okay to pad with zeros via this function
return GenesisMegaSdGenesisFlashCartSaveData.createFromRawData(newRawSaveData, platformInfo);
}
static getFlashCartFileExtension() {
return 'SRM';
}
static getRawFileExtension() {
return null;
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return null; // We pad out our files, so this doesn't make much sense here
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaSD/SMS.js
================================================
// SMS files on the Mega SD appear to just have "BUP2" prepended to the start of the file.
// This is the same as the "new style" (i.e. recent firmware) of Genesis saves on the device.
// I'm not sure if there's an "old style" for SMS saves on the Mega SD.
//
// We're going to make this also be able to load raw saves in case that was the "old style" (if there was even an "old style"!)
import SaveFilesUtil from '../../../../util/SaveFiles';
import MathUtil from '../../../../util/Math';
import Util from '../../../../util/util';
const MAGIC = 'BUP2';
const MAGIC_OFFSET = 0;
const MAGIC_ENCODING = 'US-ASCII';
function isNewStyleSave(flashCartArrayBuffer) {
try {
Util.checkMagic(flashCartArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
} catch (e) {
return false;
}
return ((flashCartArrayBuffer.byteLength > MAGIC.length) && MathUtil.isPowerOf2(flashCartArrayBuffer.byteLength - MAGIC.length));
}
function isOldStyleSave(flashCartArrayBuffer) {
return MathUtil.isPowerOf2(flashCartArrayBuffer.byteLength);
}
function convertFromNewStyleToRaw(flashCartArrayBuffer) {
return flashCartArrayBuffer.slice(MAGIC.length);
}
function convertFromRawToNewStyle(rawArrayBuffer) {
const textEncoder = new TextEncoder(MAGIC_ENCODING);
const magicArrayBuffer = Util.bufferToArrayBuffer(textEncoder.encode(MAGIC));
return Util.concatArrayBuffers([magicArrayBuffer, rawArrayBuffer]);
}
export default class GenesisMegaSdSmsFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
if (isNewStyleSave(flashCartArrayBuffer)) {
return new GenesisMegaSdSmsFlashCartSaveData(flashCartArrayBuffer, convertFromNewStyleToRaw(flashCartArrayBuffer));
}
if (isOldStyleSave(flashCartArrayBuffer)) {
const rawArrayBuffer = flashCartArrayBuffer; // In the "old sty;e", the flash cart data is just raw data
return new GenesisMegaSdSmsFlashCartSaveData(convertFromRawToNewStyle(rawArrayBuffer), rawArrayBuffer);
}
throw new Error('This does not appear to be a Mega SD Sega Master System save file');
}
static createFromRawData(rawArrayBuffer) {
return new GenesisMegaSdSmsFlashCartSaveData(convertFromRawToNewStyle(rawArrayBuffer), rawArrayBuffer);
}
static createWithNewSize(flashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(flashCartSaveData.getRawArrayBuffer(), newSize);
return GenesisMegaSdSmsFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return 'SRM';
}
static getRawFileExtension() {
return null; // SMS saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'sms';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/Genesis/MegaSD/SegaCd.js
================================================
// The MegaSD produces a single file for Sega CD that's a concatenation of:
//
// - Magic
// - Internal memory
// - RAM cart
//
// I don't have an example of a file that only has the internal memory, so I don't know if the file is truncated
// (i.e. it's just magic + internal memory) or if it also includes an empty RAM cart section. The only one I've seen has a blank section
// at the end that's as long as the RAM cart section would be (but is all 0's)
//
// So we're going to make this code able to parse a file that's truncated but only output files that have both sections, just to be safe.
import SegaCdUtil from '../../../../util/SegaCd';
import Util from '../../../../util/util';
const MAGIC = 'BUP3';
const MAGIC_OFFSET = 0;
const MAGIC_ENCODING = 'US-ASCII';
const INTERNAL_MEMORY_OFFSET = 4;
const RAM_CART_OFFSET = INTERNAL_MEMORY_OFFSET + SegaCdUtil.INTERNAL_SAVE_SIZE;
export default class GenesisMegaSdSegaCdFlashCartSaveData {
static INTERNAL_MEMORY = 'internal-memory';
static RAM_CART = 'ram-cart';
static FLASH_CART_RAM_CART_SIZE = 32768; // The RAM cart portion of a Mega SD file is this size
static EMULATOR_RAM_CART_SIZE = 524288; // The emulators I've seen produce a RAM cart file of this size (note that the output size is changeable by the user)
static createFromFlashCartData(flashCartArrayBuffer) {
Util.checkMagic(flashCartArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const isShortVersion = (flashCartArrayBuffer.byteLength === (INTERNAL_MEMORY_OFFSET + SegaCdUtil.INTERNAL_SAVE_SIZE));
const isLongVersion = (flashCartArrayBuffer.byteLength === (INTERNAL_MEMORY_OFFSET + SegaCdUtil.INTERNAL_SAVE_SIZE + GenesisMegaSdSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE));
if (!isShortVersion && !isLongVersion) {
throw new Error('This file does not appear to be a Mega SD Sega CD save file');
}
const flashCartInternalSaveArrayBuffer = flashCartArrayBuffer.slice(INTERNAL_MEMORY_OFFSET, INTERNAL_MEMORY_OFFSET + SegaCdUtil.INTERNAL_SAVE_SIZE);
let flashCartRamCartSaveArrayBuffer = SegaCdUtil.makeEmptySave(GenesisMegaSdSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE);
if (isLongVersion) {
const potentialRamCartArrayBuffer = flashCartArrayBuffer.slice(RAM_CART_OFFSET, RAM_CART_OFFSET + GenesisMegaSdSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE);
// Ignore the RAM cart portion if it's incorrectly formatted. This can happen with a file that's only the
// internal memory: the RAM cart portion is all 0's
if (SegaCdUtil.isCorrectlyFormatted(potentialRamCartArrayBuffer)) {
// Consider adding a check here to see if potentialRamCartArrayBuffer is indeed all 0's
flashCartRamCartSaveArrayBuffer = potentialRamCartArrayBuffer;
}
}
const truncatedFlashCartInternalSaveBuffer = SegaCdUtil.truncateToActualSize(flashCartInternalSaveArrayBuffer);
if (truncatedFlashCartInternalSaveBuffer.byteLength !== SegaCdUtil.INTERNAL_SAVE_SIZE) {
throw new Error(`Internal save RAM is not the correct size. Must be ${SegaCdUtil.INTERNAL_SAVE_SIZE} bytes`);
}
const truncatedFlashCartRamCartSaveArrayBuffer = SegaCdUtil.truncateToActualSize(flashCartRamCartSaveArrayBuffer);
const rawRamCartSaveArrayBuffer = SegaCdUtil.resize(truncatedFlashCartRamCartSaveArrayBuffer, GenesisMegaSdSegaCdFlashCartSaveData.EMULATOR_RAM_CART_SIZE);
return new GenesisMegaSdSegaCdFlashCartSaveData(
flashCartArrayBuffer,
truncatedFlashCartInternalSaveBuffer,
rawRamCartSaveArrayBuffer,
);
}
static createFromRawData({ rawInternalSaveArrayBuffer = null, rawRamCartSaveArrayBuffer = null }) {
const textEncoder = new TextEncoder(MAGIC_ENCODING);
const magicArrayBuffer = Util.bufferToArrayBuffer(textEncoder.encode(MAGIC));
let truncatedRawInternalSaveBuffer = SegaCdUtil.makeEmptySave(SegaCdUtil.INTERNAL_SAVE_SIZE);
let truncatedRawRamCartSaveArrayBuffer = SegaCdUtil.makeEmptySave(GenesisMegaSdSegaCdFlashCartSaveData.EMULATOR_RAM_CART_SIZE);
let flashCartRamCartSaveArrayBuffer = SegaCdUtil.makeEmptySave(GenesisMegaSdSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE);
if (rawInternalSaveArrayBuffer !== null) {
truncatedRawInternalSaveBuffer = SegaCdUtil.truncateToActualSize(rawInternalSaveArrayBuffer);
if (truncatedRawInternalSaveBuffer.byteLength !== SegaCdUtil.INTERNAL_SAVE_SIZE) {
throw new Error(`Internal save RAM is not the correct size. Must be ${SegaCdUtil.INTERNAL_SAVE_SIZE} bytes`);
}
}
if (rawRamCartSaveArrayBuffer !== null) {
truncatedRawRamCartSaveArrayBuffer = SegaCdUtil.truncateToActualSize(rawRamCartSaveArrayBuffer);
flashCartRamCartSaveArrayBuffer = SegaCdUtil.resize(truncatedRawRamCartSaveArrayBuffer, GenesisMegaSdSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE);
}
const flashCartArrayBuffer = Util.concatArrayBuffers([magicArrayBuffer, truncatedRawInternalSaveBuffer, flashCartRamCartSaveArrayBuffer]);
return new GenesisMegaSdSegaCdFlashCartSaveData(
flashCartArrayBuffer,
truncatedRawInternalSaveBuffer,
truncatedRawRamCartSaveArrayBuffer,
);
}
static createWithNewSize(flashCartSaveData, newSize) {
const newRawRamCartArrayBuffer = SegaCdUtil.resize(flashCartSaveData.rawRamCartSaveArrayBuffer, newSize);
return new GenesisMegaSdSegaCdFlashCartSaveData(
flashCartSaveData.flashCartArrayBuffer,
flashCartSaveData.rawInternalSaveArrayBuffer,
newRawRamCartArrayBuffer,
);
}
static getFlashCartFileExtension() {
return 'SRM';
}
static getRawFileExtension() {
return 'brm';
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'segacd';
}
static getRawDefaultRamCartSize() {
return GenesisMegaSdSegaCdFlashCartSaveData.EMULATOR_RAM_CART_SIZE;
}
static getFlashCartDefaultRamCartSize() {
return GenesisMegaSdSegaCdFlashCartSaveData.FLASH_CART_RAM_CART_SIZE;
}
constructor(flashCartArrayBuffer, rawInternalSaveArrayBuffer, rawRamCartSaveArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawInternalSaveArrayBuffer = rawInternalSaveArrayBuffer;
this.rawRamCartSaveArrayBuffer = rawRamCartSaveArrayBuffer;
}
getRawArrayBuffer(index = GenesisMegaSdSegaCdFlashCartSaveData.INTERNAL_MEMORY) {
switch (index) {
case GenesisMegaSdSegaCdFlashCartSaveData.INTERNAL_MEMORY:
return this.rawInternalSaveArrayBuffer;
case GenesisMegaSdSegaCdFlashCartSaveData.RAM_CART:
return this.rawRamCartSaveArrayBuffer;
default:
throw new Error(`Unknown index: ${index}`);
}
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/N64/GB64Emulator.js
================================================
/* eslint-disable no-bitwise */
/*
GB64 is a GB/GBC emulator that runs on the N64
The save format has a header which is described here: https://github.com/lambertjamesd/gb64/blob/master/src/gameboy.h#L112
struct GameboySettings
{
// Always has the value 0x47423634 (GB64 as an ascii string)
0: u32 header;
// Used to check save file compatibility
4: u32 version;
8: u16 flags;
// color palette to use for non color games
10: u16 bgpIndex;
12: u16 obp0Index;
14: u16 obp1Index;
16: struct InputMapping inputMapping; -> 16 bytes
32: struct GameboyGraphicsSettings graphics; -> 4 bytes
40: u64 timer; -> unsure why this isn't offset 36: some sort of alignment issue?
48: enum StoredInfoType storedType; -> 4 bytes
52: u32 compressedSize;
};
Offset 128: Beginning of data compressed with gzip
When uncompressed, this data is a concatenation of various things the emulator needs, and begins with SRAM data. The length of the SRAM
data is determined from the ROM
struct InputMapping
{
u8 right;
u8 left;
u8 up;
u8 down;
u8 a;
u8 b;
u8 select;
u8 start;
u8 save;
u8 load;
u8 openMenu;
u8 fastForward;
u32 reserved2;
};
struct GameboyGraphicsSettings
{
u32 unused:27;
u32 smooth:1;
u32 scaleSetting:4;
};
enum StoredInfoType
{
StoredInfoTypeAll,
StoredInfoTypeSettingsRAM,
StoredInfoTypeRAM,
StoredInfoTypeSettings,
StoredInfoTypeNone,
};
*/
import pako from 'pako';
import Util from '../../../util/util';
import SaveFilesUtil from '../../../util/SaveFiles';
import GbRom from '../../../rom-formats/gb';
const LITTLE_ENDIAN = false;
const MAGIC = 'GB64';
const MAGIC_OFFSET = 0;
const MAGIC_ENCODING = 'US-ASCII';
const VERSION_OFFSET = 4;
const FLAGS_OFFSET = 8;
const BGP_INDEX_OFFSET = 10;
const OBP0_INDEX_OFFSET = 12;
const OBP1_INDEX_OFFSET = 14;
const INPUT_MAPPING_OFFSET = 16;
const INPUT_MAPPING_LENGTH = 16;
const GRAPHICS_SETTINGS_OFFSET = 32;
const TIMER_OFFSET = 40;
const STORED_INFO_TYPE_OFFSET = 48;
const COMPRESSED_SIZE_OFFSET = 52;
const GAMEBOY_STATE_DATA_OFFSET = 0x80;
const HEADER_SIZE = GAMEBOY_STATE_DATA_OFFSET;
const HEADER_FILL_VALUE = 0x00;
const FILE_LENGTH = 0x20000;
const FILE_PADDING_VALUE = 0xAA;
const DESIRED_VERSION = 2;
const DEFAULT_FLAGS = 0x0000;
const DEFAULT_BGP_INDEX = 0;
const DEFAULT_OBP0_INDEX = 0;
const DEFAULT_OBP1_INDEX = 0;
const DEFAULT_GRAPHICS_SETTINGS = 0x00000002;
const DEFAULT_TIMER = 0x02C445F9n; // 0n;
const STORED_INFO_TYPE_ALL = 0;
// const STORED_INFO_TYPE_SETTINGS_RAM = 1; // The emulator returns immediately after reading the SRAM data if the stored info type is one of these: https://github.com/lambertjamesd/gb64/blob/master/src/save.c#L588
// const STORED_INFO_TYPE_RAM = 2;
const STORED_INFO_TYPE_SETTINGS = 3;
const STORED_INFO_TYPE_NONE = 4;
const GAMEBOY_STATE_DATA_SRAM_OFFSET = 0;
const GAMEBOY_STATE_DATA_FILL_VALUE = 0x00;
const MIN_SRAM_SIZE = 8192;
const GAMEBOY_DATA_DATA_LENGTH_GB = (25472 - 8192); // Gotten empirically by looking at a save file for Zelda: Link's Awakening: file size minus game SRAM size
const GAMEBOY_DATA_DATA_LENGTH_GBC = (58240 - 8192); // Gotten empirically by looking at a save file for Zelda: Oracle of Seasons: file size minus game SRAM size
/*
const PALETTE_COUNT = 64; // https://github.com/lambertjamesd/gb64/blob/a6b90ef5454e3f2cf4b92dd746926e7ddd858f91/src/memory_map.h#L177
const MAX_RAM_SIZE = 0x8000; // https://github.com/lambertjamesd/gb64/blob/a6b90ef5454e3f2cf4b92dd746926e7ddd858f91/src/memory_map.h#L12
const MISC_MEMORY_SIZE = 0x8000; // Man this is so complex I don't want to add it all up: https://github.com/lambertjamesd/gb64/blob/a6b90ef5454e3f2cf4b92dd746926e7ddd858f91/src/memory_map.h#L160
const MISC_SAVE_STATE_DATA_SIZE = 0x8000; // Same for this: really complex and I don't want to add it up: https://github.com/lambertjamesd/gb64/blob/391b553966ef1ff45368bad8bb28fea119aa20de/src/save.c#L434
// From https://github.com/lambertjamesd/gb64/blob/391b553966ef1ff45368bad8bb28fea119aa20de/src/save.c#L25
function alignFlashOffset(offset) {
return ((offset + 0x7F) & ~0x7F);
}
*/
function calculateGameboyStateDataSize(isGbc) {
/*
// Taken from https://github.com/lambertjamesd/gb64/blob/master/src/save.c#L441
// which seems, oddly enough, to be different from https://github.com/lambertjamesd/gb64/blob/master/src/save.c#L720
// (The latter doesn't rely on checking if the platform is GBC, for example)
// I wonder if the latter is not actually used
let offset = 0;
offset = alignFlashOffset(offset + sramLength);
offset = alignFlashOffset(offset + (isGbc ? 0x4000 : 0x2000));
offset = alignFlashOffset(offset + (2 * PALETTE_COUNT));
offset = alignFlashOffset(offset + (isGbc ? MAX_RAM_SIZE : 0x2000));
offset = alignFlashOffset(offset + MISC_MEMORY_SIZE);
offset = alignFlashOffset(offset + MISC_SAVE_STATE_DATA_SIZE);
return offset;
*/
if (isGbc) {
return GAMEBOY_DATA_DATA_LENGTH_GBC;
}
return GAMEBOY_DATA_DATA_LENGTH_GB;
}
function getDefaultInputMapping() {
// Just hardcode the default data I see in an example save file, rather than spelling out every
// last item here
const arrayBuffer = Util.getFilledArrayBuffer(INPUT_MAPPING_LENGTH, 0x00);
const dataView = new DataView(arrayBuffer);
dataView.setUint32(0, 0x08090B0A, false);
dataView.setUint32(4, 0x0F0E0D0C, false);
dataView.setUint32(8, 0x03020100, false);
return arrayBuffer;
}
export default class Gb64EmulatorSaveData {
static createFromFlashCartData(flashCartArrayBuffer, romArrayBuffer) {
const gbRom = new GbRom(romArrayBuffer);
return Gb64EmulatorSaveData.createFromFlashCartDataInternal(flashCartArrayBuffer, gbRom.getSramSize());
}
static createFromFlashCartDataInternal(flashCartArrayBuffer, sramSize) {
// Based on https://github.com/lambertjamesd/gb64/blob/master/src/save.c#L492
Util.checkMagic(flashCartArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const flashCartDataView = new DataView(flashCartArrayBuffer);
const version = flashCartDataView.getUint32(VERSION_OFFSET, LITTLE_ENDIAN);
// const flags = flashCartDataView.getUint16(FLAGS_OFFSET, LITTLE_ENDIAN);
const storedInfoType = flashCartDataView.getUint32(STORED_INFO_TYPE_OFFSET, LITTLE_ENDIAN);
const compressedSize = flashCartDataView.getUint32(COMPRESSED_SIZE_OFFSET, LITTLE_ENDIAN);
const dataIsCompressed = (version === DESIRED_VERSION);
if ((version < 0) || (version > DESIRED_VERSION)) {
throw new Error(`Found version ${version} but can only read versions 0 through ${DESIRED_VERSION}`);
}
if ((storedInfoType < STORED_INFO_TYPE_ALL) || (storedInfoType > STORED_INFO_TYPE_NONE)) {
throw new Error(`Unrecognized stored info type: ${storedInfoType}`);
}
if ((storedInfoType === STORED_INFO_TYPE_SETTINGS) || (storedInfoType === STORED_INFO_TYPE_NONE)) {
throw new Error('This file does not contain save data');
}
let gameboyStateData = null;
if (dataIsCompressed) {
const compressedData = flashCartArrayBuffer.slice(GAMEBOY_STATE_DATA_OFFSET, GAMEBOY_STATE_DATA_OFFSET + compressedSize);
gameboyStateData = pako.inflate(compressedData);
} else {
gameboyStateData = flashCartArrayBuffer.slice(GAMEBOY_STATE_DATA_OFFSET); // In the emulator code, this begins at ALIGN_FLASH_OFFSET(sizeof(GameboySettings)), which is ALIGN_FLASH_OFFSET(56), which is 0x80 -- same as FLASH_BLOCK_SIZE for the compressed data
}
// The emulator appears to incorrectly obtain the SRAM size from the ROM:
// https://github.com/lambertjamesd/gb64/blob/master/src/save.c#L443
// https://github.com/lambertjamesd/gb64/blob/dde5833ec53dda6ec642d591b9985422eecba923/src/rom.c#L206
//
// Effectively, this appears to ensure a minimum size of 8kB, even if the ROM specifies 2kB or 0kB.
// Notice also that the weird MBC2 cartridge type is also covered by this: it was a value of 0 in the header but has 2kB of onboard RAM.
// It's not tested for specifically by the emulator, but happens to succeed by making a minimum value of 8kB.
const sramLength = Math.max(sramSize, MIN_SRAM_SIZE);
const rawArrayBuffer = gameboyStateData.slice(GAMEBOY_STATE_DATA_SRAM_OFFSET, GAMEBOY_STATE_DATA_SRAM_OFFSET + sramLength);
return new Gb64EmulatorSaveData(flashCartArrayBuffer, rawArrayBuffer, gameboyStateData);
}
static createFromRawData(rawArrayBuffer, romArrayBuffer) {
const gbRom = new GbRom(romArrayBuffer);
return Gb64EmulatorSaveData.createFromRawDataInternal(rawArrayBuffer, gbRom.getSramSize(), gbRom.getIsGbc());
}
static createFromRawDataInternal(rawArrayBuffer, sramLength, isGbc) {
const headerArrayBuffer = Util.setMagic(Util.getFilledArrayBuffer(HEADER_SIZE, HEADER_FILL_VALUE), MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const headerDataView = new DataView(headerArrayBuffer);
const headerUint8Array = new Uint8Array(headerArrayBuffer);
const defaultInputMapping = new Uint8Array(getDefaultInputMapping());
headerUint8Array.set(defaultInputMapping, INPUT_MAPPING_OFFSET);
headerDataView.setUint32(VERSION_OFFSET, DESIRED_VERSION, LITTLE_ENDIAN);
headerDataView.setUint16(FLAGS_OFFSET, DEFAULT_FLAGS, LITTLE_ENDIAN);
headerDataView.setUint16(BGP_INDEX_OFFSET, DEFAULT_BGP_INDEX, LITTLE_ENDIAN);
headerDataView.setUint16(OBP0_INDEX_OFFSET, DEFAULT_OBP0_INDEX, LITTLE_ENDIAN);
headerDataView.setUint16(OBP1_INDEX_OFFSET, DEFAULT_OBP1_INDEX, LITTLE_ENDIAN);
headerDataView.setUint32(GRAPHICS_SETTINGS_OFFSET, DEFAULT_GRAPHICS_SETTINGS, LITTLE_ENDIAN);
headerDataView.setBigUint64(TIMER_OFFSET, DEFAULT_TIMER, LITTLE_ENDIAN);
headerDataView.setUint32(STORED_INFO_TYPE_OFFSET, STORED_INFO_TYPE_ALL, LITTLE_ENDIAN);
// The data which is compressed is a concatenation of SRAM data first, then other blocks of data used by the
// emulator like RAM, etc. We need to include space for all of it so that the emulator can read it in, otherwise
// the emulator will fail reading the file: https://github.com/lambertjamesd/gb64/blob/master/src/save.c#L441
const gameboyStateDataSize = calculateGameboyStateDataSize(isGbc);
const resizedRawArrayBuffer = SaveFilesUtil.resizeRawSave(rawArrayBuffer, sramLength, GAMEBOY_STATE_DATA_FILL_VALUE);
const gameboyStateDataPadding = Util.getFilledArrayBuffer(gameboyStateDataSize, GAMEBOY_STATE_DATA_FILL_VALUE);
const gameboyStateData = Util.concatArrayBuffers([resizedRawArrayBuffer, gameboyStateDataPadding]);
const compressedData = pako.gzip(gameboyStateData); // Don't use deflate() because gb64 uses a tiny zlib library that explicitly expects gzip: https://github.com/lambertjamesd/gb64/blob/391b553966ef1ff45368bad8bb28fea119aa20de/src/save.c#L260
headerDataView.setUint32(COMPRESSED_SIZE_OFFSET, compressedData.byteLength, LITTLE_ENDIAN);
const paddingArrayBuffer = Util.getFilledArrayBuffer(FILE_LENGTH - HEADER_SIZE - compressedData.byteLength, FILE_PADDING_VALUE);
const flashCartArrayBuffer = Util.concatArrayBuffers([headerArrayBuffer, compressedData, paddingArrayBuffer]);
return new Gb64EmulatorSaveData(flashCartArrayBuffer, rawArrayBuffer, gameboyStateData);
}
static createWithNewSize(gb64EmulatorSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(gb64EmulatorSaveData.getRawArrayBuffer(), newSize);
return new Gb64EmulatorSaveData(gb64EmulatorSaveData.getFlashCartArrayBuffer(), newRawSaveData, gb64EmulatorSaveData.gameboyStateDataArrayBuffer);
}
static getFlashCartFileExtension() {
return 'fla';
}
static getRawFileExtension() {
return 'srm'; // GB/C saves have many possible extensions, but keeping .fla is definitely not the correct one
}
static requiresRom() {
return {
clazz: GbRom,
requiredToConvert: ['convertToFormat', 'convertToRaw'],
};
}
static adjustOutputSizesPlatform() {
return 'gb';
}
constructor(flashCartArrayBuffer, rawArrayBuffer, gameboyStateDataArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
this.gameboyStateDataArrayBuffer = gameboyStateDataArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
getGameboyStateDataArrayBuffer() {
return this.gameboyStateDataArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/N64/N64.js
================================================
/*
Swaps the endianness of N64 save data for SRAM and FlashRAM files. EEPROM files do not need to be endian swapped
*/
import N64Util from '../../../util/N64';
import SaveFilesUtil from '../../../util/SaveFiles';
export default class N64FlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
const rawArrayBuffer = N64Util.needsEndianSwap(flashCartArrayBuffer) ? N64Util.endianSwap(flashCartArrayBuffer) : flashCartArrayBuffer;
return new N64FlashCartSaveData(flashCartArrayBuffer, rawArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
const flashCartArrayBuffer = N64Util.needsEndianSwap(rawArrayBuffer) ? N64Util.endianSwap(rawArrayBuffer) : rawArrayBuffer;
return new N64FlashCartSaveData(flashCartArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(n64FlashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(n64FlashCartSaveData.getRawArrayBuffer(), newSize);
return N64FlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return null; // N64 saves have many possible extensions, and we just want to keep whatever the original extension was
}
static getRawFileExtension() {
return null; // N64 saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'n64'; // Apparently N64 Everdrives will ignore a save that's the wrong size, and just overwrite it
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/N64/NES.js
================================================
// Some N64 flash carts support an NES core that runs on the FPGA, as opposed to the Neon64 emulator supported by others
// It seems that by default on an Everdrive 64 X7, it saves out 128kB save files, of which the first 8kB (or whatever) is the actual
// SRAM data, and the rest is padded with 0xAA. This can be changed in the Everdrive menu, under ROM Info for a particular ROM.
//
// The core is able to read in a "regualar" 8kB file, and then it rewrites it as 128kB again padded with 0xAA
//
// The emulator I tried was able to read in the 128kB file and ignore everything beyond the necessary first 8kB.
import SaveFilesUtil from '../../../util/SaveFiles';
import Util from '../../../util/util';
const PADDED_SIZE = 131072;
const PADDING_VALUE = 0xAA;
function padArrayBuffer(arrayBuffer, paddedSize) {
if (arrayBuffer.byteLength <= paddedSize) {
const paddingArrayBuffer = Util.getFilledArrayBuffer(paddedSize - arrayBuffer.byteLength, PADDING_VALUE);
return Util.concatArrayBuffers([arrayBuffer, paddingArrayBuffer]);
}
return arrayBuffer;
}
export default class N64NesFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
return new N64NesFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
const flashCartArrayBuffer = padArrayBuffer(rawArrayBuffer, PADDED_SIZE);
return new N64NesFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer, rawArrayBuffer); // Pass the new array buffer as both raw and flash cart, because in the UI we want the size to show up as 128kB to prevent confusion and that size is read from the raw array buffer
}
static createWithNewSize(flashCartSaveData, newSize) {
const newFlashCartArrayBuffer = SaveFilesUtil.resizeRawSave(flashCartSaveData.getOriginalRawArrayBuffer(), newSize, PADDING_VALUE);
const newRawSaveData = SaveFilesUtil.resizeRawSave(flashCartSaveData.getOriginalRawArrayBuffer(), newSize);
return new N64NesFlashCartSaveData(newFlashCartArrayBuffer, newRawSaveData, flashCartSaveData.getOriginalRawArrayBuffer());
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return null; // NES saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'nes';
}
constructor(flashCartArrayBuffer, rawArrayBuffer, originalRawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
this.originalRawArrayBuffer = originalRawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getOriginalRawArrayBuffer() {
return this.originalRawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/N64/Neon64Emulator.js
================================================
/*
Neon64 is an NES emulator that runs on the N64
Details on the save format: https://github.com/hcs64/neon64v2/issues/20
The format is:
0x10000-0x10003: 0x79783B4A
0x10004-0x10007: 0x985626E0
0x10008-0x12007: battery backed RAM
0x12008-0x1200B: 0x0BDFD303
0x1200C-0x1200F: 0x4579BC39
All of the rest of the file is ignored by the emulator. It happens to be filled with 0xAA.
There is an 8 byte magic header before the save data, and another 8 byte magic footer afterward.
Only 8kB saves are supported.
*/
import Util from '../../../util/util';
import SaveFilesUtil from '../../../util/SaveFiles';
const LITTLE_ENDIAN = false;
const HEADER = [
0x79783B4A,
0x985626E0,
];
const FOOTER = [
0x0BDFD303,
0x4579BC39,
];
const HEADER_OFFSET = 0x10000;
const HEADER_LENGTH = HEADER.length * 4;
const SAVE_OFFSET = HEADER_OFFSET + HEADER_LENGTH;
const SAVE_LENGTH = 8192; // Neon64 only works with 8kB saves
const FOOTER_OFFSET = HEADER_OFFSET + HEADER_LENGTH + SAVE_LENGTH;
const FILL_BYTE = 0xAA;
const FILE_LENGTH = 0x20000;
function checkHeaderAndFooter(flashCartArrayBuffer) {
const dataView = new DataView(flashCartArrayBuffer);
const headerMatches = HEADER.every((magic, index) => (dataView.getUint32(HEADER_OFFSET + (index * 4), LITTLE_ENDIAN) === magic));
const footerMatches = FOOTER.every((magic, index) => (dataView.getUint32(FOOTER_OFFSET + (index * 4), LITTLE_ENDIAN) === magic));
if (!headerMatches || !footerMatches) {
throw new Error('This does not appear to be a Neon64 save file');
}
}
function fillInHeaderAndFooter(flashCartArrayBuffer) {
const dataView = new DataView(flashCartArrayBuffer);
HEADER.forEach((magic, index) => { dataView.setUint32(HEADER_OFFSET + (index * 4), magic, LITTLE_ENDIAN); });
FOOTER.forEach((magic, index) => { dataView.setUint32(FOOTER_OFFSET + (index * 4), magic, LITTLE_ENDIAN); });
}
export default class Neon64EmulatorSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
if (flashCartArrayBuffer.byteLength !== FILE_LENGTH) {
throw new Error('This does not appear to be a Neon64 save file');
}
checkHeaderAndFooter(flashCartArrayBuffer);
return new Neon64EmulatorSaveData(flashCartArrayBuffer, flashCartArrayBuffer.slice(SAVE_OFFSET, SAVE_OFFSET + SAVE_LENGTH));
}
static createFromRawData(rawArrayBuffer) {
if (rawArrayBuffer.byteLength !== SAVE_LENGTH) {
throw new Error('Neon64 only works with 8kB NES save files');
}
const emptyArrayBuffer = Util.getFilledArrayBuffer(FILE_LENGTH, FILL_BYTE);
fillInHeaderAndFooter(emptyArrayBuffer);
const flashCartArrayBuffer = Util.setArrayBufferPortion(emptyArrayBuffer, rawArrayBuffer, SAVE_OFFSET, 0, SAVE_LENGTH);
return new Neon64EmulatorSaveData(flashCartArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(neon64EmulatorSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(neon64EmulatorSaveData.getRawArrayBuffer(), newSize);
// Don't call createFromRawData() with the resized raw data, because we'll get an error saying Neon can only do 8kB saves. Bypass it instead.
return new Neon64EmulatorSaveData(neon64EmulatorSaveData.getFlashCartArrayBuffer(), newRawSaveData);
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return null; // NES saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return null; // Neon64 only works with 8kB saves, and if we get a save of another size we can't adjust it
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/NES.js
================================================
import SaveFilesUtil from '../../util/SaveFiles';
export default class NesFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
return new NesFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new NesFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(nesFlashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(nesFlashCartSaveData.getRawArrayBuffer(), newSize);
return NesFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return null; // NES saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'nes';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/PcEngine.js
================================================
import PcEngineUtil from '../../util/PcEngine';
export default class PcEngineFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
PcEngineUtil.verifyPcEngineData(flashCartArrayBuffer);
return new PcEngineFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
PcEngineUtil.verifyPcEngineData(rawArrayBuffer);
return new PcEngineFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static getFlashCartFileExtension() {
return 'srm'; // Unsure what the best extension for PCE saves is
}
static getRawFileExtension() {
return null; // Unsure what the best extension for PCE saves is
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return null;
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/SMS.js
================================================
import SaveFilesUtil from '../../util/SaveFiles';
export default class SmsFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
return new SmsFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new SmsFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(flashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(flashCartSaveData.getRawArrayBuffer(), newSize);
return SmsFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return null; // SMS saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'sms';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/SNES/GB.js
================================================
import SaveFilesUtil from '../../../util/SaveFiles';
export default class SuperGameboyFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
return new SuperGameboyFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new SuperGameboyFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(gbFlashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(gbFlashCartSaveData.getRawArrayBuffer(), newSize);
return SuperGameboyFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return null; // GB saves have many possible extensions, and we just want to keep whatever the original extension was
}
static getRawFileExtension() {
return null; // GB saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'gb';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/FlashCarts/SNES/SNES.js
================================================
import SaveFilesUtil from '../../../util/SaveFiles';
export default class SnesFlashCartSaveData {
static createFromFlashCartData(flashCartArrayBuffer) {
return new SnesFlashCartSaveData(flashCartArrayBuffer, flashCartArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new SnesFlashCartSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(snesFlashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(snesFlashCartSaveData.getRawArrayBuffer(), newSize);
return SnesFlashCartSaveData.createFromRawData(newRawSaveData);
}
static getFlashCartFileExtension() {
return 'srm';
}
static getRawFileExtension() {
return null; // SNES saves have many possible extensions, and we just want to keep whatever the original extension was
}
static requiresRom() {
return null;
}
static adjustOutputSizesPlatform() {
return 'snes';
}
constructor(flashCartArrayBuffer, rawArrayBuffer) {
this.flashCartArrayBuffer = flashCartArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getFlashCartArrayBuffer() {
return this.flashCartArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/GBA/ActionReplay.js
================================================
/*
The Action Replay data format is just a raw file with no header or footer.
*/
import SaveFilesUtil from '../../util/SaveFiles';
export default class ActionReplaySaveData {
static createFromActionReplayData(actionReplayArrayBuffer) {
return new ActionReplaySaveData(actionReplayArrayBuffer);
}
static createFromEmulatorData(emulatorArrayBuffer) {
return new ActionReplaySaveData(emulatorArrayBuffer);
}
static createWithNewSize(actionReplaySaveData, newSize) {
// Sometimes we may need to change the size of our raw buffer. This is because it's very difficult to determine
// what the save game size is for a particular game and so some emulators get this wrong and there are many files
// floating around the Internet that are the wrong size.
//
// So we can either truncate them (most likely), or pad them with zeros to make them the size
// that the game/emulator actually expects.
//
// More information:
// - https://zork.net/~st/jottings/GBA_saves.html
// - https://dillonbeliveau.com/2020/06/05/GBA-FLASH.html
const newRawSaveData = SaveFilesUtil.resizeRawSave(actionReplaySaveData.getRawSaveData(), newSize);
return ActionReplaySaveData.createFromEmulatorData(newRawSaveData);
}
// This constructor creates a new object from a binary representation of a Action Replay save data file
constructor(arrayBuffer) {
this.rawSaveData = arrayBuffer;
}
getRawSaveData() {
return this.rawSaveData;
}
getArrayBuffer() {
return this.rawSaveData;
}
}
================================================
FILE: frontend/src/save-formats/GBA/GameShark.js
================================================
/* eslint no-bitwise: ["error", { "allow": ["&", ">>>", "<<"] }] */
/*
The GameShark data format header contains a header, the raw save, then a CRC.
The header contains a number of strings, so it's of variable length:
- First 4 bytes: 0x0000000d (length of "SharkPortSave")
- Next 13 bytes: "SharkPortSave"
- Next 4 bytes: Platform (GBA is 0x000f0000)
- Next 4 bytes: Length of title
- Next N bytes: Title
- Next 4 bytes: Length of date
- Next N bytes: Date
- Next 4 bytes: Length of notes
- Next N bytes: Notes
- Next 4 bytes: Length of raw save (including 0x1c bytes of a second header)
- Next 0x1C bytes: Second header
- Next N bytes: Raw save
- Next 4 bytes: CRC of second header + raw save
The second header is information from the ROM of the game the save is for:
- First 16 bytes: Game internal name (this is checked by some emulators, so we need to set it correctly)
- Next 2 bytes: ROM checksum
- Next 1 byte: ROM compliment check
- Next 1 byte: "Maker" (unsure what this is, often seems to be 0x30)
- Next 1 byte: 0x1 (for 1 save?)
- Next 7 bytes: 0x0
(see https://github.com/visualboyadvance-m/visualboyadvance-m/blob/master/src/gba/GBA.cpp#L1168)
*/
import GbaRom from '../../rom-formats/gba';
import Util from '../../util/util';
import SaveFilesUtil from '../../util/SaveFiles';
const LITTLE_ENDIAN = true;
const SHARK_PORT_SAVE = 'SharkPortSave';
const CODE_GBA = 0x000f0000;
const SECOND_HEADER_LENGTH = 0x1C;
const SECOND_HEADER_INTERNAL_NAME_LENGTH = 0x10;
function getText(arrayBuffer, dataView, textDecoder, currentByte) {
// The format for text in the header is 4 bytes to give the length, then a string of that length
let newCurrentByte = currentByte;
const textLength = dataView.getUint32(newCurrentByte, LITTLE_ENDIAN);
newCurrentByte += 4;
const textArrayBuffer = arrayBuffer.slice(newCurrentByte, newCurrentByte + textLength);
newCurrentByte += textLength;
const textUint8Array = new Uint8Array(textArrayBuffer);
const text = textDecoder.decode(textUint8Array);
return {
text,
nextByte: newCurrentByte,
};
}
function copyUint8ArrayUpToMaxLength(copyFrom, maxLength) {
const copyTo = new Uint8Array(maxLength);
copyTo.fill(0);
for (let i = 0; i < Math.min(copyFrom.length, maxLength); i += 1) {
copyTo[i] = copyFrom[i];
}
return copyTo;
}
function parseSecondHeader(arrayBuffer, textDecoder) {
const dataView = new DataView(arrayBuffer);
const gameInternalNameBuffer = arrayBuffer.slice(0, SECOND_HEADER_INTERNAL_NAME_LENGTH);
const gameInternalNameArray = new Uint8Array(gameInternalNameBuffer);
return {
gameInternalName: textDecoder.decode(gameInternalNameArray),
romChecksum: dataView.getUint16(SECOND_HEADER_INTERNAL_NAME_LENGTH, LITTLE_ENDIAN),
romComplimentCheck: dataView.getUint8(SECOND_HEADER_INTERNAL_NAME_LENGTH + 2),
maker: dataView.getUint8(SECOND_HEADER_INTERNAL_NAME_LENGTH + 3),
flag: dataView.getUint8(SECOND_HEADER_INTERNAL_NAME_LENGTH + 4),
};
}
function createSecondHeaderFromRom(romArrayBuffer) {
const gbaRom = new GbaRom(romArrayBuffer);
const secondHeader = {
gameInternalName: gbaRom.getInternalName(),
romChecksum: gbaRom.getChecksum(),
romComplimentCheck: gbaRom.getComplimentCheck(),
maker: gbaRom.getMaker(),
flag: 1,
};
return secondHeader;
}
function createSecondHeaderArrayBuffer(secondHeader, textEncoder) {
const headerArrayBuffer = new ArrayBuffer(SECOND_HEADER_LENGTH);
const headerDataView = new DataView(headerArrayBuffer);
const headerUint8Array = new Uint8Array(headerArrayBuffer);
const encodedInternalName = textEncoder.encode(secondHeader.gameInternalName);
const encodedInternalNameMaxLength = copyUint8ArrayUpToMaxLength(encodedInternalName, SECOND_HEADER_INTERNAL_NAME_LENGTH);
headerUint8Array.set(encodedInternalNameMaxLength, 0);
headerDataView.setUint16(SECOND_HEADER_INTERNAL_NAME_LENGTH, secondHeader.romChecksum, LITTLE_ENDIAN);
headerDataView.setUint8(SECOND_HEADER_INTERNAL_NAME_LENGTH + 2, secondHeader.romComplimentCheck);
headerDataView.setUint8(SECOND_HEADER_INTERNAL_NAME_LENGTH + 3, secondHeader.maker);
headerDataView.setUint8(SECOND_HEADER_INTERNAL_NAME_LENGTH + 4, secondHeader.flag);
return headerArrayBuffer;
}
function calculateCrc(arrayBuffer) {
// Not a standard CRC algorithm as far as I know.
// Taken from https://github.com/visualboyadvance-m/visualboyadvance-m/blob/master/src/gba/GBA.cpp#L1180
const dataView = new DataView(arrayBuffer);
let crc = 0;
for (let i = 0; i < arrayBuffer.byteLength; i += 1) {
crc += ((dataView.getUint8(i) << (crc % 18)) >>> 0);
if (crc >= 2 ** 32) {
crc -= 2 ** 32;
}
}
return crc;
}
export default class GameSharkSaveData {
static createFromGameSharkData(gameSharkArrayBuffer) {
return new GameSharkSaveData(gameSharkArrayBuffer);
}
static createWithNewSize(gameSharkSaveData, newSize) {
// Sometimes we may need to change the size of our raw buffer. This is because it's very difficult to determine
// what the save game size is for a particular game and so some emulators get this wrong and there are many files
// floating around the Internet that are the wrong size.
//
// So we can either truncate them (most likely), or pad them with zeros to make them the size
// that the game/emulator actually expects.
//
// More information:
// - https://zork.net/~st/jottings/GBA_saves.html
// - https://dillonbeliveau.com/2020/06/05/GBA-FLASH.html
const newRawSaveData = SaveFilesUtil.resizeRawSave(gameSharkSaveData.getRawSaveData(), newSize);
return GameSharkSaveData.createFromEmulatorDataAndSecondHeader(
newRawSaveData,
gameSharkSaveData.getTitle(),
gameSharkSaveData.getDate(),
gameSharkSaveData.getNotes(),
gameSharkSaveData.getSecondHeader(),
);
}
static createFromEmulatorData(emulatorArrayBuffer, title, date, notes, romArrayBuffer) {
const secondHeader = createSecondHeaderFromRom(romArrayBuffer);
return GameSharkSaveData.createFromEmulatorDataAndSecondHeader(emulatorArrayBuffer, title, date, notes, secondHeader);
}
static createFromEmulatorDataAndSecondHeader(emulatorArrayBuffer, title, date, notes, secondHeader) {
// A bit inefficient to promptly go and re-parse the save data, but this
// has the nice benefit of verifying that we put everything in the correct endianness
// and got everything in the right spot. Yes I suppose that should be a test instead.
const textEncoder = new TextEncoder('US-ASCII'); // The name can contain 0x00 characters that we need to interpret correctly
// Create the first header
const firstHeaderLength = 4 + SHARK_PORT_SAVE.length + 4 + 4 + title.length + 4 + date.length + 4 + notes.length + 4;
const firstHeaderArrayBuffer = new ArrayBuffer(firstHeaderLength);
const firstHeaderUint8Array = new Uint8Array(firstHeaderArrayBuffer);
const firstHeaderDataView = new DataView(firstHeaderArrayBuffer);
let currentByte = 0;
firstHeaderDataView.setUint32(currentByte, SHARK_PORT_SAVE.length, LITTLE_ENDIAN);
currentByte += 4;
firstHeaderUint8Array.set(textEncoder.encode(SHARK_PORT_SAVE), currentByte);
currentByte += SHARK_PORT_SAVE.length;
firstHeaderDataView.setUint32(currentByte, CODE_GBA, LITTLE_ENDIAN);
currentByte += 4;
firstHeaderDataView.setUint32(currentByte, title.length, LITTLE_ENDIAN);
currentByte += 4;
firstHeaderUint8Array.set(textEncoder.encode(title), currentByte);
currentByte += title.length;
firstHeaderDataView.setUint32(currentByte, date.length, LITTLE_ENDIAN);
currentByte += 4;
firstHeaderUint8Array.set(textEncoder.encode(date), currentByte);
currentByte += date.length;
firstHeaderDataView.setUint32(currentByte, notes.length, LITTLE_ENDIAN);
currentByte += 4;
firstHeaderUint8Array.set(textEncoder.encode(notes), currentByte);
currentByte += notes.length;
firstHeaderDataView.setUint32(currentByte, emulatorArrayBuffer.byteLength + SECOND_HEADER_LENGTH, LITTLE_ENDIAN);
currentByte += 4;
// Create the second header, and concat it with the raw save then calculate the CRC of that
const secondHeaderAndRawSave = new ArrayBuffer(emulatorArrayBuffer.byteLength + SECOND_HEADER_LENGTH);
const secondHeaderAndRawSaveUint8Array = new Uint8Array(secondHeaderAndRawSave);
const secondHeaderArrayBuffer = createSecondHeaderArrayBuffer(secondHeader, textEncoder);
const secondHeaderUint8Array = new Uint8Array(secondHeaderArrayBuffer);
const emulatorUint8Array = new Uint8Array(emulatorArrayBuffer);
secondHeaderAndRawSaveUint8Array.set(secondHeaderUint8Array, 0);
secondHeaderAndRawSaveUint8Array.set(emulatorUint8Array, SECOND_HEADER_LENGTH);
const calculatedCrc = calculateCrc(secondHeaderAndRawSave);
// And now we concat everything together: first header + second header + raw save + crc
const crcArrayBuffer = new ArrayBuffer(4);
const crcDataView = new DataView(crcArrayBuffer);
crcDataView.setUint32(0, calculatedCrc, LITTLE_ENDIAN);
const gameSharkArrayBuffer = Util.concatArrayBuffers([firstHeaderArrayBuffer, secondHeaderArrayBuffer, emulatorArrayBuffer, crcArrayBuffer]);
return new GameSharkSaveData(gameSharkArrayBuffer);
}
// This constructor creates a new object from a binary representation of a GameShark save data file
constructor(arrayBuffer) {
this.arrayBuffer = arrayBuffer;
const dataView = new DataView(arrayBuffer);
const textDecoder = new TextDecoder('US-ASCII'); // The name can contain 0x00 characters that we need to interpret correctly
//
// First make sure that the stuff in the header all makes sense
//
let currentByte = 0;
// Read "SharkPortSave"
const sharkPortSaveInfo = getText(arrayBuffer, dataView, textDecoder, currentByte);
if (sharkPortSaveInfo.text !== SHARK_PORT_SAVE) {
throw new Error('This does not appear to be a GameShark save file');
}
currentByte = sharkPortSaveInfo.nextByte;
// Read the platform
const platformCode = dataView.getUint32(currentByte, LITTLE_ENDIAN);
currentByte += 4;
if (platformCode !== CODE_GBA) {
throw new Error('This does not appear to be a GBA GameShark file');
}
try {
// Read the title
const titleInfo = getText(arrayBuffer, dataView, textDecoder, currentByte);
this.title = titleInfo.text;
currentByte = titleInfo.nextByte;
// Read the date
const dateInfo = getText(arrayBuffer, dataView, textDecoder, currentByte);
this.date = dateInfo.text;
currentByte = dateInfo.nextByte;
// Read the notes
const notesInfo = getText(arrayBuffer, dataView, textDecoder, currentByte);
this.notes = notesInfo.text;
currentByte = notesInfo.nextByte;
// Next grab the second header and raw save
const secondHeaderAndRawSaveLength = dataView.getUint32(currentByte, LITTLE_ENDIAN);
currentByte += 4;
const secondHeaderAndRawSave = arrayBuffer.slice(currentByte, currentByte + secondHeaderAndRawSaveLength);
const rawSaveLength = secondHeaderAndRawSaveLength - SECOND_HEADER_LENGTH;
const secondHeaderData = arrayBuffer.slice(currentByte, currentByte + SECOND_HEADER_LENGTH);
currentByte += SECOND_HEADER_LENGTH;
this.secondHeader = parseSecondHeader(secondHeaderData, textDecoder);
const rawSaveData = arrayBuffer.slice(currentByte, currentByte + rawSaveLength);
currentByte += 4;
// And lastly the CRC
this.crcFromFile = dataView.getUint32(currentByte, LITTLE_ENDIAN);
currentByte += 4;
this.calculatedCrc = calculateCrc(secondHeaderAndRawSave);
// Some files found on the Internet do not set the CRC (e.g. its 0x00000000 or oxFFFFFFFF) so there's
// no point in rejecting a file if the CRC doesn't match.
// Everything looks good
this.rawSaveData = rawSaveData;
} catch (e) {
// The header length is variable, so having bad values for the length of the various strings
// results in a file that isn't readable
throw new Error('This file appears to be corrupted');
}
}
getTitle() {
return this.title;
}
getDate() {
return this.date;
}
getNotes() {
return this.notes;
}
getSecondHeader() {
return this.secondHeader;
}
getCrc() {
return this.crc;
}
getCalculatedCrc() {
return this.calculatedCrc;
}
getRawSaveData() {
return this.rawSaveData;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/GBA/GameSharkSP.js
================================================
/*
The GameShark SP data format header contains a header then the raw save.
The header is of fixed length. The full format doesn't appear to be known, so we can currently only do the conversion one way (extracting the raw save).
- Bytes 0-7: "ADVSAVEG"
- Bytes 8-11: Unknown
- Bytes 12-23: Name of the ROM this file is for
- Bytes 24-43: Unknown
- Bytes 44-1067: Notes
- Bytes 1068-1071: 0x12345678 ("xV4\x12" as it's referred to in VBA below)
- Bytes 1072-EOF: Raw save
(see https://github.com/visualboyadvance-m/visualboyadvance-m/blob/master/src/gba/GBA.cpp#L1078)
*/
const LITTLE_ENDIAN = true;
const HEADER_START_MARKER = 'ADVSAVEG';
const HEADER_START_MARKER_POS = 0;
const TITLE_START_POS = 12;
const TITLE_LENGTH = 12;
const NOTES_START_POS = 44;
const NOTES_LENGTH = 1023;
const HEADER_END_MARKER = 0x12345678;
const HEADER_END_MARKER_POS = 1068;
const HEADER_LENGTH = 1072;
function getText(arrayBuffer, startPos, length, textDecoder) {
const textArrayBuffer = arrayBuffer.slice(startPos, startPos + length);
const textUint8Array = new Uint8Array(textArrayBuffer);
return textDecoder.decode(textUint8Array);
}
export default class GameSharkSpSaveData {
static createFromGameSharkSpData(gameSharkSpArrayBuffer) {
return new GameSharkSpSaveData(gameSharkSpArrayBuffer);
}
// This constructor creates a new object from a binary representation of a GameShark SP save data file
constructor(arrayBuffer) {
this.arrayBuffer = arrayBuffer;
const dataView = new DataView(arrayBuffer);
const textDecoder = new TextDecoder('US-ASCII');
//
// First make sure that the stuff in the header all makes sense
//
// Read the header start marker
const startMarker = getText(arrayBuffer, HEADER_START_MARKER_POS, HEADER_START_MARKER.length, textDecoder);
if (startMarker !== HEADER_START_MARKER) {
throw new Error('This does not appear to be a GameShark SP file');
}
// Read the header end marker
const endMarker = dataView.getUint32(HEADER_END_MARKER_POS, LITTLE_ENDIAN);
if (endMarker !== HEADER_END_MARKER) {
throw new Error('This does not appear to be a GameShark SP file');
}
// Read the game title and notes
this.title = getText(arrayBuffer, TITLE_START_POS, TITLE_LENGTH, textDecoder);
this.notes = getText(arrayBuffer, NOTES_START_POS, NOTES_LENGTH, textDecoder);
this.rawSaveData = arrayBuffer.slice(HEADER_LENGTH);
}
getTitle() {
return this.title;
}
getNotes() {
return this.notes;
}
getRawSaveData() {
return this.rawSaveData;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/GameCube/Components/Basics.js
================================================
export default class GameCubeBasics {
static LITTLE_ENDIAN = false;
static BLOCK_SIZE = 0x2000;
static NUM_RESERVED_BLOCKS = 5;
}
================================================
FILE: frontend/src/save-formats/GameCube/Components/BlockAllocationTable.js
================================================
/* eslint-disable no-bitwise */
/*
The directory entry format is reused in a few different gamecube file type
Here's the structure as assembled from reading
- https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L350
- https://www.gc-forever.com/yagcd/chap12.html#sec12.4
0x0000-0x0001: Additive checksum
0x0002-0x0003: Inverse checksum
0x0004-0x0005: Update counter (signed (!))
0x0006-0x0007: Number of free blocks
0x0008-0x0009: Last allocated block
0x000A-0x1FFF: Table of allocated blocks
*/
import Util from '../../../util/util';
import ArrayUtil from '../../../util/Array';
import GameCubeUtil from '../Util';
import GameCubeBasics from './Basics';
const {
LITTLE_ENDIAN,
BLOCK_SIZE,
NUM_RESERVED_BLOCKS,
} = GameCubeBasics;
const NUM_BLOCK_ALLOCATION_TABLE_ENTRIES = 0xFFB; // https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L107
const BLOCK_ALLOCATION_TABLE_ENTRY_SIZE = 2;
const DEFAULT_UPDATE_COUNTER = 0;
const CHECKSUM_OFFSET = 0x0000;
const CHECKSUM_INVERSE_OFFSET = 0x0002;
const UPDATE_COUNTER_OFFSET = 0x0004;
const NUM_FREE_BLOCKS_OFFSET = 0x0006;
const LAST_ALLOCATED_BLOCK_OFFSET = 0x0008;
const BLOCK_ALLOCATION_TABLE_OFFSET = 0x000A;
const CHECKSUMMED_DATA_BEGIN_OFFSET = UPDATE_COUNTER_OFFSET; // Checksummed data offset and size are taken from https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L634
const CHECKSUMMED_DATA_SIZE = BLOCK_SIZE - CHECKSUMMED_DATA_BEGIN_OFFSET;
const TABLE_ENTRY_BLOCK_IS_FREE = 0x0000;
const TABLE_ENTRY_LAST_BLOCK = 0xFFFF;
export default class GameCubeBlockAllocationTable {
static TABLE_ENTRY_BLOCK_IS_FREE = TABLE_ENTRY_BLOCK_IS_FREE;
static TABLE_ENTRY_LAST_BLOCK = TABLE_ENTRY_LAST_BLOCK;
static writeBlockAllocationTable(saveFiles, numTotalBlocks) {
const arrayBuffer = Util.getFilledArrayBuffer(BLOCK_SIZE, TABLE_ENTRY_BLOCK_IS_FREE);
const dataView = new DataView(arrayBuffer);
// Build individual block lists for each save
let numBlocksUsed = 0;
const nextBlockNumberLists = saveFiles.map((saveFile) => {
const nextBlockNumberList = saveFile.blockList.map((block, i) => NUM_RESERVED_BLOCKS + numBlocksUsed + i + 1);
numBlocksUsed += saveFile.blockList.length;
if (nextBlockNumberList.length > 0) {
nextBlockNumberList.pop();
nextBlockNumberList.push(TABLE_ENTRY_LAST_BLOCK);
}
return nextBlockNumberList;
});
// Make sure the total number of entries needed isn't too big
const blockAllocationTableSize = nextBlockNumberLists.reduce((accumulator, nextBlockNumberList) => accumulator + nextBlockNumberList.length, 0);
if (blockAllocationTableSize > NUM_BLOCK_ALLOCATION_TABLE_ENTRIES) {
throw new Error(`Cannot fit all save files in memory card image. Desired block allocation table size: ${blockAllocationTableSize} entries, but max is ${NUM_BLOCK_ALLOCATION_TABLE_ENTRIES}`);
}
// Write out all the lists into the single table
let currentOffset = BLOCK_ALLOCATION_TABLE_OFFSET;
nextBlockNumberLists.forEach((nextBlockNumberList) => {
nextBlockNumberList.forEach((nextBlockNumber) => {
dataView.setUint16(currentOffset, nextBlockNumber, LITTLE_ENDIAN);
currentOffset += BLOCK_ALLOCATION_TABLE_ENTRY_SIZE;
});
});
// Lastly, set our update counter and then finally checksums
dataView.setUint16(NUM_FREE_BLOCKS_OFFSET, numTotalBlocks - numBlocksUsed, LITTLE_ENDIAN);
dataView.setUint16(LAST_ALLOCATED_BLOCK_OFFSET, numBlocksUsed - 1 + NUM_RESERVED_BLOCKS, LITTLE_ENDIAN);
dataView.setInt16(UPDATE_COUNTER_OFFSET, DEFAULT_UPDATE_COUNTER, LITTLE_ENDIAN); // GameCube BIOS compares these as signed values: https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L359
const { checksum, checksumInverse } = GameCubeUtil.calculateChecksums(arrayBuffer, CHECKSUMMED_DATA_BEGIN_OFFSET, CHECKSUMMED_DATA_SIZE);
dataView.setUint16(CHECKSUM_OFFSET, checksum, LITTLE_ENDIAN);
dataView.setUint16(CHECKSUM_INVERSE_OFFSET, checksumInverse, LITTLE_ENDIAN);
return arrayBuffer;
}
static readBlockAllocationTable(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
const checksum = dataView.getUint16(CHECKSUM_OFFSET, LITTLE_ENDIAN);
const checksumInverse = dataView.getUint16(CHECKSUM_INVERSE_OFFSET, LITTLE_ENDIAN);
const updateCounter = dataView.getInt16(UPDATE_COUNTER_OFFSET, LITTLE_ENDIAN); // GameCube BIOS compares these as signed values: https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L359
const numFreeBlocks = dataView.getUint16(NUM_FREE_BLOCKS_OFFSET, LITTLE_ENDIAN);
const lastAllocatedBlock = dataView.getUint16(LAST_ALLOCATED_BLOCK_OFFSET, LITTLE_ENDIAN);
const blockAllocationTable = ArrayUtil.createSequentialArray(0, NUM_BLOCK_ALLOCATION_TABLE_ENTRIES).map((i) => (
dataView.getUint16(BLOCK_ALLOCATION_TABLE_OFFSET + (i * BLOCK_ALLOCATION_TABLE_ENTRY_SIZE), LITTLE_ENDIAN)
));
const calculatedChecksums = GameCubeUtil.calculateChecksums(arrayBuffer, CHECKSUMMED_DATA_BEGIN_OFFSET, CHECKSUMMED_DATA_SIZE);
if ((checksum !== calculatedChecksums.checksum) || (checksumInverse !== calculatedChecksums.checksumInverse)) {
// The block is corrupted
return null;
}
return {
updateCounter,
numFreeBlocks,
lastAllocatedBlock,
blockAllocationTable,
};
}
}
================================================
FILE: frontend/src/save-formats/GameCube/Components/Directory.js
================================================
/* eslint-disable no-bitwise */
/*
The directory entry format is reused in a few different gamecube file type
Here's the structure as assembled from reading
- https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L316
- https://www.gc-forever.com/yagcd/chap12.html#sec12.3
Note that an invalid or empty directory entry is all 0xFF. So dolphin checks for the first 4 bytes of it (the game code):
https://github.com/dolphin-emu/dolphin/blob/c9bdda63dc624995406c37f4e29e3b8c4696e6d0/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L788
0x0000-0x1FBF: Directory entries (max 127)
0x1FC0-0x1FF9: padding (0xFF)
0x1FFA-0x1FFB: Update counter (signed (!))
0x1FFC-0x1FFD: Additive checksum
0x1FFE-0x1FFF: Inverse checksum
*/
import Util from '../../../util/util';
import ArrayUtil from '../../../util/Array';
import GameCubeUtil from '../Util';
import GameCubeBasics from './Basics';
import GameCubeDirectoryEntry from './DirectoryEntry';
const {
LITTLE_ENDIAN,
BLOCK_SIZE,
} = GameCubeBasics;
const DIRECTORY_PADDING_VALUE = 0xFF;
const MAX_DIRECTORY_ENTRIES = 127;
const DIRECTORY_ENTRY_LENGTH = GameCubeDirectoryEntry.LENGTH;
const DEFAULT_UPDATE_COUNTER = 0;
const UPDATE_COUNTER_OFFSET = 0x1FFA;
const CHECKSUM_OFFSET = 0x1FFC;
const CHECKSUM_INVERSE_OFFSET = 0x1FFE;
const CHECKSUMMED_DATA_BEGIN_OFFSET = 0; // Checksummed data offset and size are taken from https://github.com/dolphin-emu/dolphin/blob/4f210df86a2d2362ef8087cf81b817b18c3d32e9/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L1348
const CHECKSUMMED_DATA_SIZE = CHECKSUM_OFFSET - CHECKSUMMED_DATA_BEGIN_OFFSET;
export default class GameCubeDirectory {
static writeDirectory(saveFiles) {
if (saveFiles.length > MAX_DIRECTORY_ENTRIES) {
throw new Error(`Unable to fit ${saveFiles.length} saves into a single memory card image. Max is ${MAX_DIRECTORY_ENTRIES}`);
}
let arrayBuffer = Util.getFilledArrayBuffer(BLOCK_SIZE, DIRECTORY_PADDING_VALUE);
saveFiles.forEach((saveFile, i) => {
const directoryEntry = GameCubeDirectoryEntry.writeDirectoryEntry(saveFile);
arrayBuffer = Util.setArrayBufferPortion(arrayBuffer, directoryEntry, i * GameCubeDirectoryEntry.LENGTH, 0, GameCubeDirectoryEntry.LENGTH);
});
// Lastly, set our update counter and then finally checksums
const dataView = new DataView(arrayBuffer);
dataView.setInt16(UPDATE_COUNTER_OFFSET, DEFAULT_UPDATE_COUNTER, LITTLE_ENDIAN); // GameCube BIOS compares these as signed values: https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L325
const { checksum, checksumInverse } = GameCubeUtil.calculateChecksums(arrayBuffer, CHECKSUMMED_DATA_BEGIN_OFFSET, CHECKSUMMED_DATA_SIZE);
dataView.setUint16(CHECKSUM_OFFSET, checksum, LITTLE_ENDIAN);
dataView.setUint16(CHECKSUM_INVERSE_OFFSET, checksumInverse, LITTLE_ENDIAN);
return arrayBuffer;
}
static readDirectory(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
const updateCounter = dataView.getInt16(UPDATE_COUNTER_OFFSET, LITTLE_ENDIAN); // GameCube BIOS compares these as signed values: https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L325
const checksum = dataView.getUint16(CHECKSUM_OFFSET, LITTLE_ENDIAN);
const checksumInverse = dataView.getUint16(CHECKSUM_INVERSE_OFFSET, LITTLE_ENDIAN);
const directoryEntries = ArrayUtil.createSequentialArray(0, MAX_DIRECTORY_ENTRIES).map((i) => {
const directoryEntryArrayBuffer = arrayBuffer.slice(i * DIRECTORY_ENTRY_LENGTH, (i + 1) * DIRECTORY_ENTRY_LENGTH);
return GameCubeDirectoryEntry.readDirectoryEntry(directoryEntryArrayBuffer);
}).filter((directoryEntry) => directoryEntry !== null);
const calculatedChecksums = GameCubeUtil.calculateChecksums(arrayBuffer, CHECKSUMMED_DATA_BEGIN_OFFSET, CHECKSUMMED_DATA_SIZE);
if ((checksum !== calculatedChecksums.checksum) || (checksumInverse !== calculatedChecksums.checksumInverse)) {
// The block is corrupted
return null;
}
return {
directoryEntries,
updateCounter,
};
}
}
================================================
FILE: frontend/src/save-formats/GameCube/Components/DirectoryEntry.js
================================================
/* eslint-disable no-bitwise */
/*
The directory entry format is reused in a few different gamecube file type
Here's the structure as assembled from reading
- https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L239
- https://www.gc-forever.com/yagcd/chap12.html#sec12.3.1
- http://www.surugi.com/projects/gcifaq.html
- https://github.com/suloku/gcmm/blob/master/source/gci.h#L12
0x00-0x03: Game code (last character is the region code)
0x04-0x05: Publisher ID
0x06: unused (0xFF)
0x07: Banner and icon flags: https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L254
0x08-0x27: File ID
0x28-0x2B: Date last modified
0x2C-0x2F: Icon graphic data offset
0x30-0x31: Icon graphic format: https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L273
0x32-0x33: Icon graphic speed: https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L281
0x34: Permission attribute bitfield (public/no copy/no move)
0x35: Copy counter
0x36-0x37: Starting block number
0x38-0x39: Save size in blocks
0x3A-0x3B: unused (0xFFFF)
0x3C-0x3F: Comment offset
*/
import GameCubeUtil from '../Util';
import Util from '../../../util/util';
import GameCubeBasics from './Basics';
const { LITTLE_ENDIAN } = GameCubeBasics;
const ENCODING = 'US-ASCII'; // From tests in Gci.spec.js with various Japanese-only games, I believe all the fields in this object are encoded as ASCII. Only the comments appear to be encoded as shift-jis in Japanese games
const GAME_CODE_OFFSET = 0x00;
const GAME_CODE_LENGTH = 4;
const REGION_CODE_OFFSET = 0x03;
const REGION_CODE_LENGTH = 1;
const PUBLISHER_CODE_OFFSET = 0x04;
const PUBLISHER_CODE_LENGTH = 2;
const BANNER_AND_ICON_FLAGS_OFFSET = 0x07;
const FILE_NAME_OFFSET = 0x08;
const FILE_NAME_LENGTH = 32;
const DATE_LAST_MODIFIED_OFFSET = 0x28;
const ICON_START_OFFSET = 0x2C;
const ICON_FORMAT_OFFSET = 0x30;
const ICON_SPEED_OFFSET = 0x32;
const PERMISSION_ATTRIBUTE_BITFIELD_OFFSET = 0x34;
const COPY_COUNTER_OFFSET = 0x35;
const SAVE_START_BLOCK_OFFSET = 0x36;
const SAVE_SIZE_BLOCKS_OFFSET = 0x38;
const COMMENT_START_OFFSET = 0x3C;
const COMMENT_LENGTH = 32;
const DIRECTORY_ENTRY_LENGTH = 0x40;
const DIRECTORY_ENTRY_PADDING_VALUE = 0xFF;
const DIRECTORY_ENTRY_FILE_NAME_FILL_VALUE = 0x00; // The filename should be filled with null characters so we don't read past the end of our string
export default class GameCubeDirectoryEntry {
static ICON_SPEED_NONE = 0x00;
static ICON_SPEED_FAST = 0x01;
static ICON_SPEED_MIDDLE = 0x02;
static ICON_SPEED_SLOW = 0x03;
static PERMISSION_ATTRIBUTE_PUBLIC = 0x04;
static PERMISSION_ATTRIBUTE_NO_COPY = 0x08;
static PERMISSION_ATTRIBUTE_NO_MOVE = 0x10;
static LENGTH = DIRECTORY_ENTRY_LENGTH;
static writeDirectoryEntry(saveFile) {
let arrayBuffer = Util.getFilledArrayBuffer(DIRECTORY_ENTRY_LENGTH, DIRECTORY_ENTRY_PADDING_VALUE);
arrayBuffer = Util.fillArrayBufferPortion(arrayBuffer, FILE_NAME_OFFSET, FILE_NAME_LENGTH, DIRECTORY_ENTRY_FILE_NAME_FILL_VALUE);
arrayBuffer = Util.setString(arrayBuffer, GAME_CODE_OFFSET, saveFile.gameCode, ENCODING, GAME_CODE_LENGTH);
arrayBuffer = Util.setString(arrayBuffer, PUBLISHER_CODE_OFFSET, saveFile.publisherCode, ENCODING, PUBLISHER_CODE_LENGTH);
arrayBuffer = Util.setString(arrayBuffer, FILE_NAME_OFFSET, saveFile.fileName, ENCODING, FILE_NAME_LENGTH);
const dataView = new DataView(arrayBuffer);
dataView.setUint8(BANNER_AND_ICON_FLAGS_OFFSET, saveFile.bannerAndIconFlags);
dataView.setUint32(DATE_LAST_MODIFIED_OFFSET, saveFile.dateLastModifiedCode, LITTLE_ENDIAN);
dataView.setUint32(ICON_START_OFFSET, saveFile.iconStartOffset, LITTLE_ENDIAN);
dataView.setUint16(ICON_FORMAT_OFFSET, saveFile.iconFormatCode, LITTLE_ENDIAN);
dataView.setUint16(ICON_SPEED_OFFSET, saveFile.iconSpeedCode, LITTLE_ENDIAN);
dataView.setUint8(PERMISSION_ATTRIBUTE_BITFIELD_OFFSET, saveFile.permissionAttributeBitfield);
dataView.setUint8(COPY_COUNTER_OFFSET, saveFile.copyCounter);
dataView.setUint16(SAVE_START_BLOCK_OFFSET, saveFile.saveStartBlock, LITTLE_ENDIAN);
dataView.setUint16(SAVE_SIZE_BLOCKS_OFFSET, saveFile.saveSizeBlocks, LITTLE_ENDIAN);
dataView.setUint32(COMMENT_START_OFFSET, saveFile.commentStart, LITTLE_ENDIAN);
return arrayBuffer;
}
static getComments(commentStart, rawDataArrayBuffer, encoding) {
const uint8Array = new Uint8Array(rawDataArrayBuffer);
const commentOffsets = [
commentStart,
commentStart + COMMENT_LENGTH,
];
return commentOffsets.map((commentOffset) => Util.readNullTerminatedString(uint8Array, commentOffset, encoding, COMMENT_LENGTH));
}
static readDirectoryEntry(arrayBuffer) {
const uint8Array = new Uint8Array(arrayBuffer);
const dataView = new DataView(arrayBuffer);
// An empty entry appears to be all 0xFF. Dolphin just checks the game code, so we will too
// https://github.com/dolphin-emu/dolphin/blob/c9bdda63dc624995406c37f4e29e3b8c4696e6d0/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L788
// https://github.com/dolphin-emu/dolphin/blob/c9bdda63dc624995406c37f4e29e3b8c4696e6d0/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L243
const isValidEntry = uint8Array
.slice(GAME_CODE_OFFSET, GAME_CODE_OFFSET + GAME_CODE_LENGTH)
.some((i) => i !== 0xFF);
if (!isValidEntry) {
return null;
}
const gameCode = Util.readString(uint8Array, GAME_CODE_OFFSET, ENCODING, GAME_CODE_LENGTH);
const regionCode = Util.readString(uint8Array, REGION_CODE_OFFSET, ENCODING, REGION_CODE_LENGTH);
const publisherCode = Util.readString(uint8Array, PUBLISHER_CODE_OFFSET, ENCODING, PUBLISHER_CODE_LENGTH);
const bannerAndIconFlags = dataView.getUint8(BANNER_AND_ICON_FLAGS_OFFSET);
const fileName = Util.readNullTerminatedString(uint8Array, FILE_NAME_OFFSET, ENCODING, FILE_NAME_LENGTH);
const dateLastModifiedCode = dataView.getUint32(DATE_LAST_MODIFIED_OFFSET, LITTLE_ENDIAN);
const iconStartOffset = dataView.getUint32(ICON_START_OFFSET, LITTLE_ENDIAN);
const iconFormatCode = dataView.getUint16(ICON_FORMAT_OFFSET, LITTLE_ENDIAN);
const iconSpeedCode = dataView.getUint16(ICON_SPEED_OFFSET, LITTLE_ENDIAN);
const permissionAttributeBitfield = dataView.getUint8(PERMISSION_ATTRIBUTE_BITFIELD_OFFSET);
const copyCounter = dataView.getUint8(COPY_COUNTER_OFFSET);
const saveStartBlock = dataView.getUint16(SAVE_START_BLOCK_OFFSET, LITTLE_ENDIAN);
const saveSizeBlocks = dataView.getUint16(SAVE_SIZE_BLOCKS_OFFSET, LITTLE_ENDIAN);
const commentStart = dataView.getUint32(COMMENT_START_OFFSET, LITTLE_ENDIAN);
return {
gameCode,
regionCode,
region: GameCubeUtil.getRegionString(regionCode),
publisherCode,
bannerAndIconFlags,
fileName,
dateLastModifiedCode,
dateLastModified: GameCubeUtil.getDate(dateLastModifiedCode),
iconStartOffset,
iconFormatCode,
iconSpeedCode,
permissionAttributeBitfield,
copyCounter,
saveStartBlock,
saveSizeBlocks,
commentStart,
};
}
}
================================================
FILE: frontend/src/save-formats/GameCube/Components/Header.js
================================================
/* eslint-disable no-bitwise */
/*
Header: https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L160
First a quick note that every individual original memory card has a unique 12-byte "flash ID". It's written to the GameCube's SRAM when the card is mounted.
When writing an image to an official memcard if the flash ID doesn't match the card's then the card will be corrupted: https://github.com/suloku/gcmm/blob/95c737c2af0ebecfa2ef02a8c6c30496d0036e87/source/main.c#L41
The serial number (see below) and flash ID is ignored in emulators and the memcard pro. Memcard images from these sources typically have a serial and/or flash ID that's all 0x00
Example flash IDs can be found in the tests for this module.
0x0000-0x000B: Serial number. This is the memory card's flash ID mangled with the format time (see getSerial() below)
0x000C-0x0013: Time of format of the card (64 bit value, as an OSTime: see GameCubeUtil.getDateFromOsTime())
0x0014-0x0017: RTC bias (value added to RTC) read from SRAM at time of format. See https://gitlab.collabora.com/sebastianfricke/linux/-/blob/7a24a61a5051a1454f42b370da76691cb59d9385/drivers/rtc/rtc-gamecube.c
0x0018-0x001B: Language code read from SRAM. Values: https://www.gc-forever.com/yagcd/chap10.html#sec10.5
0x001C-0x001F: VI DTV status register value. Contains information about progressive scan and/or whether component cables plugged in etc: https://www.gc-forever.com/yagcd/chap5.html#sec5
0x0020-0x0021: Slot number (0: Slot A, 1: Slot B)
0x0022-0x0023: Size of memcard in megabits
0x0024-0x0025: Encoding (0: ASCII, 1: shift-jis)
0x0026-0x01FB: padding (0xFF)
0x01FC-0x01FD: Additive checksum
0x01FE-0x01FF: Inverse checksum
0x0200-0x3DFF: padding (0xFF)
Some sources put a potential update counter at 0x01FA-0x01FB, but even they say it's always 0xFFFF and so likely part of the padding.
Given that the actual update counters in the directory and block allocation table blocks are in different positions, in different orders
with respect to the checksums, I don't think it makes sense to assume that there is one in this block.
Particularly when there's no need for it because there is only a single header and not a backup
*/
import Util from '../../../util/util';
import GameCubeUtil from '../Util';
import GameCubeBasics from './Basics';
const {
BLOCK_SIZE,
LITTLE_ENDIAN,
} = GameCubeBasics;
const HEADER_PADDING_VALUE = 0xFF;
const SERIAL_OFFSET = 0x0000;
const SERIAL_LENGTH = 12;
const FORMAT_OSTIME_OFFSET = 0x000C;
const RTC_BIAS_OFFSET = 0x0014;
const LANGUAGE_CODE_OFFSET = 0x0018;
const VI_DTV_STATUS_OFFSET = 0x001C;
const MEMCARD_SLOT_OFFSET = 0x0020;
const MEMCARD_SLOT_A = 0;
const MEMCARD_SLOT_B = 1;
const MEMCARD_SIZE_OFFSET = 0x0022;
const ENCODING_OFFSET = 0x0024;
const CHECKSUM_OFFSET = 0x01FC;
const CHECKSUM_INVERSE_OFFSET = 0x01FE;
const CHECKSUMMED_DATA_BEGIN_OFFSET = 0; // Checksummed data offset and size are taken from https://github.com/dolphin-emu/dolphin/blob/4f210df86a2d2362ef8087cf81b817b18c3d32e9/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L1284
const CHECKSUMMED_DATA_SIZE = CHECKSUM_OFFSET - CHECKSUMMED_DATA_BEGIN_OFFSET;
const DEFAULT_FLASH_ID = Util.getFilledArrayBuffer(SERIAL_LENGTH, 0x00);
// Taken from https://github.com/dolphin-emu/dolphin/blob/4f210df86a2d2362ef8087cf81b817b18c3d32e9/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L1210
function getSerial(cardFlashId, formatTime) {
const serialArrayBuffer = new ArrayBuffer(SERIAL_LENGTH);
const serialUint8Array = new Uint8Array(serialArrayBuffer);
const cardFlashIdUint8Array = new Uint8Array(cardFlashId);
let rand = formatTime;
for (let i = 0; i < SERIAL_LENGTH; i += 1) {
rand = (((rand * 0x0000000041c64e6dn) + 0x0000000000003039n) >> 16n);
serialUint8Array[i] = (cardFlashIdUint8Array[i] + Number(rand & 0xFFFFFFFFn)) & 0xFF;
rand = (((rand * 0x0000000041c64e6dn) + 0x0000000000003039n) >> 16n);
rand &= 0x0000000000007fffn;
}
return serialArrayBuffer;
}
// Taken from https://github.com/dolphin-emu/dolphin/blob/4f210df86a2d2362ef8087cf81b817b18c3d32e9/Source/Core/Core/HW/Sram.cpp#L50
function getCardFlashId(serialArrayBuffer, formatTime) {
const cardFlashIdArrayBuffer = new ArrayBuffer(SERIAL_LENGTH);
const cardFlashIdUint8Array = new Uint8Array(cardFlashIdArrayBuffer);
const serialUint8Array = new Uint8Array(serialArrayBuffer);
let rand = formatTime;
for (let i = 0; i < SERIAL_LENGTH; i += 1) {
rand = (((rand * 0x0000000041c64e6dn) + 0x0000000000003039n) >> 16n);
cardFlashIdUint8Array[i] = serialUint8Array[i] - Number(rand & 0xFFn);
rand = (((rand * 0x0000000041c64e6dn) + 0x0000000000003039n) >> 16n);
rand &= 0x0000000000007fffn;
}
return cardFlashIdArrayBuffer;
}
export default class GameCubeHeader {
static MEMCARD_SLOT_A = MEMCARD_SLOT_A;
static MEMCARD_SLOT_B = MEMCARD_SLOT_B;
static writeHeader(volumeInfo) {
let cardFlashId = DEFAULT_FLASH_ID;
if (volumeInfo.cardFlashId !== undefined) {
// For the Memcard Pro GC it requires that flash ID be all 0's otherwise it will read the image as invalid.
// So keep it at this default value unless it's specified
cardFlashId = volumeInfo.cardFlashId;
}
const serialArrayBuffer = getSerial(cardFlashId, volumeInfo.formatOsTimeCode);
let headerArrayBuffer = Util.getFilledArrayBuffer(BLOCK_SIZE, HEADER_PADDING_VALUE);
headerArrayBuffer = Util.setArrayBufferPortion(headerArrayBuffer, serialArrayBuffer, SERIAL_OFFSET, 0, SERIAL_LENGTH);
const headerDataView = new DataView(headerArrayBuffer);
headerDataView.setBigUint64(FORMAT_OSTIME_OFFSET, volumeInfo.formatOsTimeCode, LITTLE_ENDIAN);
headerDataView.setUint32(RTC_BIAS_OFFSET, volumeInfo.rtcBias, LITTLE_ENDIAN);
headerDataView.setUint32(LANGUAGE_CODE_OFFSET, volumeInfo.languageCode, LITTLE_ENDIAN);
headerDataView.setUint32(VI_DTV_STATUS_OFFSET, volumeInfo.viDtvStatus, LITTLE_ENDIAN);
headerDataView.setUint16(MEMCARD_SLOT_OFFSET, volumeInfo.memcardSlot, LITTLE_ENDIAN);
headerDataView.setUint16(MEMCARD_SIZE_OFFSET, volumeInfo.memcardSizeMegabits, LITTLE_ENDIAN);
headerDataView.setUint16(ENCODING_OFFSET, volumeInfo.encodingCode, LITTLE_ENDIAN);
const { checksum, checksumInverse } = GameCubeUtil.calculateChecksums(headerArrayBuffer, CHECKSUMMED_DATA_BEGIN_OFFSET, CHECKSUMMED_DATA_SIZE);
headerDataView.setUint16(CHECKSUM_OFFSET, checksum, LITTLE_ENDIAN);
headerDataView.setUint16(CHECKSUM_INVERSE_OFFSET, checksumInverse, LITTLE_ENDIAN);
return headerArrayBuffer;
}
static readHeader(headerBlock) {
const dataView = new DataView(headerBlock);
const serial = headerBlock.slice(SERIAL_OFFSET, SERIAL_OFFSET + SERIAL_LENGTH);
const formatOsTimeCode = dataView.getBigUint64(FORMAT_OSTIME_OFFSET, LITTLE_ENDIAN);
const rtcBias = dataView.getUint32(RTC_BIAS_OFFSET, LITTLE_ENDIAN);
const languageCode = dataView.getUint32(LANGUAGE_CODE_OFFSET, LITTLE_ENDIAN);
const viDtvStatus = dataView.getUint32(VI_DTV_STATUS_OFFSET, LITTLE_ENDIAN);
const memcardSlot = dataView.getUint16(MEMCARD_SLOT_OFFSET, LITTLE_ENDIAN);
const memcardSizeMegabits = dataView.getUint16(MEMCARD_SIZE_OFFSET, LITTLE_ENDIAN);
const encodingCode = dataView.getUint16(ENCODING_OFFSET, LITTLE_ENDIAN);
const checksum = dataView.getUint16(CHECKSUM_OFFSET, LITTLE_ENDIAN);
const checksumInverse = dataView.getUint16(CHECKSUM_INVERSE_OFFSET, LITTLE_ENDIAN);
const cardFlashId = getCardFlashId(serial, formatOsTimeCode);
const calculatedChecksums = GameCubeUtil.calculateChecksums(headerBlock, CHECKSUMMED_DATA_BEGIN_OFFSET, CHECKSUMMED_DATA_SIZE);
if (checksum !== calculatedChecksums.checksum) {
throw new Error(`File may be corrupt. Read checksum 0x${checksum.toString(16)} but calculated checksum 0x${calculatedChecksums.checksum.toString(16)}`);
}
if (checksumInverse !== calculatedChecksums.checksumInverse) {
throw new Error(`File may be corrupt. Read checksum inverse 0x${checksumInverse.toString(16)} but calculated checksum inverse 0x${calculatedChecksums.checksumInverse.toString(16)}`);
}
return {
serial,
cardFlashId,
formatOsTimeCode,
formatTime: GameCubeUtil.getDateFromOsTime(formatOsTimeCode),
rtcBias,
languageCode,
language: GameCubeUtil.getLanguageString(languageCode),
viDtvStatus,
memcardSlot,
memcardSizeMegabits,
encodingCode,
encodingString: GameCubeUtil.getEncodingString(encodingCode),
};
}
}
================================================
FILE: frontend/src/save-formats/GameCube/GameCube.js
================================================
/* eslint-disable no-bitwise */
/*
The format for a memory card image
The format is somewhat described here: https://www.gc-forever.com/yagcd/chap12.html#sec12 but with errors and omissions
Overall the format is fairly similar to the N64 mempack format, with the first 5 blocks being reserved and there being 2 pairs of blocks with identical information in them
Block 0: Header
Block 1: Directory
Block 2: Directory backup (repeat of block 1)
Block 3: Block allocation table
Block 4: Block allocation table backup (repeat of block 3)
*/
import Util from '../../util/util';
import PlatformSaveSizes from '../PlatformSaveSizes';
import GameCubeUtil from './Util';
import GameCubeBasics from './Components/Basics';
import GameCubeHeader from './Components/Header';
import GameCubeDirectory from './Components/Directory';
import GameCubeDirectoryEntry from './Components/DirectoryEntry';
import GameCubeBlockAllocationTable from './Components/BlockAllocationTable';
import GameSpecificFixups from './GameSpecificFixups/GameSpecificFixups';
const { BLOCK_SIZE, NUM_RESERVED_BLOCKS } = GameCubeBasics;
const HEADER_BLOCK_NUMBER = 0;
const DIRECTORY_BLOCK_NUMBER = 1;
const DIRECTORY_BACKUP_BLOCK_NUMBER = 2;
const BLOCK_ALLOCATION_TABLE_BLOCK_NUMBER = 3;
const BLOCK_ALLOCATION_TABLE_BACKUP_BLOCK_NUMBER = 4;
const BLOCK_PADDING_VALUE = 0x00;
function getBlock(arrayBuffer, blockNumber) {
const startOffset = blockNumber * BLOCK_SIZE;
return arrayBuffer.slice(startOffset, startOffset + BLOCK_SIZE);
}
function createBlocks(numBlocks) {
return Util.getFilledArrayBuffer(BLOCK_SIZE * numBlocks, BLOCK_PADDING_VALUE);
}
function getActiveBlock(mainInfo, backupInfo) {
// If the update counter is equal on the two blocks we're supposed to pick the first one:
// https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L156
// Dolphin sometimes picks one or the other:
// https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcardDirectory.cpp#L468
// https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcardDirectory.cpp#L590
// The updateCounter is compared as a 16-bit signed value by the GameCube BIOS, with no protection against overflow: https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L252
// That's only 32767 updates. My childhood memory card that I didn't use very much is 1% of the way there at 333.
// There are some interesting rules about when the GameCube BIOS considers the whole card to be corrupted:
// https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L152
// I think it makes sense to be a bit more lenient here, and so allow this tool to potentially "fix" a corrupted card.
// So let's ignore the rule about 2 corrupted blocks -> whole card considered corrupted.
// As for the rule about a mismatch in block counts from the directory entry vs the block allocation table, let's ignore that one as well
// until we get feedback that fixing the error would be helpful to users. In general, we tend to parse and re-create a memory card image
// when returning it to users and so whatever we output will be free of inconsistencies like this. I guess if there's an inconsistency
// there it's hard to know whether to prefer the directory entry or the block allocation table (i.e. assume directory entry is correct and so
// use the other block allocation table, which is what we will do by default). Might need somewhat complex logic there,
// and I'd like to get feedback that it would be helpful before adding this complexity.
// It's worth noting in all this that Dolphin has considered but never implemented "fixing" corrupted images.
// I suspect there is not much demand for this.
if ((mainInfo !== null) && (backupInfo !== null)) {
// If both blocks were not corrupted, then return the one with the higher updateCounter
// (while preferring the first one if the updateCounter is equal)
if (backupInfo.updateCounter > mainInfo.updateCounter) {
return backupInfo;
}
return mainInfo;
}
// If one block is corrupted, then return the other one
if (backupInfo !== null) {
return backupInfo;
}
if (mainInfo !== null) {
return mainInfo;
}
// If both are corrupted then the card is considered corrupted
throw new Error('This memory card image appears to be corrupted');
}
function getBlockNumberList(saveStartBlock, blockAllocationTable) {
let nextBlockNum = saveStartBlock;
const blockNumberList = [];
do {
const currentBlockNum = nextBlockNum;
const currentBlockTableEntry = blockAllocationTable[currentBlockNum - NUM_RESERVED_BLOCKS];
if (currentBlockTableEntry === GameCubeBlockAllocationTable.TABLE_ENTRY_BLOCK_IS_FREE) {
throw new Error('File appears to be corrupted: block list for file contains entry marked as free');
}
blockNumberList.push(currentBlockNum);
nextBlockNum = currentBlockTableEntry;
} while (nextBlockNum !== GameCubeBlockAllocationTable.TABLE_ENTRY_LAST_BLOCK);
return blockNumberList;
}
function getSaveData(saveStartBlock, blockAllocationTable, arrayBuffer) {
const blockNumberList = getBlockNumberList(saveStartBlock, blockAllocationTable);
const blockList = blockNumberList.map((blockNumber) => getBlock(arrayBuffer, blockNumber));
return {
blockNumberList,
rawData: Util.concatArrayBuffers(blockList),
};
}
function readSaveFiles(directoryEntries, blockAllocationTable, arrayBuffer, encoding) {
return directoryEntries.map((directoryEntry) => {
const saveData = getSaveData(directoryEntry.saveStartBlock, blockAllocationTable, arrayBuffer);
const comments = GameCubeDirectoryEntry.getComments(directoryEntry.commentStart, saveData.rawData, encoding);
return {
...directoryEntry,
comments,
...saveData,
};
});
}
function checkDesiredSize(sizeBytes) {
if (!PlatformSaveSizes.gamecube.includes(sizeBytes)) {
throw new Error(`${sizeBytes} bytes (${GameCubeUtil.bytesToMegabits(sizeBytes)} megabits) is not a valid size for a gamecube memory card image`);
}
}
export default class GameCubeSaveData {
static createWithNewSize(gameCubeSaveData, newSize) {
checkDesiredSize(newSize);
const volumeInfo = {
...gameCubeSaveData.getVolumeInfo(),
memcardSizeMegabits: GameCubeUtil.bytesToMegabits(newSize),
};
return GameCubeSaveData.createFromSaveFiles(gameCubeSaveData.getSaveFiles(), volumeInfo);
}
static createWithNewEncoding(gameCubeSaveData, newEncoding) {
const volumeInfo = {
...gameCubeSaveData.getVolumeInfo(),
encodingCode: GameCubeUtil.getEncodingCode(newEncoding),
};
return GameCubeSaveData.createFromSaveFiles(gameCubeSaveData.getSaveFiles(), volumeInfo);
}
static createFromGameCubeData(arrayBuffer) {
if (arrayBuffer.byteLength < (NUM_RESERVED_BLOCKS * BLOCK_SIZE)) {
throw new Error('This does not appear to be a GameCube memory card image');
}
const headerInfo = GameCubeHeader.readHeader(getBlock(arrayBuffer, HEADER_BLOCK_NUMBER));
const { numTotalBytes, numTotalBlocks } = GameCubeUtil.getTotalSizes(headerInfo.memcardSizeMegabits);
if (arrayBuffer.byteLength < numTotalBytes) {
throw new Error('This does not appear to be a GameCube memory card image');
}
const directoryInfo = getActiveBlock(
GameCubeDirectory.readDirectory(getBlock(arrayBuffer, DIRECTORY_BLOCK_NUMBER)),
GameCubeDirectory.readDirectory(getBlock(arrayBuffer, DIRECTORY_BACKUP_BLOCK_NUMBER)),
);
const blockAllocationTableInfo = getActiveBlock(
GameCubeBlockAllocationTable.readBlockAllocationTable(getBlock(arrayBuffer, BLOCK_ALLOCATION_TABLE_BLOCK_NUMBER)),
GameCubeBlockAllocationTable.readBlockAllocationTable(getBlock(arrayBuffer, BLOCK_ALLOCATION_TABLE_BACKUP_BLOCK_NUMBER)),
);
const volumeInfo = {
...headerInfo,
numTotalBlocks,
numUsedBlocks: numTotalBlocks - blockAllocationTableInfo.numFreeBlocks,
numFreeBlocks: blockAllocationTableInfo.numFreeBlocks,
lastAllocatedBlock: blockAllocationTableInfo.lastAllocatedBlock,
};
const saveFiles = readSaveFiles(directoryInfo.directoryEntries, blockAllocationTableInfo.blockAllocationTable, arrayBuffer, headerInfo.encodingString);
return new GameCubeSaveData(arrayBuffer, saveFiles, volumeInfo);
}
static createFromSaveFiles(saveFiles, volumeInfo) {
const { numTotalBytes, numTotalBlocks } = GameCubeUtil.getTotalSizes(volumeInfo.memcardSizeMegabits);
checkDesiredSize(numTotalBytes);
const headerBlock = GameCubeHeader.writeHeader(volumeInfo);
// Fixup any save files that need it
const saveFilesWithFixUps = saveFiles.map((saveFile) => GameSpecificFixups.fixupSaveFile(saveFile, headerBlock));
// Begin by dividing up the data from our save files into blocks, so we know how many there are
let currentBlock = NUM_RESERVED_BLOCKS;
const saveFilesWithBlockInfo = saveFilesWithFixUps.map((saveFile) => {
const saveStartBlock = currentBlock;
const blockList = [];
for (let currentOffset = 0; currentOffset < saveFile.rawData.byteLength; currentOffset += BLOCK_SIZE) {
blockList.push(saveFile.rawData.slice(currentOffset, currentOffset + BLOCK_SIZE));
}
if (blockList.length > 0) {
let finalBlock = blockList.pop();
finalBlock = Util.padArrayBuffer(finalBlock, BLOCK_SIZE, BLOCK_PADDING_VALUE);
blockList.push(finalBlock);
}
currentBlock += blockList.length;
return {
...saveFile,
saveStartBlock,
saveSizeBlocks: blockList.length,
blockList,
};
});
// The memord card image is the reserved blocks, followed by the data blocks, followed by empty blocks
const directoryBlock = GameCubeDirectory.writeDirectory(saveFilesWithBlockInfo);
const blockAllocationTableBlock = GameCubeBlockAllocationTable.writeBlockAllocationTable(saveFilesWithBlockInfo, numTotalBlocks);
let memcardArrayBuffer = Util.concatArrayBuffers([headerBlock, directoryBlock, directoryBlock, blockAllocationTableBlock, blockAllocationTableBlock]);
saveFilesWithBlockInfo.forEach((saveFile) => { memcardArrayBuffer = Util.concatArrayBuffers([memcardArrayBuffer, ...saveFile.blockList]); });
if (memcardArrayBuffer.byteLength > numTotalBytes) {
throw new Error(`Unable to create a ${volumeInfo.memcardSizeMegabit} megabit card for these save files. Requires ${memcardArrayBuffer.byteLength} bytes but card is only ${numTotalBytes} bytes`);
}
// Fill in the rest of the file with empty blocks
const remainingBlocks = Math.floor((numTotalBytes - memcardArrayBuffer.byteLength) / BLOCK_SIZE);
memcardArrayBuffer = Util.concatArrayBuffers([memcardArrayBuffer, createBlocks(remainingBlocks)]);
return new GameCubeSaveData(memcardArrayBuffer, saveFilesWithBlockInfo, volumeInfo);
}
constructor(arrayBuffer, saveFiles, volumeInfo) {
this.arrayBuffer = arrayBuffer;
this.saveFiles = saveFiles;
this.volumeInfo = volumeInfo;
}
getSaveFiles() {
return this.saveFiles;
}
getVolumeInfo() {
return this.volumeInfo;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/GameCube/GameSpecificFixups/FZeroGx.js
================================================
/* eslint-disable no-bitwise */
// Taken from https://github.com/dolphin-emu/dolphin/blob/059282df6f5a0f1671611fbd72de645916b526cd/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L1068
import GameCubeBasics from '../Components/Basics';
import Util from '../../../util/util';
const { BLOCK_SIZE, LITTLE_ENDIAN } = GameCubeBasics;
const CHECKSUM_OFFSET = 0;
const CHECKSUM_LENGTH = 2;
export default class FZeroGxFixups {
static fixupSaveFile(saveFile, headerSerials) {
// Make sure that we've got the right file and it's the right length
if (saveFile.fileName !== 'f_zero.dat') {
return saveFile;
}
if (Math.ceil(saveFile.rawData.byteLength / BLOCK_SIZE) !== 4) {
return saveFile;
}
// We've got the right file, so let's begin fixing it up
const fixedSaveFile = {
...saveFile,
rawData: Util.copyArrayBuffer(saveFile.rawData),
};
const saveFileRawDataView = new DataView(fixedSaveFile.rawData);
const saveFileRawDataUint8Array = new Uint8Array(fixedSaveFile.rawData);
// Set the new serial numbers
saveFileRawDataView.setUint16(1 * BLOCK_SIZE + 0x0066, (headerSerials.serial1 >>> 16) & 0xFFFF, LITTLE_ENDIAN); // Deconstruct the offsets to make it easier to check this code against Dolphin's
saveFileRawDataView.setUint16(3 * BLOCK_SIZE + 0x1580, (headerSerials.serial2 >>> 16) & 0xFFFF, LITTLE_ENDIAN);
saveFileRawDataView.setUint16(1 * BLOCK_SIZE + 0x0060, headerSerials.serial1 & 0xFFFF, LITTLE_ENDIAN);
saveFileRawDataView.setUint16(1 * BLOCK_SIZE + 0x0200, headerSerials.serial2 & 0xFFFF, LITTLE_ENDIAN);
// Calculate a new 16 bit checksum
let checksum = 0xFFFF;
for (let i = CHECKSUM_OFFSET + CHECKSUM_LENGTH; i < fixedSaveFile.rawData.byteLength; i += 1) {
checksum ^= saveFileRawDataUint8Array[i];
for (let j = 8; j > 0; j -= 1) {
if ((checksum & 1) !== 0) {
checksum = ((checksum >>> 1) ^ 0x8408) >>> 0; // xor will interpret number as negative if high bit is set, but we want unsigned
} else {
checksum >>>= 1;
}
}
}
// Set the new checksum
saveFileRawDataView.setUint16(CHECKSUM_OFFSET, (~checksum) & 0xFFFF, LITTLE_ENDIAN);
return fixedSaveFile;
}
}
================================================
FILE: frontend/src/save-formats/GameCube/GameSpecificFixups/GameSpecificFixups.js
================================================
/* eslint-disable no-bitwise */
/*
There are various games for the GameCube where the save data depends on specifics from the memory card
(usually the serial number), and so moving the save to a different card (or the same card with a different format time)
will result in the game thinking the save is corrupted
*/
import GameCubeBasics from '../Components/Basics';
import FZeroGxFixups from './FZeroGx';
import PhantasyStarOnlineFixups from './PhantasyStarOnline';
const { LITTLE_ENDIAN } = GameCubeBasics;
const FIXUP_CLASSES = [
FZeroGxFixups,
PhantasyStarOnlineFixups,
];
const HEADER_SERIAL_DATA_LENGTH = 32;
// Taken from https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L1249
// and https://github.com/bodgit/gc/blob/main/header.go#L64
// It's a bit confusing that both Dolphin and the gc library above refer to these checksums as "serials", which is
// also the name they give the first 12 bytes of the header.
function calculateHeaderSerials(headerBlock) {
const headerBlockDataView = new DataView(headerBlock);
let serial1 = 0;
let serial2 = 0;
for (let currentOffset = 0; currentOffset < HEADER_SERIAL_DATA_LENGTH; currentOffset += 8) {
serial1 = (serial1 ^ headerBlockDataView.getUint32(currentOffset + 0, LITTLE_ENDIAN)) & 0xFFFFFFFF;
serial2 = (serial2 ^ headerBlockDataView.getUint32(currentOffset + 4, LITTLE_ENDIAN)) & 0xFFFFFFFF;
}
return { serial1, serial2 };
}
export default class GameSpecificFixups {
static fixupSaveFile(saveFile, headerBlock) {
// All of the games so far want the same checksums from the card header
const headerSerials = calculateHeaderSerials(headerBlock);
let fixedSaveFile = saveFile;
FIXUP_CLASSES.forEach((fixupClass) => { fixedSaveFile = fixupClass.fixupSaveFile(fixedSaveFile, headerSerials); });
return fixedSaveFile;
}
}
================================================
FILE: frontend/src/save-formats/GameCube/GameSpecificFixups/PhantasyStarOnline.js
================================================
/* eslint-disable no-bitwise */
// Taken from https://github.com/dolphin-emu/dolphin/blob/059282df6f5a0f1671611fbd72de645916b526cd/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L1123
import GameCubeBasics from '../Components/Basics';
import Util from '../../../util/util';
const { BLOCK_SIZE, LITTLE_ENDIAN } = GameCubeBasics;
const FILENAME_TO_EXTRA_CHECKSUM_LENGTH = new Map([
['PSO_SYSTEM', 0],
['PSO3_SYSTEM', 0x10],
]);
const CRC32_LOOK_UP_TABLE_SIZE = 256;
const CRC32_LOOK_UP_TABLE = new Array(CRC32_LOOK_UP_TABLE_SIZE);
function initializeCrc32LookUpTable() {
let checksum = 0;
for (let i = 0; i < CRC32_LOOK_UP_TABLE_SIZE; i += 1) {
checksum = i;
for (let j = 8; j > 0; j -= 1) {
if ((checksum & 1) !== 0) {
checksum = ((checksum >>> 1) ^ 0xEDB88320) >>> 0; // xor will interpret number as negative if high bit is set, but we want unsigned
} else {
checksum >>>= 1;
}
}
CRC32_LOOK_UP_TABLE[i] = checksum;
}
}
initializeCrc32LookUpTable();
export default class PhantasyStarOnlineFixups {
static fixupSaveFile(saveFile, headerSerials) {
// Make sure that we've got the right file
if (!FILENAME_TO_EXTRA_CHECKSUM_LENGTH.has(saveFile.fileName)) {
return saveFile;
}
// We've got the right file, so let's begin fixing it up
const fixedSaveFile = {
...saveFile,
rawData: Util.copyArrayBuffer(saveFile.rawData),
};
const saveFileRawDataView = new DataView(fixedSaveFile.rawData);
const saveFileRawDataUint8Array = new Uint8Array(fixedSaveFile.rawData);
// Set the new serial numbers
saveFileRawDataView.setUint32(1 * BLOCK_SIZE + 0x0158, headerSerials.serial1, LITTLE_ENDIAN); // Deconstruct the offsets to make it easier to check this code against Dolphin's
saveFileRawDataView.setUint32(1 * BLOCK_SIZE + 0x015C, headerSerials.serial2, LITTLE_ENDIAN);
// Calculate our new checksum
const extraChecksumLength = FILENAME_TO_EXTRA_CHECKSUM_LENGTH.get(fixedSaveFile.fileName);
let checksum = 0xDEBB20E3;
for (let i = 0x004C; i < (0x0164 + extraChecksumLength); i += 1) {
const lookupTableIndex = ((checksum ^ saveFileRawDataUint8Array[1 * BLOCK_SIZE + i]) >>> 0) % CRC32_LOOK_UP_TABLE_SIZE;
checksum = (((checksum >>> 8) & 0xFFFFFF) ^ CRC32_LOOK_UP_TABLE[lookupTableIndex]) >>> 0;
}
saveFileRawDataView.setUint32(1 * BLOCK_SIZE + 0x0048, (checksum ^ 0xFFFFFFFF) >>> 0, LITTLE_ENDIAN);
return fixedSaveFile;
}
}
================================================
FILE: frontend/src/save-formats/GameCube/IndividualSaves/GameShark.js
================================================
// A GameCube GameShark file is a .gci file with a 0x110 byte header prepended
//
// 0x00-0x05: Magic ("GCSAVE")
// 0x10-0x109: Comment (encoding may be either US-ASCII or shift-jis)
// 0x110-EOF: .gci file
import GameCubeGciSaveData from './Gci';
import GameCubeBasics from '../Components/Basics';
import Util from '../../../util/util';
const { BLOCK_SIZE } = GameCubeBasics;
const HEADER_LENGTH = 0x110;
const MAGIC = 'GCSAVE';
const MAGIC_OFFSET = 0;
const MAGIC_ENCODING = 'US-ASCII';
const COMMENT_OFFSET = 0x10;
const COMMENT_LENGTH = HEADER_LENGTH - COMMENT_OFFSET;
export default class GameCubeGameSharkSaveData {
static convertIndividualSaveToSaveFile(arrayBuffer) {
Util.checkMagic(arrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const uint8Array = new Uint8Array(arrayBuffer);
const gciArrayBuffer = arrayBuffer.slice(HEADER_LENGTH);
const saveFile = GameCubeGciSaveData.convertIndividualSaveToSaveFile(gciArrayBuffer, false); // Save size in blocks may be set incorrectly, so don't check it: https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/HW/GCMemcard/GCMemcardUtils.cpp#L162
const gameSharkComment = Util.readNullTerminatedString(uint8Array, COMMENT_OFFSET, saveFile.inferredCommentEncoding, COMMENT_LENGTH);
return {
...saveFile,
saveSizeBlocks: Math.ceil(saveFile.rawData.byteLength / BLOCK_SIZE), // Fix up the save size manually
gameSharkComment,
};
}
}
================================================
FILE: frontend/src/save-formats/GameCube/IndividualSaves/Gci.js
================================================
/* eslint-disable no-bitwise */
/*
The standard format for individual saves on the GameCube appears to be the .GCI format.
There are other potential formats recognized by Dolphin:
- .GCS: https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/HW/GCMemcard/GCMemcardUtils.cpp#L149
- .SAV: https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/HW/GCMemcard/GCMemcardUtils.cpp#L182
Dolphin distinguishes between them based on file size, because the blocks are the same but the header is different: https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/HW/GCMemcard/GCMemcardUtils.cpp#L212
.GCI is a directory entry concatenated with the raw save data
Note that the starting block number in the directory entry is irrelevant here
0x00-0x3F: Directory entry
0x40-EOF: Game data (including the comment and icons)
*/
import Util from '../../../util/util';
import GameCubeBasics from '../Components/Basics';
import GameCubeDirectoryEntry from '../Components/DirectoryEntry';
const { BLOCK_SIZE } = GameCubeBasics;
// Figuring out the encoding of the comments is tricky. In a real save file the encoding is specified in the header.
// However, this user-defined format takes the directory entry from the real format but omits the header.
// It does not appear that we can automatically detect the encoding of the comments.
// The encoding-japanese package that we use to encode/decode shift-jis strings can in theory
// detect encoding, but for our example save files it returns "UTF32" for US-ASCII comments,
// and any one of "UTF32", "UTF16", or "SJIS" for the Japanese ones (which are all in shift-jis)
// We could parse them always using shift-jis because the ASCII stuff will be decoded correctly, with the exception of backslash and tilde and then anything in "extended" ASCII above 0x7F
// But we can maybe do slightly better by trying to infer the encoding from the region of the game.
// Games from the Japan region we'll decode as shift-jis, and the other 2 regions (North American and Europe) we'll decode as US-ASCII
const GAME_CODE_AND_FILE_NAME_ENCODING = 'US-ASCII';
const SHIFT_JIS_COMMENT_REGIONS = ['Japan', 'Korea'];
const DATA_OFFSET = GameCubeDirectoryEntry.LENGTH;
const DEFAULT_START_BLOCK_NUMBER = 0; // Doesn't matter: the concept of where the save is located doesn't mean anything in this format
export default class GameCubeGciSaveData {
static convertSaveFilesToGcis(saveFiles) {
return saveFiles.map((saveFile) => {
const saveFileWithBlockInfo = {
...saveFile,
saveStartBlock: DEFAULT_START_BLOCK_NUMBER,
saveSizeBlocks: saveFile.rawData.byteLength / BLOCK_SIZE,
};
const directoryEntryArrayBuffer = GameCubeDirectoryEntry.writeDirectoryEntry(saveFileWithBlockInfo);
return Util.concatArrayBuffers([directoryEntryArrayBuffer, saveFileWithBlockInfo.rawData]);
});
}
// Based on https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/HW/GCMemcard/GCMemcardUtils.cpp#L131
static convertIndividualSaveToSaveFile(arrayBuffer, checkSaveSizes = true) {
const directoryEntry = GameCubeDirectoryEntry.readDirectoryEntry(arrayBuffer, GAME_CODE_AND_FILE_NAME_ENCODING);
const rawData = arrayBuffer.slice(DATA_OFFSET);
const inferredCommentEncoding = (SHIFT_JIS_COMMENT_REGIONS.indexOf(directoryEntry.region) >= 0) ? 'shift-jis' : 'US-ASCII'; // Note that this can be incorrect for Korean games: they can have the region E (USA)
if (checkSaveSizes && (rawData.byteLength !== (directoryEntry.saveSizeBlocks * BLOCK_SIZE))) {
throw new Error(`File appears to be corrupt. Save size specified as ${directoryEntry.saveSizeBlocks} blocks (${directoryEntry.saveSizeBlocks * BLOCK_SIZE} bytes)`
+ ` but save data is ${rawData.byteLength} bytes`);
}
const comments = GameCubeDirectoryEntry.getComments(directoryEntry.commentStart, rawData, inferredCommentEncoding);
return {
...directoryEntry,
inferredCommentEncoding,
comments,
rawData,
};
}
}
================================================
FILE: frontend/src/save-formats/GameCube/IndividualSaves/IndividualSaves.js
================================================
/* eslint-disable no-bitwise */
/*
There are various formats on GameFAQs for individual save files. Fortunately they have magic in the header
which makes them easy to distinguish between
*/
import GameCubeGameSharkSaveData from './GameShark';
import GameCubeMaxDriveSaveData from './MaxDrive';
import GameCubeGciSaveData from './Gci';
const INDIVIDUAL_SAVE_CLASSES = [
GameCubeGameSharkSaveData,
GameCubeMaxDriveSaveData,
GameCubeGciSaveData, // This one last because it doesn't have any handy magic bytes in the header to easily distinguish it
];
export default class GameCubeIndividualSaves {
static convertIndividualSaveToSaveFile(individualSave) {
for (let i = 0; i < INDIVIDUAL_SAVE_CLASSES.length; i += 1) {
try {
return INDIVIDUAL_SAVE_CLASSES[i].convertIndividualSaveToSaveFile(individualSave);
} catch (e) {
// Move onto the next individual save type
}
}
throw new Error('This does not appear to be a GameCube individual save file');
}
}
================================================
FILE: frontend/src/save-formats/GameCube/IndividualSaves/MaxDrive.js
================================================
// A GameCube MaxDrive file is a .gci file with a 0x80 byte header prepended
//
// Note that many, but not all, of the .gci fields are endian swapped. Specifically, the date and all of the text fields are not swapped.
//
// 0x00-0x0B: Magic ("DATELGC_SAVE")
// 0x10-0x47: Comment 1 (encoding may be either US-ASCII or shift-jis)
// 0x48-0x79: Comment 2
// 0x80-EOF: .gci file
import GameCubeGciSaveData from './Gci';
import Util from '../../../util/util';
const HEADER_LENGTH = 0x80;
const MAGIC = 'DATELGC_SAVE';
const MAGIC_OFFSET = 0;
const MAGIC_ENCODING = 'US-ASCII';
const COMMENT_OFFSETS = [0x10, 0x48];
const COMMENT_LENGTH = 0x38;
const BYTE_SWAP_OFFSETS = [ // https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/HW/GCMemcard/GCMemcardUtils.cpp#L94
0x06, // Banner and icon flags
0x2C, // Icon graphic data offset (4 bytes)
0x2E, // Icon graphic data offset
0x30, // Icon graphic format
0x32, // Icon graphic speed
0x34, // Permission attribute bitfield
0x36, // Starting block number
0x38, // Save size in blocks
0x3A, // Unused
0x3C, // Comment offset (4 bytes)
0x3E, // Comment offset
];
export default class GameCubeMaxDriveSaveData {
static convertIndividualSaveToSaveFile(arrayBuffer) {
Util.checkMagic(arrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const uint8Array = new Uint8Array(arrayBuffer);
const gciArrayBuffer = arrayBuffer.slice(HEADER_LENGTH);
const gciDataView = new DataView(gciArrayBuffer);
BYTE_SWAP_OFFSETS.forEach((offset) => gciDataView.setUint16(offset, gciDataView.getUint16(offset, false), true)); // Weirdly, most but not all entries are a different endianness
const saveFile = GameCubeGciSaveData.convertIndividualSaveToSaveFile(gciArrayBuffer);
const maxDriveComments = COMMENT_OFFSETS.map((commentOffset) => Util.readNullTerminatedString(uint8Array, commentOffset, saveFile.inferredCommentEncoding, COMMENT_LENGTH));
return {
...saveFile,
maxDriveComments,
};
}
}
================================================
FILE: frontend/src/save-formats/GameCube/Util.js
================================================
/* eslint-disable no-bitwise */
import GameCubeBasics from './Components/Basics';
const {
BLOCK_SIZE,
NUM_RESERVED_BLOCKS,
LITTLE_ENDIAN,
} = GameCubeBasics;
// Taken from http://www.surugi.com/projects/gcifaq.html and the FAQ from https://gc-saves.com/
const REGION_DECODE = new Map([
['J', 'Japan'],
['E', 'North America'],
['P', 'Europe'],
['D', 'Germany'],
['F', 'France'],
['H', 'Netherlands'],
['I', 'Italy'],
['S', 'Spain'],
['K', 'Korea'],
['U', 'Korea'],
['W', 'Korea'],
]);
const UNKNOWN_REGION_STRING = 'Unknown';
const UNKNOWN_REGION_CODE = 'X';
// Taken from https://github.com/dolphin-emu/dolphin/blob/ee27f03a4387baca6371a06068274135ff9547a5/Source/Core/Core/HW/GCMemcard/GCMemcard.h#L186
const ENCODING_DECODE = new Map([
[0, 'US-ASCII'],
[1, 'shift-jis'],
]);
const UNKNOWN_ENCODING_STRING = 'Unknown';
const UNKNOWN_ENCODING_CODE = -1;
// Taken from https://www.gc-forever.com/yagcd/chap10.html#sec10.5
const LANGUAGE_DECODE = new Map([
[0, 'English'],
[1, 'German'],
[2, 'French'],
[3, 'Spanish'],
[4, 'Italian'],
[5, 'Dutch'],
]);
const UNKNOWN_LANGUAGE_STRING = 'Unknown';
const UNKNOWN_LANGUAGE_CODE = -1;
// The epoch for Javascript Dates is Jan 1, 1970. For GameCube dates it's Jan 1, 2000: https://github.com/dolphin-emu/dolphin/blob/4f210df86a2d2362ef8087cf81b817b18c3d32e9/Source/Core/Core/HW/EXI/EXI_DeviceIPL.h#L27
const MILLISECONDS_BETWEEN_EPOCHS = 946684800000;
// Numbers to turn a OSTime into a regular time
// Bus operates at 162 MHz. Read games read this value from address 0x800000F8: https://github.com/doldecomp/melee/blob/aa123d0cefebc03046794e5ebc712551fa9b36fa/src/dolphin/os/OSTime.h#L29
// which contains the number 162000000: https://www.gc-forever.com/yagcd/chap4.html
const OS_BUS_CLOCK = 162000000n; // https://github.com/doldecomp/melee/blob/aa123d0cefebc03046794e5ebc712551fa9b36fa/src/dolphin/os/OSTime.h#L31
const OS_TIMER_CLOCK = OS_BUS_CLOCK / 4n; // https://github.com/doldecomp/melee/blob/aa123d0cefebc03046794e5ebc712551fa9b36fa/src/dolphin/os/OSTime.h#L32
function getString(code, decodeMap, unknownString) {
if (decodeMap.has(code)) {
return decodeMap.get(code);
}
return unknownString;
}
function getCode(string, decodeMap, unknownCode) {
const possibleCodes = Array.from(decodeMap.keys());
const code = possibleCodes.find((key) => decodeMap.get(key) === string);
if (code === undefined) {
return unknownCode;
}
return code;
}
export default class GameCubeUtil {
static getRegionString(regionCode) {
return getString(regionCode, REGION_DECODE, UNKNOWN_REGION_STRING);
}
static getRegionCode(regionString) {
return getCode(regionString, REGION_DECODE, UNKNOWN_REGION_CODE);
}
static getEncodingString(encodingCode) {
return getString(encodingCode, ENCODING_DECODE, UNKNOWN_ENCODING_STRING);
}
static getEncodingCode(encodingString) {
return getCode(encodingString, ENCODING_DECODE, UNKNOWN_ENCODING_CODE);
}
static getLanguageString(languageCode) {
return getString(languageCode, LANGUAGE_DECODE, UNKNOWN_LANGUAGE_STRING);
}
static getLanguageCode(languageString) {
return getCode(languageString, LANGUAGE_DECODE, UNKNOWN_LANGUAGE_CODE);
}
static getDate(dateEncoded) {
// Date conversion from: http://www.surugi.com/projects/gcifaq.html
// The GameCube stores the date as the number of seconds since Jan 1, 2000. So to convert to a javascript Date,
// we multiply to get milliseconds, and add the number of milliseconds between Jan 1, 1970 and Jan 1, 2000
return new Date((dateEncoded * 1000) + MILLISECONDS_BETWEEN_EPOCHS);
}
static getDateCode(date) {
return Math.floor((date.valueOf() - MILLISECONDS_BETWEEN_EPOCHS) / 1000);
}
static getDateFromOsTime(osTime) {
// OSTime is a 64 bit BigInt, so can't mix calculations with a Number
const secondsFromEpoch = osTime / OS_TIMER_CLOCK; // https://github.com/doldecomp/melee/blob/aa123d0cefebc03046794e5ebc712551fa9b36fa/src/dolphin/os/OSTime.h#L33
return GameCubeUtil.getDate(Number(secondsFromEpoch));
}
static getOsTimeFromDate(date) {
const dateCode = GameCubeUtil.getDateCode(date);
// BigInt is not defined in our default javascript eslint rules. Doing this properly by making a .eslintrc file specifying using newer rules leads to an awful mess of having to make a giant .eslintrc file to deal with error after error
return BigInt(dateCode) * OS_TIMER_CLOCK; // eslint-disable-line no-undef
}
// Taken from https://github.com/dolphin-emu/dolphin/blob/4f210df86a2d2362ef8087cf81b817b18c3d32e9/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L328
static calculateChecksums(arrayBuffer, beginOffset, length) {
let checksum = 0;
let checksumInverse = 0;
const dataView = new DataView(arrayBuffer);
for (let i = 0; i < length; i += 2) {
checksum += dataView.getUint16(beginOffset + i, LITTLE_ENDIAN);
checksumInverse += dataView.getUint16(beginOffset + i, LITTLE_ENDIAN) ^ 0xFFFF;
// Need to make sure we're always constrained to 16 bits
checksum &= 0xFFFF;
checksumInverse &= 0xFFFF;
}
if (checksum === 0xFFFF) {
checksum = 0;
}
if (checksumInverse === 0xFFFF) {
checksumInverse = 0;
}
return {
checksum,
checksumInverse,
};
}
static megabitsToBytes(numMegabits) {
return ((numMegabits / 8) * 1024 * 1024);
}
static bytesToMegabits(numBytes) {
return (numBytes / (1024 * 1024)) * 8;
}
static getTotalSizes(numMegabits) {
const numTotalBytes = GameCubeUtil.megabitsToBytes(numMegabits);
return {
numTotalBytes,
numTotalBlocks: (numTotalBytes / BLOCK_SIZE) - NUM_RESERVED_BLOCKS,
};
}
}
================================================
FILE: frontend/src/save-formats/Mister/GameGear.js
================================================
/*
The MiSTer makes saves that are 64kB in size and the emulator I tried makes saves that are 32kB. Each seems able to load the other's save.
I'm not sure which of the two sizes is "correct", so I'm just going to leave everything alone.
A list of Game Gear games that support saving can be found here:
https://segaretro.org/Battery_backup#Game_Gear
*/
import SaveFilesUtil from '../../util/SaveFiles';
export default class MisterGameGearSaveData {
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return 'gamegear';
}
static createWithNewSize(misterSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(misterSaveData.getRawArrayBuffer(), newSize);
return MisterGameGearSaveData.createFromRawData(newRawSaveData);
}
static createFromMisterData(misterArrayBuffer) {
return new MisterGameGearSaveData(misterArrayBuffer, misterArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new MisterGameGearSaveData(rawArrayBuffer, rawArrayBuffer);
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/Gameboy.js
================================================
/*
The MiSTer saves GB/GBC data as just the raw data of the correct size: no transformation or padding required
*/
import SaveFilesUtil from '../../util/SaveFiles';
export default class MisterGameboySaveData {
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return 'gb';
}
static createWithNewSize(misterSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(misterSaveData.getRawArrayBuffer(), newSize);
return MisterGameboySaveData.createFromRawData(newRawSaveData);
}
static createFromMisterData(misterArrayBuffer) {
return new MisterGameboySaveData(misterArrayBuffer, misterArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new MisterGameboySaveData(rawArrayBuffer, rawArrayBuffer);
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/GameboyAdvance.js
================================================
/*
MiSTer GBA files are the same as emulator files with 2 exceptions:
- 512B EEPROM files must be padded out to 8192B, otherwise the core does not recognize them. This is because the core is unable to determine the EEPROM size from the ROM
- MiSTer GBA files may have RTC data appended to the end, which will be ignored by emulators so we can remove it
More information is here: https://misterfpga.org/viewtopic.php?t=2040
When going from a raw 8192B EEPROM save, we can't automatically truncate it for the MiSTer because we don't know whether it should be 512B or 8192B.
I think most emulators will accept either size, but regardless the user is able to select to truncate it in the interface.
*/
import SaveFilesUtil from '../../util/SaveFiles';
import PaddingUtil from '../../util/Padding';
import MathUtil from '../../util/Math';
const MISTER_MINIMUM_FILE_SIZE = 8192;
const MISTER_PADDING_VALUE = 0x00; // It doesn't matter what we use here: the core doesn't clear the data from the previous game, so it gets left uninitialized
export default class MisterGameboyAdvanceSaveData {
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return 'gba';
}
static createWithNewSize(misterSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(misterSaveData.getRawArrayBuffer(), newSize);
return MisterGameboyAdvanceSaveData.createFromRawData(newRawSaveData);
}
static createFromMisterData(misterArrayBuffer) {
// Check if we have any RTC data appended
let rawArrayBuffer = misterArrayBuffer;
const hasRtcData = !MathUtil.isPowerOf2(misterArrayBuffer.byteLength);
if (hasRtcData) {
rawArrayBuffer = PaddingUtil.removePaddingFromEnd(misterArrayBuffer, misterArrayBuffer.byteLength - MathUtil.getNextSmallestPowerOf2(misterArrayBuffer.byteLength));
}
return new MisterGameboyAdvanceSaveData(rawArrayBuffer, misterArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new MisterGameboyAdvanceSaveData(rawArrayBuffer, PaddingUtil.padAtEndToMinimumSize(rawArrayBuffer, MISTER_PADDING_VALUE, MISTER_MINIMUM_FILE_SIZE));
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/Genesis.js
================================================
/*
The MiSTer saves Genesis data as bytes (similar to the internal Wii format) rather than shorts like some emulators
Based on https://github.com/superg/srmtools
*/
import PaddingUtil from '../../util/Padding';
import GenesisUtil from '../../util/Genesis';
import SaveFilesUtil from '../../util/SaveFiles';
// Genesis files on the mister are padded out to 64k with 0xFFs.
// The core is apparently pretty lenient on reading unpadded files, but we'll still be friendly and pad ours out.
// There's one ROM hack, Sonic 1 Remastered, that instead requires padding out with 0x00s.
// But we're not going to handle that. How could we?
const MISTER_FILE_SIZE = 65536;
const MISTER_PADDING_VALUE = 0xFF;
export default class MisterGenesisSaveData {
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return 'genesis';
}
static createWithNewSize(misterSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(misterSaveData.getRawArrayBuffer(), newSize);
return MisterGenesisSaveData.createFromRawData(newRawSaveData);
}
static createFromMisterData(misterArrayBuffer) {
// First, we need to unpad the mister save. Otherwise we will byte-expand all the 0xFFs at the end
// which will result in a crazy file
let unpaddedMisterArrayBuffer = misterArrayBuffer;
const padding = PaddingUtil.getPadFromEndValueAndCount(misterArrayBuffer);
// The Genesis MiSTer core can actually output files with either padding value.
// I guess if it gets a file padded with 0x00 then it just maintains that?
// So althugh it's tempting to only remove padding here if it's the MISTER_PADDING_VALUE
// it seems to be more correct to always remove it
unpaddedMisterArrayBuffer = PaddingUtil.removePaddingFromEnd(misterArrayBuffer, padding.count);
if (GenesisUtil.isEepromSave(unpaddedMisterArrayBuffer)) {
// If it's an EEPROM save, an emulator will want it to not be byte expanded
return new MisterGenesisSaveData(unpaddedMisterArrayBuffer, unpaddedMisterArrayBuffer);
}
// Now that the padding is gone, we can proceed
const rawArrayBuffer = GenesisUtil.byteExpand(unpaddedMisterArrayBuffer, 0x00);
return new MisterGenesisSaveData(rawArrayBuffer, misterArrayBuffer); // Note that we're passing through the padded file here as the mister file
}
static createFromRawData(rawArrayBuffer) {
// The mister takes all of its files as non-byte-expanded, whether they are SRAM/FRAM or EEPROM
// Genesis EEPROM saves don't have either kind of strange byte expansion to work in an emulator that the SRAM and FRAM saves
// for the Genesis do. And so it works as-is on a MiSTer.
//
// But, the user may not know that, and try to convert their save when trying to use it
// on a MiSTer.
//
// Rather than display an error, which may mislead the user into not using the tool for other
// subsequent files that DO require conversion, let's just silently pass back the same file (but add padding)
// and pretend we converted it.
//
// This only applies to a really small list of games, so whichever tactic we choose here
// won't have much of an impact (hopefully!)
let unpaddedMisterArrayBuffer = rawArrayBuffer;
if (GenesisUtil.isByteExpanded(rawArrayBuffer)) {
unpaddedMisterArrayBuffer = GenesisUtil.byteCollapse(rawArrayBuffer);
}
return new MisterGenesisSaveData(rawArrayBuffer, PaddingUtil.padAtEndToMinimumSize(unpaddedMisterArrayBuffer, MISTER_PADDING_VALUE, MISTER_FILE_SIZE));
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/N64Cart.js
================================================
/*
We've split up the N64 into cart and mempack because a mempack image is the same size as an SRAM image,
So if the mempack was corrupted we wouldn't be able to disambiguate that and would
assume the image was SRAM which may be confusing to the user
MiSTer N64 cart saves are stored with the same endianness as emulator saves, so no transformation is required
*/
import SaveFilesUtil from '../../util/SaveFiles';
import N64Util from '../../util/N64';
export default class MisterN64CartSaveData {
static getMisterFileExtension(arrayBuffer) {
return N64Util.getFileExtension(arrayBuffer);
}
static getRawFileExtension(arrayBuffer) {
return N64Util.getFileExtension(arrayBuffer);
}
static adjustOutputSizesPlatform() {
return 'n64';
}
static createWithNewSize(misterSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(misterSaveData.getRawArrayBuffer(), newSize);
return MisterN64CartSaveData.createFromRawData(newRawSaveData);
}
static createFromMisterData(misterArrayBuffer) {
return new MisterN64CartSaveData(misterArrayBuffer, misterArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new MisterN64CartSaveData(rawArrayBuffer, rawArrayBuffer);
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/N64Mempack.js
================================================
/*
The MiSTer saves N64 mempack data as just a raw memory card image: no transformation requuired
*/
import N64MempackSaveData from '../N64/Mempack';
export default class MisterN64MempackSaveData {
static getMisterFileExtension() {
return 'cpk';
}
static getRawFileExtension() {
return 'mpk';
}
static adjustOutputSizesPlatform() {
return null; // File size is always one memory card
}
static createWithNewSize(misterSaveData) {
return misterSaveData;
}
static createFromMisterData(misterArrayBuffer) {
const n64MempackSaveData = N64MempackSaveData.createFromN64MempackData(misterArrayBuffer); // Parse the data so we can display an error if it's not in the correct format
return new MisterN64MempackSaveData(n64MempackSaveData.getArrayBuffer(), n64MempackSaveData.getArrayBuffer());
}
static createFromRawData(rawArrayBuffer) {
const n64MempackSaveData = N64MempackSaveData.createFromN64MempackData(rawArrayBuffer); // Parse the data so we can display an error if it's not in the correct format
return new MisterN64MempackSaveData(n64MempackSaveData.getArrayBuffer(), n64MempackSaveData.getArrayBuffer());
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/Nes.js
================================================
/*
The MiSTer saves NES data as a regular raw file. If the NES ROM has an iNES header, then the MiSTer core will make a 32kB save.
If it's a NES 2.0 headered ROM, then the MiSTer core will make a correct (usually 8kB) sized save.
If we made a file smaller than 32kB, the MiSTer core will leave the rest of the memory uninitialized (which shouldn't cause any harm),
but let's be friendly and pad out our file to 32kB. Beyond that mark, the MiSTer core will start mirroring addresses (whatever that means :))
Loading these too-large 32kB files straight in an emulator seems to work fine. Since we don't know what the actual size should be (even
though it's usually 8kB) then let's just leave them alone.
There are some games that will cause the MiSTer core to make a larger (e.g. 128kB) save.
*/
import PaddingUtil from '../../util/Padding';
import SaveFilesUtil from '../../util/SaveFiles';
const MISTER_MINIMUM_FILE_SIZE = 32768;
const MISTER_PADDING_VALUE = 0x00; // Not sure what to choose for padding, given that actual MiSTer files are filled with garbage data
export default class MisterNesSaveData {
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return 'nes';
}
static createWithNewSize(misterSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(misterSaveData.getRawArrayBuffer(), newSize);
return MisterNesSaveData.createFromRawData(newRawSaveData);
}
static createFromMisterData(misterArrayBuffer) {
// Just copy it straight over, because we don't know what size to truncate to: not all NES saves are 8kB
return new MisterNesSaveData(misterArrayBuffer, misterArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
// Pad our file out to the minimum MiSTer size, just to be nice
return new MisterNesSaveData(rawArrayBuffer, PaddingUtil.padAtEndToMinimumSize(rawArrayBuffer, MISTER_PADDING_VALUE, MISTER_MINIMUM_FILE_SIZE));
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/PcEngine.js
================================================
/*
The MiSTer saves PC Engine data as just the raw data of the correct size: no transformation or padding required
We can do a little checking to make sure it's in the correct format
*/
import PcEngineUtil from '../../util/PcEngine';
export default class MisterPcEngineSaveData {
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return null; // Not sure if anything writes out BRAM that's larger/smaller than the real one
}
static createWithNewSize(misterSaveData) {
return misterSaveData;
}
static createFromMisterData(misterArrayBuffer) {
PcEngineUtil.verifyPcEngineData(misterArrayBuffer);
return new MisterPcEngineSaveData(misterArrayBuffer, misterArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
PcEngineUtil.verifyPcEngineData(rawArrayBuffer);
return new MisterPcEngineSaveData(rawArrayBuffer, rawArrayBuffer);
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/Ps1.js
================================================
/*
The MiSTer saves PS1 data as just a raw memory card image: no transformation requuired
*/
import Ps1MemcardSaveData from '../PS1/Memcard';
export default class MisterPs1SaveData {
static getMisterFileExtension() {
return 'mcd';
}
static getRawFileExtension() {
return 'mcr';
}
static adjustOutputSizesPlatform() {
return null; // File size is always one memory card
}
static createWithNewSize(misterSaveData) {
return misterSaveData;
}
static createFromMisterData(misterArrayBuffer) {
const ps1MemcardSaveData = Ps1MemcardSaveData.createFromPs1MemcardData(misterArrayBuffer); // Parse the data so we can display an error if it's not in the correct format
return new MisterPs1SaveData(ps1MemcardSaveData.getArrayBuffer(), ps1MemcardSaveData.getArrayBuffer());
}
static createFromRawData(rawArrayBuffer) {
const ps1MemcardSaveData = Ps1MemcardSaveData.createFromPs1MemcardData(rawArrayBuffer); // Parse the data so we can display an error if it's not in the correct format
return new MisterPs1SaveData(ps1MemcardSaveData.getArrayBuffer(), ps1MemcardSaveData.getArrayBuffer());
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/SegaCd.js
================================================
/*
The Mister Sega CD save file is a single file with the internal backup RAM (8kB) concatenated with the cartridge backup RAM (set to 512kB)
Most other platforms store 2 files, but the Mister stores just one for consistency with its other cores.
Once the file is parsed, like all platforms the Mister interfaces with Sega CD data via its BIOS, meaning that all saves are the same.
In the example saves I was given, one of them was a 520kB (8kB + 512kB) file but without the BRAM_FORMAT at the end of the second section
(that section was completely blank). So I guess that part wasn't valid, and we should just truncate the file.
*/
import SegaCdUtil from '../../util/SegaCd';
import Util from '../../util/util';
import SegaError from '../SegaError';
export default class MisterSegaCdSaveData {
static INTERNAL_MEMORY = 'internal-memory';
static RAM_CART = 'ram-cart';
static RAM_CART_SIZE = 524288; // Both the Mister and the emulators I've seen produce RAM cart files of this size
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'brm';
}
static adjustOutputSizesPlatform() {
return 'segacd';
}
static createWithNewSize(misterSaveData, newSize) {
// Just leave the mister data alone: it can't load anything other than the original 512kB ram cart size.
// But other platforms/emulators may need a different ram cart size
const newRawRamCartArrayBuffer = SegaCdUtil.resize(misterSaveData.rawCartSaveArrayBuffer, newSize);
return new MisterSegaCdSaveData(misterSaveData.rawInternalSaveArrayBuffer, newRawRamCartArrayBuffer, misterSaveData.misterArrayBuffer);
}
static createFromMisterData(misterArrayBuffer) {
// The file can be either just internal backup RAM (potentially with padding afterward),
// or internal backup RAM concatenated with RAM cart backup RAM
if (misterArrayBuffer.byteLength === (SegaCdUtil.INTERNAL_SAVE_SIZE + MisterSegaCdSaveData.RAM_CART_SIZE)) {
let internalArrayBuffer = misterArrayBuffer.slice(0, SegaCdUtil.INTERNAL_SAVE_SIZE);
let ramCartArrayBuffer = misterArrayBuffer.slice(SegaCdUtil.INTERNAL_SAVE_SIZE, SegaCdUtil.INTERNAL_SAVE_SIZE + MisterSegaCdSaveData.RAM_CART_SIZE);
internalArrayBuffer = SegaCdUtil.isCorrectlyFormatted(internalArrayBuffer) ? SegaCdUtil.truncateToActualSize(internalArrayBuffer) : SegaCdUtil.makeEmptySave(SegaCdUtil.INTERNAL_SAVE_SIZE);
ramCartArrayBuffer = SegaCdUtil.isCorrectlyFormatted(ramCartArrayBuffer) ? SegaCdUtil.truncateToActualSize(ramCartArrayBuffer) : SegaCdUtil.makeEmptySave(MisterSegaCdSaveData.RAM_CART_SIZE);
return new MisterSegaCdSaveData(internalArrayBuffer, ramCartArrayBuffer, misterArrayBuffer);
}
return new MisterSegaCdSaveData(SegaCdUtil.truncateToActualSize(misterArrayBuffer), SegaCdUtil.makeEmptySave(MisterSegaCdSaveData.RAM_CART_SIZE), misterArrayBuffer);
}
static createFromRawData({ rawInternalSaveArrayBuffer = null, rawCartSaveArrayBuffer = null }) {
// We can output either a large (8kB + 512kB) save or a small (only 8kB) save depending on
// whether we're passed an internal save buffer and/or a ram cart save buffer.
// There are 4 cases, depending on which combination of raw file we are passed.
let truncatedRawInternalSaveBuffer = null;
let truncatedRawCartSaveArrayBuffer = null;
let misterRamCartSaveArrayBuffer = null;
// since either file (or both files) could have errors, throw an object with all errors collected
const conversionErrors = { internalSaveError: null, ramCartError: null };
if (rawInternalSaveArrayBuffer !== null) {
try {
truncatedRawInternalSaveBuffer = SegaCdUtil.truncateToActualSize(rawInternalSaveArrayBuffer);
if (truncatedRawInternalSaveBuffer.byteLength !== SegaCdUtil.INTERNAL_SAVE_SIZE) {
conversionErrors.internalSaveError = `Internal save RAM is not the correct size. Must be ${SegaCdUtil.INTERNAL_SAVE_SIZE} bytes`;
}
} catch (error) {
conversionErrors.internalSaveError = error.message;
}
}
if (rawCartSaveArrayBuffer !== null) {
try {
truncatedRawCartSaveArrayBuffer = SegaCdUtil.truncateToActualSize(rawCartSaveArrayBuffer);
misterRamCartSaveArrayBuffer = SegaCdUtil.resize(truncatedRawCartSaveArrayBuffer, MisterSegaCdSaveData.RAM_CART_SIZE);
} catch (error) {
conversionErrors.ramCartError = error.message;
}
}
if ((conversionErrors.internalSaveError !== null) || (conversionErrors.ramCartError !== null)) {
throw new SegaError(conversionErrors.internalSaveError, conversionErrors.ramCartError);
}
// Now that we've got our pieces resized and ready, we can see what we've got and figure out
// whether to create a small or large mister file
if (truncatedRawInternalSaveBuffer !== null) {
if (misterRamCartSaveArrayBuffer !== null) {
// We have both pieces, so we're creating a large mister file
return new MisterSegaCdSaveData(truncatedRawInternalSaveBuffer, truncatedRawCartSaveArrayBuffer, Util.concatArrayBuffers([truncatedRawInternalSaveBuffer, misterRamCartSaveArrayBuffer]));
}
// We have the internal save data but not the ram cart save data, so create a small mister file
return new MisterSegaCdSaveData(truncatedRawInternalSaveBuffer, SegaCdUtil.makeEmptySave(MisterSegaCdSaveData.RAM_CART_SIZE), truncatedRawInternalSaveBuffer);
}
// We don't have an internal save buffer
const emptyInternalSaveBuffer = SegaCdUtil.makeEmptySave(SegaCdUtil.INTERNAL_SAVE_SIZE);
if (misterRamCartSaveArrayBuffer !== null) {
// We have only the ram cart data, so create a large mister file
return new MisterSegaCdSaveData(emptyInternalSaveBuffer, truncatedRawCartSaveArrayBuffer, Util.concatArrayBuffers([emptyInternalSaveBuffer, misterRamCartSaveArrayBuffer]));
}
// We were given neither an internal nor a ram cart save buffer, so return a small mister file
return new MisterSegaCdSaveData(emptyInternalSaveBuffer, SegaCdUtil.makeEmptySave(MisterSegaCdSaveData.RAM_CART_SIZE), emptyInternalSaveBuffer);
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawInternalSaveArrayBuffer, rawCartSaveArrayBuffer, misterArrayBuffer) {
this.rawInternalSaveArrayBuffer = rawInternalSaveArrayBuffer;
this.rawCartSaveArrayBuffer = rawCartSaveArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer(index = MisterSegaCdSaveData.INTERNAL_MEMORY) {
switch (index) {
case MisterSegaCdSaveData.INTERNAL_MEMORY:
return this.rawInternalSaveArrayBuffer;
case MisterSegaCdSaveData.RAM_CART:
return this.rawCartSaveArrayBuffer;
default:
throw new Error(`Unknown index: ${index}`);
}
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/SegaSaturn.js
================================================
/*
The Saturn core on the MiSTer stores out regular Saturn BIOS files, except "byte expanded".
The cartridge memory is optionally appended to the internal memory -- the same as how the Sega CD
does it: https://github.com/MiSTer-devel/Saturn_MiSTer/issues/283
*/
import SegaSaturnSaveData from '../SegaSaturn/SegaSaturn';
import EmulatorSegaSaturnSaveData from '../SegaSaturn/Emulators/Emulators';
import GenesisUtil from '../../util/Genesis';
import Util from '../../util/util';
const MISTER_INTERNAL_SAVE_SIZE = SegaSaturnSaveData.INTERNAL_SAVE_SIZE * 2;
const MISTER_CARTRIDGE_SAVE_SIZE = SegaSaturnSaveData.CARTRIDGE_SAVE_SIZE * 2;
const MISTER_PADDING_VALUE = 0xFF;
export default class MisterSegaSaturnSaveData {
static INTERNAL_MEMORY = 'internal-memory';
static RAM_CART = 'ram-cart';
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension(saveType) {
return (saveType === 'internal-memory') ? 'bkr' : 'bcr';
}
static adjustOutputSizesPlatform() {
return null; // The internal and cart sizes are fixed
}
static createWithNewSize(/* misterSaveData, newSize */) {
/*
// Just leave the mister data alone: it can't load anything other than the original 512kB ram cart size.
// But other platforms/emulators may need a different ram cart size
const newRawCartArrayBuffer = SegaCdUtil.resize(misterSaveData.rawRamCartSaveArrayBuffer, newSize);
return new MisterSegaSaturnSaveData(misterSaveData.rawInternalSaveArrayBuffer, newRawRamCartArrayBuffer, misterSaveData.misterArrayBuffer);
*/
}
static createFromMisterData(misterArrayBuffer) {
// The file can be either just internal backup memory,
// or internal backup memory concatenated with cart backup memory
let internalArrayBuffer = misterArrayBuffer.slice(0, MISTER_INTERNAL_SAVE_SIZE);
let cartArrayBuffer = GenesisUtil.byteExpand(SegaSaturnSaveData.createEmptySave(SegaSaturnSaveData.CARTRIDGE_BLOCK_SIZE), MISTER_PADDING_VALUE);
if (misterArrayBuffer.byteLength === (MISTER_INTERNAL_SAVE_SIZE + MISTER_CARTRIDGE_SAVE_SIZE)) {
cartArrayBuffer = misterArrayBuffer.slice(MISTER_INTERNAL_SAVE_SIZE, MISTER_INTERNAL_SAVE_SIZE + MISTER_CARTRIDGE_SAVE_SIZE);
}
// With this core the file can contain garbage data in the "expanded" bytes, so testing with GenesisUtil.isByteExpanded()
// will give false negatives. Instead we just blindly byte collapse it and then test the format of the resultant file.
// Sega Saturn files have a distinct signature at the start of the file so this test should be sufficient.
internalArrayBuffer = GenesisUtil.byteCollapse(internalArrayBuffer);
cartArrayBuffer = GenesisUtil.byteCollapse(cartArrayBuffer);
if (!SegaSaturnSaveData.isCorrectlyFormatted(internalArrayBuffer) || !SegaSaturnSaveData.isCorrectlyFormatted(cartArrayBuffer)) {
throw new Error('This does not appear to be a MiSTer Sega Saturn save file');
}
return new MisterSegaSaturnSaveData(internalArrayBuffer, cartArrayBuffer, misterArrayBuffer);
}
static createFromRawData({ rawInternalSaveArrayBuffer = null, rawCartSaveArrayBuffer = null }) {
// We can output either a large (64kB + 1024kB) save or a small (only 64kB) save depending on
// whether we're passed an internal save buffer and/or a ram cart save buffer.
// There are 4 cases, depending on which combination of raw file we are passed.
// Our raw files may come from an emulator, in which case they may come in some slightly diffently formats and we need to get the actual raw data
let actualRawInternalSaveArrayBuffer = null;
let actualRawCartSaveArrayBuffer = null;
if (rawInternalSaveArrayBuffer !== null) {
actualRawInternalSaveArrayBuffer = EmulatorSegaSaturnSaveData.createFromSegaSaturnData(rawInternalSaveArrayBuffer).getArrayBuffer();
if (actualRawInternalSaveArrayBuffer.byteLength !== SegaSaturnSaveData.INTERNAL_SAVE_SIZE) {
throw new Error('This does not appear to be an internal Sega Saturn save file');
}
}
if (rawCartSaveArrayBuffer !== null) {
actualRawCartSaveArrayBuffer = EmulatorSegaSaturnSaveData.createFromSegaSaturnData(rawCartSaveArrayBuffer).getArrayBuffer();
if (actualRawCartSaveArrayBuffer.byteLength !== SegaSaturnSaveData.CARTRIDGE_SAVE_SIZE) {
throw new Error('This does not appear to be a Sega Saturn cartridge save file');
}
}
// Now that we've got our pieces resized and ready, we can see what we've got and figure out
// whether to create a small or large mister file
const emptyInternalSaveBuffer = SegaSaturnSaveData.createEmptySave(SegaSaturnSaveData.INTERNAL_BLOCK_SIZE);
const emptyCartSaveBuffer = SegaSaturnSaveData.createEmptySave(SegaSaturnSaveData.CARTRIDGE_BLOCK_SIZE);
if (actualRawInternalSaveArrayBuffer !== null) {
if (actualRawCartSaveArrayBuffer !== null) {
// We have both pieces, so we're creating a large mister file
return new MisterSegaSaturnSaveData(
actualRawInternalSaveArrayBuffer,
actualRawCartSaveArrayBuffer,
GenesisUtil.byteExpand(Util.concatArrayBuffers([actualRawInternalSaveArrayBuffer, actualRawCartSaveArrayBuffer]), MISTER_PADDING_VALUE),
);
}
// We have the internal save data but not the ram cart save data, so create a small mister file
return new MisterSegaSaturnSaveData(
actualRawInternalSaveArrayBuffer,
emptyCartSaveBuffer,
GenesisUtil.byteExpand(actualRawInternalSaveArrayBuffer, MISTER_PADDING_VALUE),
);
}
// We don't have an internal save buffer
if (actualRawCartSaveArrayBuffer !== null) {
// We have only the ram cart data, so create a large mister file
return new MisterSegaSaturnSaveData(
emptyInternalSaveBuffer,
actualRawCartSaveArrayBuffer,
GenesisUtil.byteExpand(Util.concatArrayBuffers([emptyInternalSaveBuffer, actualRawCartSaveArrayBuffer]), MISTER_PADDING_VALUE),
);
}
// We were given neither an internal nor a ram cart save buffer, so return a small mister file
return new MisterSegaSaturnSaveData(
emptyInternalSaveBuffer,
emptyCartSaveBuffer,
GenesisUtil.byteExpand(emptyInternalSaveBuffer, MISTER_PADDING_VALUE),
);
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawInternalSaveArrayBuffer, rawCartSaveArrayBuffer, misterArrayBuffer) {
this.rawInternalSaveArrayBuffer = rawInternalSaveArrayBuffer;
this.rawCartSaveArrayBuffer = rawCartSaveArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer(index = MisterSegaSaturnSaveData.INTERNAL_MEMORY) {
switch (index) {
case MisterSegaSaturnSaveData.INTERNAL_MEMORY:
return this.rawInternalSaveArrayBuffer;
case MisterSegaSaturnSaveData.RAM_CART:
return this.rawCartSaveArrayBuffer;
default:
throw new Error(`Unknown index: ${index}`);
}
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/Sms.js
================================================
/*
Looks to be similar to NES, as the MiSTer pads out the save to 32kB
Opening one of these large MiSTerfiles in an emulator seems to work fine, but I don't think any of the test files I have actually
contain saved data so I can't be sure.
*/
import PaddingUtil from '../../util/Padding';
import SaveFilesUtil from '../../util/SaveFiles';
const MISTER_MINIMUM_FILE_SIZE = 32768;
const MISTER_PADDING_VALUE = 0x00;
export default class MisterSmsSaveData {
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return 'sms';
}
static createWithNewSize(misterSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(misterSaveData.getRawArrayBuffer(), newSize);
return MisterSmsSaveData.createFromRawData(newRawSaveData);
}
static createFromMisterData(misterArrayBuffer) {
// Just copy it straight over, because we don't know what size to truncate to: not all NES saves are 8kB
return new MisterSmsSaveData(misterArrayBuffer, misterArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
// Pad our file out to the minimum MiSTer size, just to be nice
return new MisterSmsSaveData(rawArrayBuffer, PaddingUtil.padAtEndToMinimumSize(rawArrayBuffer, MISTER_PADDING_VALUE, MISTER_MINIMUM_FILE_SIZE));
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/Snes.js
================================================
/*
The MiSTer saves SNES data as just the raw data of the correct size: no transformation or padding required
*/
import SaveFilesUtil from '../../util/SaveFiles';
export default class MisterSnesSaveData {
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return 'snes';
}
static createWithNewSize(misterSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(misterSaveData.getRawArrayBuffer(), newSize);
return MisterSnesSaveData.createFromRawData(newRawSaveData);
}
static createFromMisterData(misterArrayBuffer) {
return new MisterSnesSaveData(misterArrayBuffer, misterArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new MisterSnesSaveData(rawArrayBuffer, rawArrayBuffer);
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/Mister/WonderSwan.js
================================================
/*
The MiSTer saves WonderSwan data with an extra sector of data at the end for realtime clock information
See https://github.com/MiSTer-devel/WonderSwan_MiSTer/issues/10 for more information
*/
import PaddingUtil from '../../util/Padding';
import MathUtil from '../../util/Math';
const MISTER_REALTIME_CLOCK_SIZE = 0x200;
const MISTER_PADDING_VALUE = 0x00;
function addUninitializedRealtimeClockData(inputArrayBuffer) {
const padding = {
value: MISTER_PADDING_VALUE,
count: MISTER_REALTIME_CLOCK_SIZE,
};
return PaddingUtil.addPaddingToEnd(inputArrayBuffer, padding);
}
function removeRealtimeClockData(inputArrayBuffer) {
if ((inputArrayBuffer.byteLength <= MISTER_REALTIME_CLOCK_SIZE) || !MathUtil.isPowerOf2(inputArrayBuffer.byteLength - MISTER_REALTIME_CLOCK_SIZE)) {
throw new Error('File does not appear to be in the MiSTer WonderSwan format');
}
return PaddingUtil.removePaddingFromEnd(inputArrayBuffer, MISTER_REALTIME_CLOCK_SIZE);
}
export default class MisterWonderSwanSaveData {
static getMisterFileExtension() {
return 'sav';
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return null; // No way to write WonderSwan games to real carts that I'm aware of, so prob no need to be picky about sizes?
}
static createWithNewSize(misterSaveData) {
return misterSaveData;
}
static createFromMisterData(misterArrayBuffer) {
return new MisterWonderSwanSaveData(removeRealtimeClockData(misterArrayBuffer), misterArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
// Pad our file out for the MiSTer clock data, just to be nice
return new MisterWonderSwanSaveData(rawArrayBuffer, addUninitializedRealtimeClockData(rawArrayBuffer));
}
// This constructor creates a new object from a binary representation of a MiSTer save data file
constructor(rawArrayBuffer, misterArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.misterArrayBuffer = misterArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getMisterArrayBuffer() {
return this.misterArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/N64/Components/Basics.js
================================================
import Util from '../../../util/util';
export default class N64Basics {
static LITTLE_ENDIAN = false;
static NUM_NOTES = 16; // A "note" is a save slot. It consists of >= 1 pages
static NUM_PAGES = 128;
static PAGE_SIZE = 256;
static TOTAL_MEMPACK_SIZE = N64Basics.NUM_PAGES * N64Basics.PAGE_SIZE;
static FIRST_SAVE_DATA_PAGE = 5;
static createEmptyBlock(size) {
return Util.getFilledArrayBuffer(size, 0x00);
}
}
================================================
FILE: frontend/src/save-formats/N64/Components/GameSerialCodeUtil.js
================================================
const GAME_SERIAL_CODE_MEDIA_INDEX = 0;
const GAME_SERIAL_CODE_REGION_INDEX = 3;
// Taken from https://github.com/bryc/mempak/blob/master/js/codedb.js#L88
const REGION_CODE_TO_NAME = {
A: 'All regions',
B: 'Brazil', // Unlicensed?
C: 'China', // Unused?
D: 'Germany',
E: 'North America',
F: 'France',
G: 'Gateway 64 (NTSC)',
H: 'Netherlands', // Unused. GC/Wii only.
I: 'Italy',
J: 'Japan',
K: 'South Korea', // Unused. GC/Wii only.
L: 'Gateway 64 (PAL)',
P: 'Europe',
R: 'Russia', // Unused. Wii only.
S: 'Spain',
U: 'Australia', // Although some AU games used standard P codes.
W: 'Taiwan', // Unused. GC/Wii only.
X: 'Europe', // Alternative PAL version (Other languages)
Y: 'Europe', // Alternative PAL version (Other languages)
Z: 'Europe', // Unused. Alternative PAL version 3. Possibly Wii only.
};
const GAMESHARK_ACTIONREPLAY_CART_SAVE_GAME_SERIAL_CODE = '\x3B\xAD\xD1\xE5';
const GAMESHARK_ACTIONREPLAY_CART_SAVE_PUBLISHER_CODE = '\xFA\xDE';
const BLACKBAG_CART_SAVE_GAME_SERIAL_CODE = '\xDE\xAD\xBE\xEF'; // #cute
const BLACKBAG_CART_SAVE_PUBLISHER_CODE = '\x12\x34'; // #cute
export default class N64GameSerialCodeUtil {
static GAMESHARK_ACTIONREPLAY_CART_SAVE_GAME_SERIAL_CODE = GAMESHARK_ACTIONREPLAY_CART_SAVE_GAME_SERIAL_CODE;
static GAMESHARK_ACTIONREPLAY_CART_SAVE_PUBLISHER_CODE = GAMESHARK_ACTIONREPLAY_CART_SAVE_PUBLISHER_CODE;
static GAMESHARK_ACTIONREPLAY_CART_SAVE_REGION_CODE = N64GameSerialCodeUtil.getRegionCode(GAMESHARK_ACTIONREPLAY_CART_SAVE_GAME_SERIAL_CODE);
static GAMESHARK_ACTIONREPLAY_CART_SAVE_MEDIA_CODE = N64GameSerialCodeUtil.getMediaCode(GAMESHARK_ACTIONREPLAY_CART_SAVE_GAME_SERIAL_CODE);
static BLACKBAG_CART_SAVE_GAME_SERIAL_CODE = BLACKBAG_CART_SAVE_GAME_SERIAL_CODE;
static BLACKBAG_CART_SAVE_PUBLISHER_CODE = BLACKBAG_CART_SAVE_PUBLISHER_CODE;
static BLACKBAG_CART_SAVE_REGION_CODE = N64GameSerialCodeUtil.getRegionCode(BLACKBAG_CART_SAVE_GAME_SERIAL_CODE);
static BLACKBAG_CART_SAVE_MEDIA_CODE = N64GameSerialCodeUtil.getMediaCode(BLACKBAG_CART_SAVE_GAME_SERIAL_CODE);
static getRegionCode(gameSerialCode) {
return gameSerialCode.charAt(GAME_SERIAL_CODE_REGION_INDEX);
}
static getMediaCode(gameSerialCode) {
return gameSerialCode.charAt(GAME_SERIAL_CODE_MEDIA_INDEX);
}
static getRegionName(gameSerialCode) {
const regionCode = N64GameSerialCodeUtil.getRegionCode(gameSerialCode);
if (regionCode in REGION_CODE_TO_NAME) {
return REGION_CODE_TO_NAME[regionCode];
}
return 'Unknown region';
}
static isCartSave(saveFile) {
return ((saveFile.gameSerialCode === GAMESHARK_ACTIONREPLAY_CART_SAVE_GAME_SERIAL_CODE) || (saveFile.gameSerialCode === BLACKBAG_CART_SAVE_GAME_SERIAL_CODE));
}
}
================================================
FILE: frontend/src/save-formats/N64/Components/IdArea.js
================================================
/* eslint-disable no-bitwise */
import Util from '../../../util/util';
import N64Basics from './Basics';
const {
LITTLE_ENDIAN,
PAGE_SIZE,
} = N64Basics;
const ID_AREA_BLOCK_SIZE = 32;
const ID_AREA_CHECKSUM_OFFSETS = [0x20, 0x60, 0x80, 0xC0]; // 4 different checksum in the ID Area, and if any of them match then the data is deemed valid
const ID_AREA_CHECKSUM_LENGTH = 28;
const ID_AREA_DEVICE_OFFSET = 25;
const ID_AREA_BANK_SIZE_OFFSET = 26;
const ID_AREA_CHECKSUM_DESIRED_SUM_A_OFFSET = 28;
const ID_AREA_CHECKSUM_DESIRED_SUM_B_OFFSET = 30;
const DEVICE_CONTROLLER_PAK = 0x1;
const BANK_SIZE = 0x1;
function randomByte(randomNumberGenerator = null) {
const rng = (randomNumberGenerator !== null) ? randomNumberGenerator : Math.random;
return 0 | rng() * 256;
}
// From https://github.com/bryc/mempak/blob/master/js/parser.js#L130
function calculateChecksumsOfBlock(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let sumA = 0x0;
let sumB = 0xFFF2;
for (let i = 0; i < ID_AREA_CHECKSUM_LENGTH; i += 2) {
sumA += dataView.getUint16(i, LITTLE_ENDIAN);
sumA &= 0xFFFF;
}
sumB -= sumA;
return {
sumA,
sumB,
};
}
export default class N64IdArea {
// Based on https://github.com/bryc/mempak/blob/master/js/state.js#L13
static createIdAreaPage(randomNumberGenerator = null) {
// This page is 4 copies of the same block at different offsets
const checksumBlock = new ArrayBuffer(ID_AREA_BLOCK_SIZE);
const checksumBlockDataView = new DataView(checksumBlock);
checksumBlockDataView.setUint8(1, randomByte(randomNumberGenerator) & 0x3F);
checksumBlockDataView.setUint8(5, randomByte(randomNumberGenerator) & 0x7);
checksumBlockDataView.setUint8(6, randomByte(randomNumberGenerator));
checksumBlockDataView.setUint8(7, randomByte(randomNumberGenerator));
checksumBlockDataView.setUint8(8, randomByte(randomNumberGenerator) & 0xF);
checksumBlockDataView.setUint8(9, randomByte(randomNumberGenerator));
checksumBlockDataView.setUint8(10, randomByte(randomNumberGenerator));
checksumBlockDataView.setUint8(11, randomByte(randomNumberGenerator));
checksumBlockDataView.setUint8(ID_AREA_DEVICE_OFFSET, DEVICE_CONTROLLER_PAK);
checksumBlockDataView.setUint8(ID_AREA_BANK_SIZE_OFFSET, BANK_SIZE);
const { sumA, sumB } = calculateChecksumsOfBlock(checksumBlock);
checksumBlockDataView.setUint16(ID_AREA_CHECKSUM_DESIRED_SUM_A_OFFSET, sumA, LITTLE_ENDIAN);
checksumBlockDataView.setUint16(ID_AREA_CHECKSUM_DESIRED_SUM_B_OFFSET, sumB, LITTLE_ENDIAN);
// Now we can make our empty page
let pageArrayBuffer = new ArrayBuffer(PAGE_SIZE);
pageArrayBuffer = Util.fillArrayBuffer(pageArrayBuffer, 0);
// Now copy our block to the various offsets it needs to be at
ID_AREA_CHECKSUM_OFFSETS.forEach((offset) => {
pageArrayBuffer = Util.setArrayBufferPortion(pageArrayBuffer, checksumBlock, offset, 0, ID_AREA_BLOCK_SIZE);
});
return pageArrayBuffer;
}
// Taken from https://github.com/bryc/mempak/blob/master/js/parser.js#L147
// Calculate checksums of 4 byte arrays and compare them against the checksum
// listed in the file. They're redundant, so if any are correct then the file
// is deems not corrupted.
static checkIdArea(arrayBuffer) {
let foundValidBlock = false;
const dataView = new DataView(arrayBuffer);
ID_AREA_CHECKSUM_OFFSETS.forEach((offset) => {
const block = arrayBuffer.slice(offset, offset + ID_AREA_BLOCK_SIZE);
const { sumA, sumB } = calculateChecksumsOfBlock(block);
const desiredSumA = dataView.getUint16(offset + ID_AREA_CHECKSUM_DESIRED_SUM_A_OFFSET, LITTLE_ENDIAN);
let desiredSumB = dataView.getUint16(offset + ID_AREA_CHECKSUM_DESIRED_SUM_B_OFFSET, LITTLE_ENDIAN);
// Find incorrect checksums found in many DexDrive files
// https://github.com/bryc/mempak/blob/master/js/parser.js#L127
if ((desiredSumB !== sumB) && ((desiredSumB ^ 0x0C) === sumB) && (desiredSumA === sumA)) {
desiredSumB ^= 0xC;
}
foundValidBlock = foundValidBlock || ((desiredSumA === sumA) && (desiredSumB === sumB));
});
if (!foundValidBlock) {
throw new Error('File appears to be corrupt - checksums in ID Area do not match');
}
}
}
================================================
FILE: frontend/src/save-formats/N64/Components/InodeTable.js
================================================
/* eslint-disable no-bitwise */
import Util from '../../../util/util';
import N64Basics from './Basics';
const {
LITTLE_ENDIAN,
NUM_PAGES,
PAGE_SIZE,
FIRST_SAVE_DATA_PAGE,
} = N64Basics;
const INODE_TABLE_ENTRY_STOP = 1;
const INODE_TABLE_ENTRY_EMPTY = 3;
function setNextPageNumber(inodePageDataView, pageNumber, nextPageNumber) {
inodePageDataView.setUint16(pageNumber * 2, nextPageNumber, LITTLE_ENDIAN);
}
function getNextPageNumber(inodePageDataView, pageNumber) {
return inodePageDataView.getUint16(pageNumber * 2, LITTLE_ENDIAN);
}
export default class N64InodeTable {
static INODE_TABLE_ENTRY_STOP = INODE_TABLE_ENTRY_STOP;
static getNextPageNumber(inodePageDataView, pageNumber) {
return getNextPageNumber(inodePageDataView, pageNumber);
}
static createInodeTablePage(saveFiles) {
// Here we can cheat a little and figure out what the linked list *would* look like if we actually
// split each file into chunks
let inodeTablePage = new ArrayBuffer(PAGE_SIZE);
const startingPages = [];
inodeTablePage = Util.fillArrayBuffer(inodeTablePage, 0);
const inodeTablePageDataView = new DataView(inodeTablePage);
let currentPage = FIRST_SAVE_DATA_PAGE;
saveFiles.forEach((saveFile) => {
startingPages.push(currentPage);
for (let currentByteInFile = 0; currentByteInFile < (saveFile.rawData.byteLength - PAGE_SIZE); currentByteInFile += PAGE_SIZE) {
setNextPageNumber(inodeTablePageDataView, currentPage, currentPage + 1);
currentPage += 1;
}
setNextPageNumber(inodeTablePageDataView, currentPage, INODE_TABLE_ENTRY_STOP);
currentPage += 1;
});
while (currentPage < NUM_PAGES) {
setNextPageNumber(inodeTablePageDataView, currentPage, INODE_TABLE_ENTRY_EMPTY);
currentPage += 1;
}
return {
inodeTablePage,
startingPages,
};
}
// Taken from https://github.com/bryc/mempak/blob/master/js/parser.js#L269
//
// The implementation there performs quite a number of consistency checks on this data, and if
// an anomoly is found then it switches to using the backup inode page. Because these checks therefore
// influence how the data is parsed, we'll replicate them here
static checkIndexes(inodeArrayBuffer, noteTableKeys) {
const inodePageDataView = new DataView(inodeArrayBuffer);
const found = {
parsed: [],
empty: [],
stops: [],
keys: [],
values: [],
duplicates: {},
};
// First, go through each entry and make sure that all the values are within range,
// and there are no duplicates
for (let currentPage = FIRST_SAVE_DATA_PAGE; currentPage < NUM_PAGES; currentPage += 1) {
const nextPage = getNextPageNumber(inodePageDataView, currentPage);
if (nextPage === INODE_TABLE_ENTRY_STOP) {
found.stops.push(currentPage);
found.keys.push(currentPage);
} else if (nextPage === INODE_TABLE_ENTRY_EMPTY) {
found.empty.push(currentPage);
} else if ((nextPage >= FIRST_SAVE_DATA_PAGE) && (nextPage < NUM_PAGES)) {
if (found.duplicates[nextPage]) {
throw new Error(`Found duplicate entries in inode table. Both ${found.duplicates[nextPage]} and ${currentPage} point to page ${nextPage}`);
}
found.values.push(nextPage);
found.keys.push(currentPage);
found.duplicates[nextPage] = currentPage;
} else {
throw new Error(`Inode table contains illegal value: ${nextPage} at page ${currentPage}`);
}
}
// Figure out which keys we found are ones that begin a sequence, and then compare this against
// what we found when parsing the note table. We should have found the same number of both
// start keys and stops in the inode table as we found start keys in the note table.
const startKeysFound = found.keys.filter((x) => !found.values.includes(x));
if ((noteTableKeys.length !== startKeysFound.length) || (noteTableKeys.length !== found.stops.length)) {
throw new Error(`Found ${noteTableKeys.length} starting keys in the note table, but found ${startKeysFound.length} starting keys and ${found.stops.length} stop keys in inode table`);
}
startKeysFound.forEach((x) => {
if (!noteTableKeys.includes(x)) {
throw new Error(`Found start key ${x} in inode table which doesn't exist in note table`);
}
});
// Get the index sequence for each note
const noteIndexes = {};
startKeysFound.forEach((startingPage) => {
const indexes = [startingPage];
let currentPage = startingPage;
let nextPage = getNextPageNumber(inodePageDataView, currentPage);
while (nextPage !== INODE_TABLE_ENTRY_STOP) { // We've already validated that all of these are >= FIRST_SAVE_DATA_PAGE and < NUM_PAGES
indexes.push(nextPage);
currentPage = nextPage;
nextPage = getNextPageNumber(inodePageDataView, currentPage);
}
noteIndexes[startingPage] = indexes;
found.parsed.push(...indexes);
});
// Check that we parsed and found the same number of keys
if (found.parsed.length !== found.keys.length) {
throw new Error(`We encountered ${found.parsed.length} keys when following the various index sequences, but found ${found.keys.length} keys when looking through the entire inode table.`);
}
// We've passed all of our validations, so the last remaining part is the checksums
// Apparently valid files can have invalid checksums, so we won't actually check the checksums
// For DexDrive files, we parse the file and then re-create it entirely to avoid having to fix these sorts of things manually
return noteIndexes;
}
}
================================================
FILE: frontend/src/save-formats/N64/Components/NoteTable.js
================================================
/* eslint-disable no-bitwise */
import Util from '../../../util/util';
import N64Basics from './Basics';
import N64InodeTable from './InodeTable';
import N64GameSerialCodeUtil from './GameSerialCodeUtil';
import N64TextDecoder from './TextDecoder';
const {
LITTLE_ENDIAN,
NUM_NOTES,
NUM_PAGES,
FIRST_SAVE_DATA_PAGE,
} = N64Basics;
const NOTE_TABLE_BLOCK_SIZE = 32;
const NOTE_TABLE_GAME_SERIAL_CODE_OFFSET = 0;
const NOTE_TABLE_GAME_SERIAL_CODE_LENGTH = 4;
const NOTE_TABLE_PUBLISHER_CODE_OFFSET = 4;
const NOTE_TABLE_PUBLISHER_CODE_LENGTH = 2;
const NOTE_TABLE_STARTING_PAGE_OFFSET = 6;
const NOTE_TABLE_STATUS_OFFSET = 8;
const NOTE_TABLE_OCCUPIED_BIT = 0x2;
const NOTE_TABLE_UNUSED_OFFSET = 10;
const NOTE_TABLE_NOTE_NAME_EXTENSION_OFFSET = 12;
const NOTE_TABLE_NOTE_NAME_EXTENSION_LENGTH = 4;
const NOTE_TABLE_NOTE_NAME_OFFSET = 16;
const NOTE_TABLE_NOTE_NAME_LENGTH = 16;
function decodeString(uint8Array) {
// These fixups are made to be compatible with the strings fed to https://github.com/bryc/mempak/blob/master/js/codedb.js
// in case we want to use those lookup tables.
// Note that there's only 1 code there (the publisher for Wave Race 64) that actually uses the - at this time,
// but may as well make the output here match exactly just in case.
const uint8ArrayFixup = uint8Array.slice();
const sum = uint8Array.reduce((accumulator, n) => accumulator + n, 0);
// These indicate that something was corrupted in the file and needs manual fixing
// This only seems to affect one entry: the publisher for Wave Race 64. We'll maintain this fixup for compatibility with https://github.com/bryc/mempak/blob/master/js/codedb.js
// FIXME: This repair only affects a copy of the data (a slice) and not the actual data written out
if (sum === 0) {
uint8ArrayFixup[uint8ArrayFixup.length - 1] |= 1;
}
return {
stringCode: String.fromCharCode(...uint8Array),
stringCodeFixup: String.fromCharCode(...uint8ArrayFixup).replace(/\0/g, '-'),
};
}
function encodeString(string, encodedLength) {
const output = new Uint8Array(encodedLength);
output.fill(0);
for (let i = 0; i < string.length; i += 1) {
const charCode = string.charCodeAt(i);
output[i] = ((charCode <= 255) ? charCode : 0);
}
return output;
}
export default class N64NoteTable {
static createNoteTablePage(saveFilesWithStartingPage) {
const noteBlocks = saveFilesWithStartingPage.map((saveFile) => {
const noteBlock = N64Basics.createEmptyBlock(NOTE_TABLE_BLOCK_SIZE);
const noteBlockDataView = new DataView(noteBlock);
const noteBlockArray = new Uint8Array(noteBlock);
const noteNameEncoded = N64TextDecoder.encode(saveFile.noteName, NOTE_TABLE_NOTE_NAME_LENGTH);
const noteNameExtensionEncoded = N64TextDecoder.encode(saveFile.noteNameExtension, NOTE_TABLE_NOTE_NAME_EXTENSION_LENGTH);
const gameSerialEncoded = encodeString(saveFile.gameSerialCode, NOTE_TABLE_GAME_SERIAL_CODE_LENGTH);
const publisherCodeEncoded = encodeString(saveFile.publisherCode, NOTE_TABLE_PUBLISHER_CODE_LENGTH);
noteBlockArray.set(gameSerialEncoded, NOTE_TABLE_GAME_SERIAL_CODE_OFFSET);
noteBlockArray.set(publisherCodeEncoded, NOTE_TABLE_PUBLISHER_CODE_OFFSET);
noteBlockDataView.setUint16(NOTE_TABLE_STARTING_PAGE_OFFSET, saveFile.startingPage, LITTLE_ENDIAN);
noteBlockArray[NOTE_TABLE_STATUS_OFFSET] = NOTE_TABLE_OCCUPIED_BIT;
noteBlockArray.set(noteNameEncoded, NOTE_TABLE_NOTE_NAME_OFFSET);
noteBlockArray.set(noteNameExtensionEncoded, NOTE_TABLE_NOTE_NAME_EXTENSION_OFFSET);
return noteBlock;
});
while (noteBlocks.length < NUM_NOTES) {
noteBlocks.push(N64Basics.createEmptyBlock(NOTE_TABLE_BLOCK_SIZE));
}
return Util.concatArrayBuffers(noteBlocks);
}
// Taken from https://github.com/bryc/mempak/blob/master/js/parser.js#L173
static readNoteTable(inodePageArrayBuffer, noteTableArrayBuffer) {
const noteKeys = [];
const notes = [];
const noteTableArray = new Uint8Array(noteTableArrayBuffer);
const noteTableDataView = new DataView(noteTableArrayBuffer);
const inodePageDataView = new DataView(inodePageArrayBuffer);
for (let currentByte = 0; currentByte < noteTableArrayBuffer.byteLength; currentByte += NOTE_TABLE_BLOCK_SIZE) {
const noteIndex = currentByte / NOTE_TABLE_BLOCK_SIZE;
const startingPage = noteTableDataView.getUint16(currentByte + NOTE_TABLE_STARTING_PAGE_OFFSET, LITTLE_ENDIAN);
const nextPage = N64InodeTable.getNextPageNumber(inodePageDataView, startingPage);
const firstPageValid = (startingPage >= FIRST_SAVE_DATA_PAGE) && (startingPage < NUM_PAGES);
const unusedBytesAreZero = (noteTableDataView.getUint16(currentByte + NOTE_TABLE_UNUSED_OFFSET, LITTLE_ENDIAN) === 0);
const nextPageValid = (nextPage === N64InodeTable.INODE_TABLE_ENTRY_STOP) || ((nextPage >= FIRST_SAVE_DATA_PAGE) && (nextPage < NUM_PAGES));
if (firstPageValid && unusedBytesAreZero && nextPageValid) {
noteKeys.push(startingPage);
const gameSerialCodeArray = noteTableArray.slice(currentByte + NOTE_TABLE_GAME_SERIAL_CODE_OFFSET, currentByte + NOTE_TABLE_GAME_SERIAL_CODE_OFFSET + NOTE_TABLE_GAME_SERIAL_CODE_LENGTH);
const publisherCodeArray = noteTableArray.slice(currentByte + NOTE_TABLE_PUBLISHER_CODE_OFFSET, currentByte + NOTE_TABLE_PUBLISHER_CODE_OFFSET + NOTE_TABLE_PUBLISHER_CODE_LENGTH);
const { stringCode: gameSerialCode, stringCodeFixup: gameSerialCodeFixup } = decodeString(gameSerialCodeArray);
const { stringCode: publisherCode, stringCodeFixup: publisherCodeFixup } = decodeString(publisherCodeArray);
const noteName = N64TextDecoder.decode(
noteTableArray.slice(
currentByte + NOTE_TABLE_NOTE_NAME_OFFSET,
currentByte + NOTE_TABLE_NOTE_NAME_OFFSET + NOTE_TABLE_NOTE_NAME_LENGTH,
),
);
const noteNameExtension = N64TextDecoder.decode(
noteTableArray.slice(
currentByte + NOTE_TABLE_NOTE_NAME_EXTENSION_OFFSET,
currentByte + NOTE_TABLE_NOTE_NAME_EXTENSION_OFFSET + NOTE_TABLE_NOTE_NAME_EXTENSION_LENGTH,
),
);
notes.push({
noteIndex,
startingPage,
gameSerialCode,
gameSerialCodeFixup,
publisherCode,
publisherCodeFixup,
noteName,
noteNameExtension,
region: N64GameSerialCodeUtil.getRegionCode(gameSerialCode),
regionName: N64GameSerialCodeUtil.getRegionName(gameSerialCode),
media: N64GameSerialCodeUtil.getMediaCode(gameSerialCode),
});
}
}
return {
noteKeys,
notes,
};
}
}
================================================
FILE: frontend/src/save-formats/N64/Components/TextDecoder.js
================================================
/* eslint-disable object-property-newline */
import { invert } from 'lodash-es';
// The N64 uses a custom character encoding
// Taken from https://github.com/bryc/mempak/blob/master/js/parser.js#L35
// Also listed here: http://n64devkit.square7.ch/n64man/nos/nosLoadFont.htm
const CHARACTER_CODE_LOOKUP = {
0: '', 15: ' ', 16: '0',
17: '1', 18: '2', 19: '3', 20: '4',
21: '5', 22: '6', 23: '7', 24: '8',
25: '9', 26: 'A', 27: 'B', 28: 'C',
29: 'D', 30: 'E', 31: 'F', 32: 'G',
33: 'H', 34: 'I', 35: 'J', 36: 'K',
37: 'L', 38: 'M', 39: 'N', 40: 'O',
41: 'P', 42: 'Q', 43: 'R', 44: 'S',
45: 'T', 46: 'U', 47: 'V', 48: 'W',
49: 'X', 50: 'Y', 51: 'Z', 52: '!',
53: '"', 54: '#', 55: '\'', 56: '*',
57: '+', 58: ',', 59: '-', 60: '.',
61: '/', 62: ':', 63: '=', 64: '?',
65: '@', 66: '。', 67: '゛', 68: '゜',
69: 'ァ', 70: 'ィ', 71: 'ゥ', 72: 'ェ',
73: 'ォ', 74: 'ッ', 75: 'ャ', 76: 'ュ',
77: 'ョ', 78: 'ヲ', 79: 'ン', 80: 'ア',
81: 'イ', 82: 'ウ', 83: 'エ', 84: 'オ',
85: 'カ', 86: 'キ', 87: 'ク', 88: 'ケ',
89: 'コ', 90: 'サ', 91: 'シ', 92: 'ス',
93: 'セ', 94: 'ソ', 95: 'タ', 96: 'チ',
97: 'ツ', 98: 'テ', 99: 'ト', 100: 'ナ',
101: 'ニ', 102: 'ヌ', 103: 'ネ', 104: 'ノ',
105: 'ハ', 106: 'ヒ', 107: 'フ', 108: 'ヘ',
109: 'ホ', 110: 'マ', 111: 'ミ', 112: 'ム',
113: 'メ', 114: 'モ', 115: 'ヤ', 116: 'ユ',
117: 'ヨ', 118: 'ラ', 119: 'リ', 120: 'ル',
121: 'レ', 122: 'ロ', 123: 'ワ', 124: 'ガ',
125: 'ギ', 126: 'グ', 127: 'ゲ', 128: 'ゴ',
129: 'ザ', 130: 'ジ', 131: 'ズ', 132: 'ゼ',
133: 'ゾ', 134: 'ダ', 135: 'ヂ', 136: 'ヅ',
137: 'デ', 138: 'ド', 139: 'バ', 140: 'ビ',
141: 'ブ', 142: 'ベ', 143: 'ボ', 144: 'パ',
145: 'ピ', 146: 'プ', 147: 'ペ', 148: 'ポ',
};
const CHARACTER_LOOKUP = invert(CHARACTER_CODE_LOOKUP);
export default class N64TextDecoder {
static decode(uint8array) {
let output = '';
for (let i = 0; i < uint8array.length; i += 1) {
const char = CHARACTER_CODE_LOOKUP[uint8array[i]];
output += (char || '');
}
return output;
}
static encode(string, encodedLength) {
const output = new Uint8Array(encodedLength);
output.fill(0);
for (let i = 0; i < Math.min(string.length, encodedLength); i += 1) {
const char = string.charAt(i);
if (char in CHARACTER_LOOKUP) {
output[i] = CHARACTER_LOOKUP[char];
} else {
output[i] = 0;
}
}
return output;
}
}
================================================
FILE: frontend/src/save-formats/N64/DexDrive.js
================================================
/*
The DexDrive data format is described here:
https://github.com/bryc/mempak/wiki/DexDrive-.N64-format
It is:
- 4160 byte header:
- Magic
- Some unknown stuff. This unknown stuff seems inconsequential (see link above)
- A comment for each block
- Normal N64 .MPK memory card data
*/
import N64Basics from './Components/Basics';
import N64MempackSaveData from './Mempack';
import Util from '../../util/util';
const {
NUM_NOTES,
TOTAL_MEMPACK_SIZE,
} = N64Basics;
// DexDrive header
const HEADER_LENGTH = 4160;
const HEADER_MAGIC = '123-456-STD';
const MAGIC_ENCODING = 'US-ASCII';
const COMMENT_ENCODING = 'US-ASCII';
const FIRST_COMMENT_OFFSET = 64;
const COMMENT_LENGTH = 256;
function getCommentStartOffset(i) {
return FIRST_COMMENT_OFFSET + (i * COMMENT_LENGTH);
}
function getComments(headerArrayBuffer) {
const comments = [];
const textDecoder = new TextDecoder(COMMENT_ENCODING);
for (let i = 0; i < NUM_NOTES; i += 1) {
const commentStartOffset = getCommentStartOffset(i);
const commentArrayBuffer = headerArrayBuffer.slice(commentStartOffset, commentStartOffset + COMMENT_LENGTH);
comments.push(Util.trimNull(textDecoder.decode(commentArrayBuffer)).trim());
}
return comments;
}
export default class N64DexDriveSaveData {
static createFromDexDriveData(dexDriveArrayBuffer, randomNumberGenerator = null) {
return new N64DexDriveSaveData(dexDriveArrayBuffer, randomNumberGenerator);
}
static createFromSaveFiles(saveFiles, randomNumberGenerator = null) {
// The DexDrive image is the DexDrive header then the regular mempack data
const mempackSaveData = N64MempackSaveData.createFromSaveFiles(saveFiles, randomNumberGenerator);
const headerArrayBuffer = new ArrayBuffer(HEADER_LENGTH);
const headerArray = new Uint8Array(headerArrayBuffer);
const magicTextEncoder = new TextEncoder(MAGIC_ENCODING);
const commentTextEncoder = new TextEncoder(COMMENT_ENCODING);
// Fill in our magic
headerArray.fill(0);
headerArray.set(magicTextEncoder.encode(HEADER_MAGIC), 0);
// Make an array of our comments, arranged by the starting block of each save
const comments = Array.from({ length: NUM_NOTES }, () => null);
const mempackSaveDataFilesWithComments = mempackSaveData.getSaveFiles().map((file, i) => ({ ...file, comment: saveFiles[i].comment })); // Our list of save files from the memcard data is in the same order as the files were passed in
mempackSaveDataFilesWithComments.forEach((file) => { comments[file.noteIndex] = file.comment; });
for (let i = 0; i < NUM_NOTES; i += 1) {
if (comments[i] !== null) {
const encodedComment = commentTextEncoder.encode(comments[i]).slice(0, COMMENT_LENGTH);
headerArray.set(encodedComment, getCommentStartOffset(i));
}
}
// Now that we've created our DexDrive header, we can create our final memory image. We'll parse it again
// to pull out the file descriptions
const finalArrayBuffer = Util.concatArrayBuffers([headerArrayBuffer, mempackSaveData.getArrayBuffer()]);
return N64DexDriveSaveData.createFromDexDriveData(finalArrayBuffer, randomNumberGenerator);
}
// This constructor creates a new object from a binary representation of a DexDrive save data file
constructor(arrayBuffer, randomNumberGenerator = null) {
this.arrayBuffer = arrayBuffer;
// Parse the DexDrive-specific header: magic and comments
let dexDriveHeaderArrayBuffer = arrayBuffer.slice(0, HEADER_LENGTH);
let mempackArrayBuffer = arrayBuffer.slice(HEADER_LENGTH); // The remainder of the file is the actual contents of the memory card
try {
Util.checkMagic(dexDriveHeaderArrayBuffer, 0, HEADER_MAGIC, MAGIC_ENCODING);
} catch (e) {
if (arrayBuffer.byteLength === TOTAL_MEMPACK_SIZE) {
// Some files, found on gamefaqs primarily, are labeled as being dexdrive but are actually
// raw memcard images. This is likely due to gamefaqs' policy of only allowing "legitimate" saves
// and not those that could have come from an emulator.
dexDriveHeaderArrayBuffer = Util.getFilledArrayBuffer(HEADER_LENGTH, 0x00);
mempackArrayBuffer = arrayBuffer;
} else if (arrayBuffer.byteLength !== (HEADER_LENGTH + TOTAL_MEMPACK_SIZE)) {
// For some files found on the Internet they just contain a completely blank header. Not sure what
// program makes them. But they're parseable by the rest of the code here even though they don't
// contain the correct magic
throw new Error('This does not appear to be a N64 DexDrive file');
}
}
const comments = getComments(dexDriveHeaderArrayBuffer);
// Parse the rest of the file
const mempack = N64MempackSaveData.createFromN64MempackData(mempackArrayBuffer);
// Add in the comments we found in the header
this.saveFiles = mempack.getSaveFiles().map((x) => ({ ...x, comment: comments[x.noteIndex] }));
this.mempack = N64MempackSaveData.createFromSaveFiles(this.saveFiles, randomNumberGenerator); // Re-create our memory pack image from scratch because many dexdrive files found in the wild are seen as corrupt when loaded in game
}
getSaveFiles() {
return this.saveFiles;
}
getMempack() {
return this.mempack;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/N64/IndividualSaveFilename.js
================================================
import Util from '../../util/util';
import N64Util from '../../util/N64';
import N64GameSerialCodeUtil from './Components/GameSerialCodeUtil';
const FILENAME_ENCODING = 'utf8'; // Encoding to use when creating a filename for an individual note
function parseNoteNameAndExtension(noteNameAndExtension) {
// Here we are going to assume that if there's one . then it's intended to split the name from the extension (e.g. "T2-WAREHOUSE.P" for Tony Hawk)
// and if there are 0 or > 1 .'s then it's just all the filename (e.g. "S.F. RUSH" for San Francisco Rush)
const noteNameAndExtensionParts = noteNameAndExtension.split('.');
let noteName = noteNameAndExtension;
let noteNameExtension = '';
if (noteNameAndExtensionParts.length === 2) {
[noteName, noteNameExtension] = noteNameAndExtensionParts;
}
return {
noteName,
noteNameExtension,
};
}
export default class N64IndividualSaveFilename {
static getDisplayName(saveFile) {
if (saveFile.noteNameExtension.length > 0) {
return `${saveFile.noteName}.${saveFile.noteNameExtension}`;
}
return saveFile.noteName;
}
static createFilename(saveFile) {
if (N64GameSerialCodeUtil.isCartSave(saveFile)) {
// Here we want to make a user-friendly name, meaning having the correct extension for an emulator to load
// NOTE: if we get into trouble again here with having a . in between the note name and the note name extension,
// we'll again need to deal with the issue of users having legacy filenames on their machines
return `${N64IndividualSaveFilename.getDisplayName(saveFile)}.${N64Util.getFileExtension(saveFile.rawData)}`; // It's always going to be .eep because that's all that can fit in a mempack image: the next size up is the size of an entire mempack, which doesn't leave room for the system information
}
// We need to encode all the stuff that goes into the note table into our file name.
// Some of these portions can contain non-ASCII characters (For example, the publisher
// code can be 0x0000), so encoding it as hex makes for an easy (if long) filename.
const noteNameEncoded = Buffer.from(saveFile.noteName, FILENAME_ENCODING).toString('hex');
const noteNameExtensionEncoded = Buffer.from(saveFile.noteNameExtension, FILENAME_ENCODING).toString('hex');
const gameSerialCodeEncoded = Buffer.from(saveFile.gameSerialCode, FILENAME_ENCODING).toString('hex');
const publisherCodeEncoded = Buffer.from(saveFile.publisherCode, FILENAME_ENCODING).toString('hex');
return `RAW-${noteNameEncoded}-${noteNameExtensionEncoded}-${gameSerialCodeEncoded}-${publisherCodeEncoded}`;
}
static parseFilename(filename) {
if (filename.startsWith('RAW-')) {
const filenamePortions = filename.split('-');
// We originally had a bug where the notename was encoded as "." which caused issues
// when the notename itself had a . in it, such as "S.F. Rush". Users may have legacy filenames on their system, and
// so we have to support either the old format or the new format
try {
if (filenamePortions.length === 4) {
// Old style
const noteNameAndExtension = Buffer.from(filenamePortions[1], 'hex').toString(FILENAME_ENCODING);
const gameSerialCode = Buffer.from(filenamePortions[2], 'hex').toString(FILENAME_ENCODING);
const publisherCode = Buffer.from(filenamePortions[3], 'hex').toString(FILENAME_ENCODING);
const { noteName, noteNameExtension } = parseNoteNameAndExtension(noteNameAndExtension);
return {
noteName,
noteNameExtension,
gameSerialCode,
publisherCode,
};
}
if (filenamePortions.length === 5) {
// New style
const noteName = Buffer.from(filenamePortions[1], 'hex').toString(FILENAME_ENCODING);
const noteNameExtension = Buffer.from(filenamePortions[2], 'hex').toString(FILENAME_ENCODING);
const gameSerialCode = Buffer.from(filenamePortions[3], 'hex').toString(FILENAME_ENCODING);
const publisherCode = Buffer.from(filenamePortions[4], 'hex').toString(FILENAME_ENCODING);
return {
noteName,
noteNameExtension,
gameSerialCode,
publisherCode,
};
}
throw new Error('Wrong number of parts in filename');
} catch (e) {
throw new Error('Filename not in correct format. Format should be \'RAW-XXXX-XXXX-XXXX\' or \'RAW-XXXX-XXXX-XXXX-XXXX\'');
}
} else {
// Otherwise, we have to assume it's a cart save. So, it could set its game/publisher code to
// be either Gameshark or Black Bag. The Black Bag file manager is I believe a defunct program
// that ran on individual computers, and would be hard for most people to get running on their
// modern machines. Whereas if we assign it to Gameshark, then someone could use the Gameshark
// hardware to load it onto a real cart, regardless of whether the file was originally from
// the Black Bag software.
//
// So, we'll just assign everything to Gameshark
const noteNameAndExtension = Util.removeFilenameExtension(filename).trim().toUpperCase(); // There's no lower case arabic characters in the N64 text encoding
const { noteName, noteNameExtension } = parseNoteNameAndExtension(noteNameAndExtension);
return {
noteName,
noteNameExtension,
gameSerialCode: N64GameSerialCodeUtil.GAMESHARK_ACTIONREPLAY_CART_SAVE_GAME_SERIAL_CODE,
publisherCode: N64GameSerialCodeUtil.GAMESHARK_ACTIONREPLAY_CART_SAVE_PUBLISHER_CODE,
};
}
}
}
================================================
FILE: frontend/src/save-formats/N64/Mempack.js
================================================
/* eslint no-bitwise: ['error', { 'allow': ['&', '|', '^', '&=', '^=', '|='] }] */
/*
The N64 mempack format is described here:
https://github.com/bryc/mempak/wiki/MemPak-structure
The first 5 pages are header information.
Page 0: ID area, containing checksums
Page 1: "Inode" (index) information
Page 2: Repeat of page 1
Page 3, 4: Note table
The subsequent 123 pages are the actual save data
*/
import Util from '../../util/util';
import N64Basics from './Components/Basics';
import N64IdArea from './Components/IdArea';
import N64InodeTable from './Components/InodeTable';
import N64NoteTable from './Components/NoteTable';
import N64GameSerialCodeUtil from './Components/GameSerialCodeUtil';
import N64IndividualSaveFilename from './IndividualSaveFilename';
const {
NUM_NOTES,
NUM_PAGES,
PAGE_SIZE,
FIRST_SAVE_DATA_PAGE,
} = N64Basics;
// The first 5 pages are special header info
const ID_AREA_PAGE = 0;
const INODE_TABLE_PAGE = 1;
const INODE_TABLE_BACKUP_PAGE = 2; // Page 2 is a repeat of page 1, checked in case we encounter corruption of page 1
const NOTE_TABLE_PAGES = [3, 4];
const MAX_DATA_SIZE = (NUM_PAGES - FIRST_SAVE_DATA_PAGE) * PAGE_SIZE;
// Using various cheating devices, it's possible to copy a save stored on a cartridge onto
// a controller pak. There are many potential cart save sizes (see http://micro-64.com/database/gamesave.shtml),
// but only the ones below will fit onto a controller pak: the next size up (32768 bytes) doesn't fit because of the 5
// pages taken up by system infomation.
//
// For some/all of these devices they apparently will skip pages at the end that are filled with padding, meaning
// that we should pad out any cart saves that we encounter to be one of these sizes. Some emulators will take an
// unpadded file just fine, but apparently others won't like it.
const CART_SAVE_SIZES = [
512,
2048,
];
function getPage(pageNumber, arrayBuffer) {
const offset = pageNumber * PAGE_SIZE;
return arrayBuffer.slice(offset, offset + PAGE_SIZE);
}
function concatPages(pageNumbers, arrayBuffer) {
const pages = pageNumbers.map((i) => getPage(i, arrayBuffer));
return Util.concatArrayBuffers(pages);
}
// See comments above: cart saves can be stored in a controller pak file, but the cheat
// devices used to do so may truncate the file to eliminate portions that are all padding.
// We'll add that padding back for better compatibility with emulators.
function padCartSave(saveFile) {
if (!N64GameSerialCodeUtil.isCartSave(saveFile)) { /* eslint-disable-line no-use-before-define */
return saveFile;
}
for (let i = 0; i < CART_SAVE_SIZES.length; i += 1) {
if (CART_SAVE_SIZES[i] === saveFile.rawData.byteLength) {
return saveFile;
}
if (CART_SAVE_SIZES[i] > saveFile.rawData.byteLength) {
let paddedRawData = saveFile.rawData;
while (paddedRawData.byteLength < CART_SAVE_SIZES[i]) {
paddedRawData = Util.concatArrayBuffers([paddedRawData, N64Basics.createEmptyBlock(PAGE_SIZE)]);
}
return {
...saveFile,
rawData: paddedRawData,
};
}
}
return saveFile;
}
export default class N64MempackSaveData {
static createFromN64MempackData(mempackArrayBuffer) {
return new N64MempackSaveData(mempackArrayBuffer);
}
static createFromSaveFiles(saveFiles, randomNumberGenerator = null) {
// Check to make sure that there's not too many save files, or too much data, or save files the wrong length
if (saveFiles.length > NUM_NOTES) {
throw new Error(`Found ${saveFiles.length} notes, but max is ${NUM_NOTES}`);
}
const totalSize = saveFiles.reduce((accumulator, x) => accumulator + x.rawData.byteLength, 0);
if (totalSize > MAX_DATA_SIZE) {
throw new Error(`These files are too large to fit in a single N64 Controller Pak: total size is ${totalSize} bytes, but max is ${MAX_DATA_SIZE}`);
}
saveFiles.forEach((x) => {
if (x.rawData.byteLength === 0) {
throw new Error(`Save file ${x.noteNate} does not contain any data`);
}
if (x.rawData.byteLength % PAGE_SIZE !== 0) {
throw new Error(`All saves must be multiples of ${PAGE_SIZE} bytes, but save '${N64IndividualSaveFilename.getDisplayName(x)}' is ${x.rawData.byteLength} bytes`);
}
});
// Now make our header pages
const idAreaPage = N64IdArea.createIdAreaPage(randomNumberGenerator);
const { inodeTablePage, startingPages } = N64InodeTable.createInodeTablePage(saveFiles);
const saveFilesWithStartingPage = saveFiles.map((x, i) => ({ ...x, startingPage: startingPages[i] }));
const noteTablePage = N64NoteTable.createNoteTablePage(saveFilesWithStartingPage);
// Technically, we should split each save into separate pages, then concat all 128 pages together to get the final
// arraybuffer. But, we can cheat and just concat the existing saves together instead of splitting them apart first
let dataPages = Util.concatArrayBuffers(saveFiles.map((x) => x.rawData));
while (dataPages.byteLength < MAX_DATA_SIZE) {
dataPages = Util.concatArrayBuffers([dataPages, N64Basics.createEmptyBlock(PAGE_SIZE)]);
}
const arrayBuffer = Util.concatArrayBuffers([idAreaPage, inodeTablePage, inodeTablePage, noteTablePage, dataPages]);
return new N64MempackSaveData(arrayBuffer);
}
constructor(mempackArrayBuffer) {
this.arrayBuffer = mempackArrayBuffer;
// There are 5 pages of header information, then the rest are game save data
// The first page, the ID Area, is a series of checksums plus some other stuff we'll ignore
N64IdArea.checkIdArea(getPage(ID_AREA_PAGE, mempackArrayBuffer));
// Now, check the note table
const inodeArrayBuffer = getPage(INODE_TABLE_PAGE, mempackArrayBuffer);
const inodeBackupArrayBuffer = getPage(INODE_TABLE_BACKUP_PAGE, mempackArrayBuffer);
const noteTableArrayBuffer = concatPages(NOTE_TABLE_PAGES, mempackArrayBuffer);
const noteInfo = N64NoteTable.readNoteTable(inodeArrayBuffer, noteTableArrayBuffer);
let noteIndexes = {};
try {
noteIndexes = N64InodeTable.checkIndexes(inodeArrayBuffer, noteInfo.noteKeys);
} catch (e) {
// If we encounter something that appears to be corrupted in the main inode table, then
// try again with the backup table
try {
noteIndexes = N64InodeTable.checkIndexes(inodeBackupArrayBuffer, noteInfo.noteKeys);
} catch (e2) {
throw new Error('Both primary and backup inode tables appear to be corrupted. Error from backup table follows', { cause: e2 });
}
}
const saveFiles = noteInfo.notes.map((x) => ({
...x,
pageNumbers: noteIndexes[x.startingPage],
rawData: concatPages(noteIndexes[x.startingPage], mempackArrayBuffer),
}));
this.saveFiles = saveFiles.map((x) => padCartSave(x));
}
getSaveFiles() {
return this.saveFiles;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/NintendoSwitchOnline/Gameboy.js
================================================
/*
The Nintendo Switch Online save format for Gameboy games is the similar to its format for NES except that there's 2 hashes:
one for the ROM used and the other for the save data itself
There may or may not be RTC data present in the header
0x00: Magic 1: "SRAM" + 0x03
0x08: Encoded SHA-1 hash of the ROM
0x30: Length of git revision number in bytes
0x34: Git revision number: "HEAD-vXXX.X", or "master-vXXX.X", or "HEAD-vXXX.X-X-XXXXXXXXX"
The rest isn't at fixed offsets:
1 byte indicating whether RTC data is present
(optional) 0x20 bytes of RTC data
0x28 bytes of encoded SHA-1 hash of the raw save data
Raw save data
It appears there's at least 6 versions of the git revision number:
- HEAD-v178.0 (the initial batch of NSO games)
- HEAD-v184.0 (Kirby's Dreamland 2)
- HEAD-v203.0 (Spanish versions of Pokemon Red/Blue/Yellow/Gold/Silver/Crystal)
- HEAD-v213.0-1-g59d2e63b (Europe version of Pokemon TCG)
- master-v196.0 (Pokemon TCG)
- master-v199.0 (Pokemon - Crystal Version)
*/
import SaveFilesUtil from '../../util/SaveFiles';
import Util from '../../util/util';
import HashUtil from '../../util/Hash';
const MAGIC_OFFSET = 0;
const MAGIC = [0x53, 0x52, 0x41, 0x4D, 0x03]; // 'SRAM';
const ROM_HASH_OFFSET = 0x08;
const HASH_ALGORITHM = 'sha1';
const HASH_LENGTH = 0x28; // The SHA-1 digest is converted to hex and encoded as ASCII
const HASH_ENCODING = 'US-ASCII';
const GIT_REVISION_NUMBER_LENGTH_OFFSET = 0x30;
const GIT_REVISION_NUMBER_OFFSET = 0x34;
// This is likely RTC data (it's found in Pokemon Gold/Silver/Crystal and not in Pokemon Red/Blue/Yellow)
const NO_RTC_DATA = 0x00;
const HAS_RTC_DATA = 0x01;
const HAS_RTC_DATA_POTENTIAL_VALUES = [NO_RTC_DATA, HAS_RTC_DATA];
const RTC_DATA_LENGTH = 0x20;
const HEADER_FILL_VALUE = 0x00; // There are some misc 0x00 bytes after the magics
function getFileInfo(nsoArrayBuffer) {
// First, get the git revision number
const nsoDataView = new DataView(nsoArrayBuffer);
const gitRevisionNumberLength = nsoDataView.getUint8(GIT_REVISION_NUMBER_LENGTH_OFFSET);
const gitRevisionNumberArrayBuffer = nsoArrayBuffer.slice(GIT_REVISION_NUMBER_OFFSET, GIT_REVISION_NUMBER_OFFSET + gitRevisionNumberLength);
// Now we need to look for potential RTC data
const hasRtcDataOffset = GIT_REVISION_NUMBER_OFFSET + gitRevisionNumberLength;
const hasRtcData = nsoDataView.getUint8(hasRtcDataOffset);
if (HAS_RTC_DATA_POTENTIAL_VALUES.indexOf(hasRtcData) < 0) {
throw new Error('This does not appear to be a Nintendo Switch Online Gameboy save file');
}
let rtcDataArrayBuffer = null;
let saveDataHashOffset = hasRtcDataOffset + 1;
if (hasRtcData === HAS_RTC_DATA) {
rtcDataArrayBuffer = nsoArrayBuffer.slice(hasRtcDataOffset + 1, hasRtcDataOffset + 1 + RTC_DATA_LENGTH);
saveDataHashOffset += RTC_DATA_LENGTH;
}
return {
gitRevisionNumberArrayBuffer,
rtcDataArrayBuffer,
saveDataHashOffset,
dataBeginOffset: saveDataHashOffset + HASH_LENGTH,
};
}
export default class NsoGameboySaveData {
static getNsoFileExtension() {
return 'sram';
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return 'gb';
}
static nsoDataRequiresRomInfo() {
return true;
}
static createWithNewSize(nsoSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(nsoSaveData.getRawArrayBuffer(), newSize);
return NsoGameboySaveData.createFromRawData(newRawSaveData, nsoSaveData.getEncodedRomHash(), nsoSaveData.getEncodedVersion(), nsoSaveData.getUnknownData(), nsoSaveData.getFileFormat());
}
static createFromNsoData(nsoArrayBuffer) {
Util.checkMagicBytes(nsoArrayBuffer, MAGIC_OFFSET, MAGIC);
const fileInfo = getFileInfo(nsoArrayBuffer);
const romHashArrayBuffer = nsoArrayBuffer.slice(ROM_HASH_OFFSET, ROM_HASH_OFFSET + HASH_LENGTH);
return new NsoGameboySaveData(nsoArrayBuffer.slice(fileInfo.dataBeginOffset), nsoArrayBuffer, romHashArrayBuffer, fileInfo.gitRevisionNumberArrayBuffer, fileInfo.rtcDataArrayBuffer);
}
static createFromRawData(rawArrayBuffer, romHashArrayBuffer, gitRevisionNumberArrayBuffer, rtcDataArrayBuffer) {
const rtcDataLength = (rtcDataArrayBuffer !== null) ? rtcDataArrayBuffer.byteLength : 0;
const hasRtcDataOffset = GIT_REVISION_NUMBER_OFFSET + gitRevisionNumberArrayBuffer.byteLength;
const rtcDataOffset = hasRtcDataOffset + 1;
const saveDataHashOffset = rtcDataOffset + rtcDataLength;
const headerLength = saveDataHashOffset + HASH_LENGTH;
let headerArrayBuffer = Util.getFilledArrayBuffer(headerLength, HEADER_FILL_VALUE);
const headerDataView = new DataView(headerArrayBuffer);
// We can't interleave these line with lines that mess with headerArrayBuffer below, otherwise this change gets stomped
headerDataView.setUint8(GIT_REVISION_NUMBER_LENGTH_OFFSET, gitRevisionNumberArrayBuffer.byteLength);
headerDataView.setUint8(hasRtcDataOffset, (rtcDataArrayBuffer !== null) ? HAS_RTC_DATA : NO_RTC_DATA);
headerArrayBuffer = Util.setMagicBytes(headerArrayBuffer, MAGIC_OFFSET, MAGIC);
headerArrayBuffer = Util.setArrayBufferPortion(headerArrayBuffer, romHashArrayBuffer, ROM_HASH_OFFSET, 0, HASH_LENGTH);
headerArrayBuffer = Util.setArrayBufferPortion(headerArrayBuffer, gitRevisionNumberArrayBuffer, GIT_REVISION_NUMBER_OFFSET, 0, gitRevisionNumberArrayBuffer.byteLength);
if (rtcDataLength > 0) {
headerArrayBuffer = Util.setArrayBufferPortion(headerArrayBuffer, rtcDataArrayBuffer, rtcDataOffset, 0, rtcDataArrayBuffer.byteLength);
}
const encodedSaveDataHashArrayBuffer = HashUtil.getEncodedHash(rawArrayBuffer, HASH_ALGORITHM, HASH_ENCODING);
headerArrayBuffer = Util.setArrayBufferPortion(headerArrayBuffer, encodedSaveDataHashArrayBuffer, saveDataHashOffset, 0, HASH_LENGTH);
const nsoArrayBuffer = Util.concatArrayBuffers([headerArrayBuffer, rawArrayBuffer]);
return new NsoGameboySaveData(rawArrayBuffer, nsoArrayBuffer, romHashArrayBuffer, gitRevisionNumberArrayBuffer, rtcDataArrayBuffer);
}
// This constructor creates a new object from a binary representation of a NSO save data file
constructor(rawArrayBuffer, nsoArrayBuffer, romHashArrayBuffer, gitRevisionNumberArrayBuffer, rtcDataArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.nsoArrayBuffer = nsoArrayBuffer;
this.romHashArrayBuffer = romHashArrayBuffer;
this.gitRevisionNumberArrayBuffer = gitRevisionNumberArrayBuffer;
this.rtcDataArrayBuffer = rtcDataArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getNsoArrayBuffer() {
return this.nsoArrayBuffer;
}
getRomHashArrayBuffer() {
return this.romHashArrayBuffer;
}
getGitRevisionNumberArrayBuffer() {
return this.gitRevisionNumberArrayBuffer;
}
getRtcDataArrayBuffer() {
return this.rtcDataArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/NintendoSwitchOnline/GameboyAdvance.js
================================================
// GBA saves on NSO are just raw files
import SaveFilesUtil from '../../util/SaveFiles';
export default class GbaNsoSaveData {
static createFromNsoData(nsoArrayBuffer) {
return new GbaNsoSaveData(nsoArrayBuffer, nsoArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new GbaNsoSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(nsoSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(nsoSaveData.getRawArrayBuffer(), newSize);
return GbaNsoSaveData.createFromRawData(newRawSaveData);
}
static getNsoFileExtension() {
return 'sram';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return 'gba';
}
static nsoDataRequiresRomInfo() {
return false;
}
constructor(nsoArrayBuffer, rawArrayBuffer) {
this.nsoArrayBuffer = nsoArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getNsoArrayBuffer() {
return this.nsoArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/NintendoSwitchOnline/Genesis.js
================================================
// Genesis files on NSO are not byte-expanded like other emulators
import SaveFilesUtil from '../../util/SaveFiles';
import GenesisUtil from '../../util/Genesis';
const EMULATOR_BYTE_EXPAND_FILL_BYTE = 0x00;
export default class GenesisNsoSaveData {
static createFromNsoData(nsoArrayBuffer) {
if (GenesisUtil.isEepromSave(nsoArrayBuffer)) {
// If it's an EEPROM save, an emulator will want it to not be byte expanded
return new GenesisNsoSaveData(nsoArrayBuffer, nsoArrayBuffer);
}
// Now that the padding is gone, we can proceed
const rawArrayBuffer = GenesisUtil.byteExpand(nsoArrayBuffer, EMULATOR_BYTE_EXPAND_FILL_BYTE);
return new GenesisNsoSaveData(nsoArrayBuffer, rawArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
let nsoArrayBuffer = rawArrayBuffer;
if (GenesisUtil.isByteExpanded(rawArrayBuffer)) {
nsoArrayBuffer = GenesisUtil.byteCollapse(rawArrayBuffer);
}
return new GenesisNsoSaveData(nsoArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(nsoSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(nsoSaveData.getRawArrayBuffer(), newSize);
return GenesisNsoSaveData.createFromRawData(newRawSaveData);
}
static getNsoFileExtension() {
return 'sram';
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return 'genesis';
}
static nsoDataRequiresRomInfo() {
return false;
}
constructor(nsoArrayBuffer, rawArrayBuffer) {
this.nsoArrayBuffer = nsoArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getNsoArrayBuffer() {
return this.nsoArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/NintendoSwitchOnline/N64.js
================================================
// N64 saves on NSO have opposite endianness to emulator saves for SRAM and FlashRAM files. EEPROM files do not need to be endian swapped
import N64Util from '../../util/N64';
import SaveFilesUtil from '../../util/SaveFiles';
export default class NsoN64SaveData {
static createFromNsoData(nsoArrayBuffer) {
const rawArrayBuffer = N64Util.needsEndianSwap(nsoArrayBuffer) ? N64Util.endianSwap(nsoArrayBuffer) : nsoArrayBuffer;
return new NsoN64SaveData(nsoArrayBuffer, rawArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
const nsoArrayBuffer = N64Util.needsEndianSwap(rawArrayBuffer) ? N64Util.endianSwap(rawArrayBuffer) : rawArrayBuffer;
return new NsoN64SaveData(nsoArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(n64FlashCartSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(n64FlashCartSaveData.getRawArrayBuffer(), newSize);
return NsoN64SaveData.createFromRawData(newRawSaveData);
}
static getNsoFileExtension() {
return 'sram';
}
static getRawFileExtension(rawArrayBuffer) {
return N64Util.getFileExtension(rawArrayBuffer);
}
static adjustOutputSizesPlatform() {
return 'n64';
}
static nsoDataRequiresRomInfo() {
return false;
}
constructor(nsoArrayBuffer, rawArrayBuffer) {
this.nsoArrayBuffer = nsoArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getNsoArrayBuffer() {
return this.nsoArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/NintendoSwitchOnline/Nes.js
================================================
/*
The Nintendo Switch Online save format for NES games is the same as those on the NES Classic: the save is padded out to 32kB,
and has a 40-byte SHA-1 digest encoded in ASCII prepended to the beginning
*/
import PaddingUtil from '../../util/Padding';
import SaveFilesUtil from '../../util/SaveFiles';
import Util from '../../util/util';
import HashUtil from '../../util/Hash';
const NSO_NES_FILE_SIZE = 32768;
const NSO_NES_PADDING_VALUE = 0x00;
const NES_FILE_SIZE = 8192; // Although not all NES games have 8kB saves, the selection available on NSO all do
const HASH_ALGORITHM = 'sha1';
const HASH_LENGTH = 40; // The SHA-1 digest is converted to hex and encoded as ASCII
const HASH_ENCODING = 'US-ASCII';
const HASH_OFFSET = 0;
const DATA_BEGIN_OFFSET = HASH_OFFSET + HASH_LENGTH;
function padArrayBuffer(inputArrayBuffer) {
const padding = {
value: NSO_NES_PADDING_VALUE,
count: Math.max(NSO_NES_FILE_SIZE - inputArrayBuffer.byteLength, 0),
};
return PaddingUtil.addPaddingToEnd(inputArrayBuffer, padding);
}
export default class NsoNesSaveData {
static getNsoFileExtension() {
return 'sram';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return 'nes';
}
static nsoDataRequiresRomInfo() {
return false;
}
static createWithNewSize(nsoSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(nsoSaveData.getRawArrayBuffer(), newSize);
return NsoNesSaveData.createFromRawData(newRawSaveData);
}
static createFromNsoData(nsoArrayBuffer) {
return new NsoNesSaveData(nsoArrayBuffer.slice(DATA_BEGIN_OFFSET, DATA_BEGIN_OFFSET + NES_FILE_SIZE), nsoArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
const rawArrayBufferTruncated = rawArrayBuffer.slice(0, NES_FILE_SIZE);
const rawArrayBufferPadded = padArrayBuffer(rawArrayBufferTruncated);
const hashEncodedArrayBuffer = HashUtil.getEncodedHash(rawArrayBufferPadded, HASH_ALGORITHM, HASH_ENCODING);
const nsoArrayBuffer = Util.concatArrayBuffers([hashEncodedArrayBuffer, rawArrayBufferPadded]);
return new NsoNesSaveData(rawArrayBuffer, nsoArrayBuffer);
}
// This constructor creates a new object from a binary representation of a NSO save data file
constructor(rawArrayBuffer, nsoArrayBuffer) {
this.rawArrayBuffer = rawArrayBuffer;
this.nsoArrayBuffer = nsoArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getNsoArrayBuffer() {
return this.nsoArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/NintendoSwitchOnline/Snes.js
================================================
// SNES saves on NSO are just raw save files
import SaveFilesUtil from '../../util/SaveFiles';
export default class SnesNsoSaveData {
static createFromNsoData(nsoArrayBuffer) {
return new SnesNsoSaveData(nsoArrayBuffer, nsoArrayBuffer);
}
static createFromRawData(rawArrayBuffer) {
return new SnesNsoSaveData(rawArrayBuffer, rawArrayBuffer);
}
static createWithNewSize(nsoSaveData, newSize) {
const newRawSaveData = SaveFilesUtil.resizeRawSave(nsoSaveData.getRawArrayBuffer(), newSize);
return SnesNsoSaveData.createFromRawData(newRawSaveData);
}
static getNsoFileExtension() {
return 'sram';
}
static getRawFileExtension() {
return 'srm';
}
static adjustOutputSizesPlatform() {
return 'snes';
}
static nsoDataRequiresRomInfo() {
return false;
}
constructor(nsoArrayBuffer, rawArrayBuffer) {
this.nsoArrayBuffer = nsoArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getNsoArrayBuffer() {
return this.nsoArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/OnlineEmulators/Emulators/EmulatorBase.js
================================================
/*
Base class for converting from save states for online emulators. Handles resizing multiple times without accidentally losing data
*/
import SaveFilesUtil from '../../../util/SaveFiles';
export default class EmulatorBase {
static createFromSaveStateData(emulatorSaveStateArrayBuffer, saveSize, clazz) {
// Note that saveSize may be undefined if we're converting from a platform that doesn't require it (e.g. snes9x).
// So below we pass in rawArrayBuffer.byteLength as the original save size instead of relying on saveSize
const rawArrayBuffer = clazz.getRawArrayBufferFromSaveStateArrayBuffer(emulatorSaveStateArrayBuffer, saveSize);
return new clazz(emulatorSaveStateArrayBuffer, rawArrayBuffer, rawArrayBuffer.byteLength); // eslint-disable-line new-cap
}
static createWithNewSize(emulatorSaveStateData, newSize, clazz) {
// The user's emulator etc may require a different file size than the "true" size.
// We need to make sure that if the user resizes multiple times they don't lose data.
const originalRawArrayBuffer = clazz.getRawArrayBufferFromSaveStateArrayBuffer(emulatorSaveStateData.getEmulatorSaveStateArrayBuffer(), emulatorSaveStateData.getOriginalSaveSize());
const newRawSaveData = SaveFilesUtil.resizeRawSave(originalRawArrayBuffer, newSize);
return new clazz(emulatorSaveStateData.getEmulatorSaveStateArrayBuffer(), newRawSaveData, emulatorSaveStateData.getOriginalSaveSize()); // eslint-disable-line new-cap
}
constructor(emulatorSaveStateArrayBuffer, rawArrayBuffer, saveSize) {
this.emulatorSaveStateArrayBuffer = emulatorSaveStateArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
this.originalSaveSize = saveSize;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getEmulatorSaveStateArrayBuffer() {
return this.emulatorSaveStateArrayBuffer;
}
getOriginalSaveSize() {
return this.originalSaveSize;
}
}
================================================
FILE: frontend/src/save-formats/OnlineEmulators/Emulators/Gambatte.js
================================================
/*
I'm not 100% sure which emulator GB games in emulator.js use.
It may be the Gambatte emulator: https://github.com/EmulatorJS/EmulatorJS/blob/0bf944370c020f9877ca6701081a1963e160b8b0/data/src/emulator.js#L14
And my tenuous belief that that package is relevant is because one site I looked at (www[.]retrogames[.]onl) mentions emulator.js if you dig deeply enough on the page of one of their games
Magic 'sram' is at offset 0x747
Save data size in bytes is stored at offset 0x74B
Save data itself begins at 0x74F
*/
import EmulatorBase from './EmulatorBase';
import PlatformSaveSizes from '../../PlatformSaveSizes';
import Util from '../../../util/util';
const LITTLE_ENDIAN = false;
const MAGIC = 'sram';
const MAGIC_OFFSET = 0x747;
const MAGIC_ENCODING = 'US-ASCII';
const SAVE_SIZE_OFFSET = 0x74B;
const SAVE_OFFSET = 0x74F;
export default class GambatteSaveStateData extends EmulatorBase {
static getRawArrayBufferFromSaveStateArrayBuffer(emulatorSaveStateArrayBuffer) {
if (SAVE_OFFSET > emulatorSaveStateArrayBuffer.byteLength) {
throw new Error('This does not appear to be an Gambatte save state file: file is too short');
}
Util.checkMagic(emulatorSaveStateArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const emulatorSaveStateDataView = new DataView(emulatorSaveStateArrayBuffer);
const saveSize = emulatorSaveStateDataView.getUint32(SAVE_SIZE_OFFSET, LITTLE_ENDIAN);
if (PlatformSaveSizes.gb.indexOf(saveSize) < 0) {
throw new Error(`This does not appear to be an Gambatte save state file: ${saveSize} is not a valid Gameboy save file size`);
}
return emulatorSaveStateArrayBuffer.slice(SAVE_OFFSET, SAVE_OFFSET + saveSize);
}
static createFromSaveStateData(emulatorSaveStateArrayBuffer, saveSize) {
return super.createFromSaveStateData(emulatorSaveStateArrayBuffer, saveSize, GambatteSaveStateData);
}
static createWithNewSize(emulatorSaveStateData, newSize) {
return super.createWithNewSize(emulatorSaveStateData, newSize, GambatteSaveStateData);
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return 'gb';
}
static fileSizeIsRequiredToConvert() {
return false;
}
}
================================================
FILE: frontend/src/save-formats/OnlineEmulators/Emulators/Gb.js
================================================
/*
I have no idea which emulator GB/C games use
*/
import EmulatorBase from './EmulatorBase';
const SRAM_OFFSET = 0x72D; // GB, GBC, and dual-compatibility games all appear to have their SRAM at this offset (despite having different file lengths for their save states)
export default class GbSaveStateData extends EmulatorBase {
static getRawArrayBufferFromSaveStateArrayBuffer(emulatorSaveStateArrayBuffer, saveSize) {
if ((SRAM_OFFSET + saveSize) > emulatorSaveStateArrayBuffer.byteLength) {
throw new Error('This does not appear to be a Gameboy save state file');
}
return emulatorSaveStateArrayBuffer.slice(SRAM_OFFSET, SRAM_OFFSET + saveSize);
}
static createFromSaveStateData(emulatorSaveStateArrayBuffer, saveSize) {
return super.createFromSaveStateData(emulatorSaveStateArrayBuffer, saveSize, GbSaveStateData);
}
static createWithNewSize(emulatorSaveStateData, newSize) {
return super.createWithNewSize(emulatorSaveStateData, newSize, GbSaveStateData);
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return 'gb';
}
static fileSizeIsRequiredToConvert() {
return true;
}
}
================================================
FILE: frontend/src/save-formats/OnlineEmulators/Emulators/Snes9x.js
================================================
/*
SNES9x save states appear to store the game's SRAM data after the magic "SRA::"
*/
import EmulatorBase from './EmulatorBase';
import Util from '../../../util/util';
const MAGIC = 'SRA:'; // Magic begins with this string
const SIZE_END_MAGIC = ':'; // Then we have the size of the SRAM data in bytes, which is terminated with this string
const MAGIC_ENCODING = 'US-ASCII';
export default class Snes9xSaveStateData extends EmulatorBase {
static getRawArrayBufferFromSaveStateArrayBuffer(emulatorSaveStateArrayBuffer) {
try {
const magicOffset = Util.findMagic(emulatorSaveStateArrayBuffer, MAGIC, MAGIC_ENCODING);
const sizeBeginOffset = magicOffset + MAGIC.length;
const sizeEndOffset = Util.findMagic(emulatorSaveStateArrayBuffer, SIZE_END_MAGIC, MAGIC_ENCODING, sizeBeginOffset);
const magicSizeDecoder = new TextDecoder(MAGIC_ENCODING);
const saveSizeString = magicSizeDecoder.decode(emulatorSaveStateArrayBuffer.slice(sizeBeginOffset, sizeEndOffset));
const saveSize = parseInt(saveSizeString, 10);
const rawSaveBeginOffset = sizeEndOffset + SIZE_END_MAGIC.length;
const rawSaveEndOffset = rawSaveBeginOffset + saveSize;
return emulatorSaveStateArrayBuffer.slice(rawSaveBeginOffset, rawSaveEndOffset);
} catch (e) {
throw new Error('This does not appear to be a SNES9x save state file', e);
}
}
static createFromSaveStateData(emulatorSaveStateArrayBuffer) {
return super.createFromSaveStateData(emulatorSaveStateArrayBuffer, undefined, Snes9xSaveStateData);
}
static createWithNewSize(emulatorSaveStateData, newSize) {
return super.createWithNewSize(emulatorSaveStateData, newSize, Snes9xSaveStateData);
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return 'snes';
}
static fileSizeIsRequiredToConvert() {
return false;
}
}
================================================
FILE: frontend/src/save-formats/OnlineEmulators/Emulators/VBA-Next.js
================================================
/*
I'm not 100% sure which emulator GBA games use.
It may be the VBA-next emulator: https://github.com/libretro/vba-next/blob/master/src/gba.cpp#L8820
It matches having the ROM internal name at the start of the file, and having the different types of save data at consistent offsets.
My tenuous belief that it's VBA-next comes from this: https://github.com/liriliri/luna/blob/master/src/retro-emulator/story.js#L20
And my tenuous belief that that package is relevant is because the sites I looked at seem to get their emulation from retroemulator[.]com (which in turn appears to get its ROMs from playroms[.]net)
In-game save data offsets
=========================
These found by creating the same save file in both the online emulator and a standalone emulator, then searching the online emulator file for the save data
512B EEPROM: 0x91000 (the first 512B of EEPROM data are also written at 0x90DEC so that offset could be used too for 512B saves. See VBA-Next source code)
8kB EEPROM: 0x91000
32kB SRAM: 0x93010
64kB Flash RAM: 0x93010
128kB Flash RAM: 0x93010
*/
import EmulatorBase from './EmulatorBase';
const EEPROM_OFFSET = 0x91000;
const SRAM_OFFSET = 0x93010;
const FLASH_RAM_OFFSET = 0x93010;
const SAVE_OFFSET = {
512: EEPROM_OFFSET,
8192: EEPROM_OFFSET,
32768: SRAM_OFFSET,
65536: FLASH_RAM_OFFSET,
131072: FLASH_RAM_OFFSET,
};
export default class VbaNextSaveStateData extends EmulatorBase {
static getRawArrayBufferFromSaveStateArrayBuffer(emulatorSaveStateArrayBuffer, saveSize) {
if (!(saveSize in SAVE_OFFSET)) {
throw new Error(`${saveSize} is not a valid save size for a GBA game`);
}
const rawSaveOffset = SAVE_OFFSET[saveSize];
if ((rawSaveOffset + saveSize) > emulatorSaveStateArrayBuffer.byteLength) {
throw new Error('This does not appear to be a VBA-Next save state file');
}
return emulatorSaveStateArrayBuffer.slice(rawSaveOffset, rawSaveOffset + saveSize);
}
static createFromSaveStateData(emulatorSaveStateArrayBuffer, saveSize) {
return super.createFromSaveStateData(emulatorSaveStateArrayBuffer, saveSize, VbaNextSaveStateData);
}
static createWithNewSize(emulatorSaveStateData, newSize) {
return super.createWithNewSize(emulatorSaveStateData, newSize, VbaNextSaveStateData);
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return 'gba';
}
static fileSizeIsRequiredToConvert() {
return true;
}
}
================================================
FILE: frontend/src/save-formats/OnlineEmulators/Emulators/mGba.js
================================================
/*
I'm not 100% sure which emulator GBA games in emulator.js use.
It may be the mGBA emulator: https://github.com/EmulatorJS/EmulatorJS/blob/0bf944370c020f9877ca6701081a1963e160b8b0/data/src/emulator.js#L26
And my tenuous belief that that package is relevant is because one site I looked at (www[.]retrogames[.]onl) mentions emulator.js if you dig deeply enough on the page of one of their games
Save data size in bytes is stored at offset 0x61004
Save data itself begins at 0x61030
*/
import EmulatorBase from './EmulatorBase';
import PlatformSaveSizes from '../../PlatformSaveSizes';
const LITTLE_ENDIAN = true;
// Note that there exists another version of this emulator that puts the size at 0x61014 and the save data at 0x61040
// We may want to make a Map of SAVE_SIZE_OFFSET to SAVE_OFFSET, and probe each potential SAVE_SIZE_OFFSET to see if
// it has a valid GBA save file size
// See discussion at https://github.com/euan-forrester/save-file-converter/issues/380
const SAVE_SIZE_OFFSET = 0x61004;
const SAVE_OFFSET = 0x61030;
export default class MGbaSaveStateData extends EmulatorBase {
static getRawArrayBufferFromSaveStateArrayBuffer(emulatorSaveStateArrayBuffer) {
if (SAVE_OFFSET > emulatorSaveStateArrayBuffer.byteLength) {
throw new Error('This does not appear to be an mGBA save state file: file is too short');
}
const emulatorSaveStateDataView = new DataView(emulatorSaveStateArrayBuffer);
const saveSize = emulatorSaveStateDataView.getUint32(SAVE_SIZE_OFFSET, LITTLE_ENDIAN);
if (PlatformSaveSizes.gba.indexOf(saveSize) < 0) {
throw new Error(`This does not appear to be an mGBA save state file: ${saveSize} is not a valid GBA save file size`);
}
return emulatorSaveStateArrayBuffer.slice(SAVE_OFFSET, SAVE_OFFSET + saveSize);
}
static createFromSaveStateData(emulatorSaveStateArrayBuffer, saveSize) {
return super.createFromSaveStateData(emulatorSaveStateArrayBuffer, saveSize, MGbaSaveStateData);
}
static createWithNewSize(emulatorSaveStateData, newSize) {
return super.createWithNewSize(emulatorSaveStateData, newSize, MGbaSaveStateData);
}
static getRawFileExtension() {
return 'sav';
}
static adjustOutputSizesPlatform() {
return 'gba';
}
static fileSizeIsRequiredToConvert() {
return false;
}
}
================================================
FILE: frontend/src/save-formats/OnlineEmulators/OnlineEmulatorWrapper.js
================================================
// At their core, many online emulator sites appear to use mostly the same underlying emulators, which appear to be
// supplied to them by a couple of different sources such as neptunejs[.]xyz or retroemulator[.]com
//
// The big difference is that some sites will only allow the user to download/upload a single save state
// while other sites will manage a set of save states and allow the user to download a zip file containing all of them.
//
// Some of the sites that support these zip files will include a screen shot file along with each save state file.
// So we want to filter those out
import JSZip from 'jszip';
import Util from '../../util/util';
import PlatformSaveSizes from '../PlatformSaveSizes';
import Snes9xSaveStateData from './Emulators/Snes9x';
import GbSaveStateData from './Emulators/Gb';
import GambatteSaveStateData from './Emulators/Gambatte';
import VbaNextSaveStateData from './Emulators/VBA-Next';
import MGbaSaveStateData from './Emulators/mGba';
const IMAGE_FILE_TYPES = ['.png', '.jpg', '.jpeg', '.tif', '.tiff', '.gif', '.bmp'];
const GB_SAVE_STATE_TYPES = [
// Gambatte save states have a magic string that we can test for, and the other doesn't, so check from Gambatte first
GambatteSaveStateData,
GbSaveStateData,
];
const GBA_SAVE_STATE_TYPES = [
// We're just testing based on file size, so go from the largest file to the smallest
VbaNextSaveStateData,
MGbaSaveStateData,
];
async function getSaveStatesFromZip(zipContents) {
const compressedSaveStateFiles = zipContents.filter((relativePath, file) => (IMAGE_FILE_TYPES.indexOf(Util.getExtension(file.name)) < 0));
const saveStateData = await Promise.all(compressedSaveStateFiles.map((file) => file.async('arraybuffer')));
return compressedSaveStateFiles.map((file, i) => ({ name: file.name, arrayBuffer: saveStateData[i] }));
}
function getSaveStateFromSingleFile(arrayBuffer, filename) {
return [{ name: filename, arrayBuffer }];
}
async function getSaveStates(arrayBuffer, filename) {
// We need to determine whether we've been given a compressed file containing save states,
// or just given a save state directly
const zip = new JSZip();
let saveStates = [];
try {
const zipContents = await zip.loadAsync(arrayBuffer, { checkCRC32: true });
saveStates = await getSaveStatesFromZip(zipContents);
} catch (e) {
// According to wikipedia, the correct way to determine whether a file is a zip file is to look
// for an end of central directory record. There's also a byte signature at the start of the file,
// but apparently this is optional and not always present
// https://en.wikipedia.org/wiki/ZIP_(file_format)#Structure
if (e.message.startsWith('Can\'t find end of central directory')) {
saveStates = getSaveStateFromSingleFile(arrayBuffer, filename);
} else {
throw e;
}
}
return saveStates;
}
function getSaveStateType(saveStateTypes, arrayBuffer, smallestSaveSize) {
const saveStateType = saveStateTypes.find((clazz) => {
try {
clazz.createFromSaveStateData(arrayBuffer, smallestSaveSize); // Smallest size because if we pick a bigger one we might get a false positive for one of the classes if the save state file is for a smaller sized save state type but with a bigger internal save file
return true;
} catch (e) {
return false;
}
});
if (saveStateType === undefined) {
throw new Error('Unrecogized save state');
}
return saveStateType;
}
function getClass(platform, saveStateArrayBuffer) {
switch (platform) {
case 'snes':
return Snes9xSaveStateData;
case 'gba':
return getSaveStateType(GBA_SAVE_STATE_TYPES, saveStateArrayBuffer, PlatformSaveSizes.gba[0]);
case 'gb':
return getSaveStateType(GB_SAVE_STATE_TYPES, saveStateArrayBuffer, PlatformSaveSizes.gb[0]);
default:
throw new Error(`Unrecognized platform type: '${platform}'`);
}
}
export default class OnlineEmulatorWrapper {
static async createFromEmulatorData(emulatorSaveStateArrayBuffer, emulatorSaveStateFilename, platform, saveSize = null) {
const saveStates = await getSaveStates(emulatorSaveStateArrayBuffer, emulatorSaveStateFilename);
// Now that we have our save state data, turn it into raw in-game saves
if (saveStates.length === 0) {
return new OnlineEmulatorWrapper([], platform, undefined);
}
const clazz = getClass(platform, saveStates[0].arrayBuffer);
const files = saveStates.map((saveState) => ({
name: saveState.name,
emulatorSaveStateData: clazz.createFromSaveStateData(saveState.arrayBuffer, saveSize),
}));
return new OnlineEmulatorWrapper(files, platform, clazz);
}
static getRawFileExtension() {
return 'sav';
}
static createWithNewSize(onlineEmulatorWrapperData, newSize) {
const files = onlineEmulatorWrapperData.getFiles().map((file) => ({
...file,
emulatorSaveStateData: onlineEmulatorWrapperData.getClass().createWithNewSize(file.emulatorSaveStateData, newSize),
}));
return new OnlineEmulatorWrapper(files, onlineEmulatorWrapperData.getPlatform(), onlineEmulatorWrapperData.getClass());
}
static async fileSizeIsRequiredToConvert(emulatorSaveStateArrayBuffer, platform) {
const saveStates = await getSaveStates(emulatorSaveStateArrayBuffer, 'dummy');
if (saveStates.length === 0) {
return false;
}
const clazz = getClass(platform, saveStates[0].arrayBuffer);
return clazz.fileSizeIsRequiredToConvert();
}
constructor(files, platform, clazz) {
this.files = files;
this.platform = platform;
this.clazz = clazz;
}
adjustOutputSizesPlatform() {
return this.platform;
}
getFiles() {
return this.files;
}
getPlatform() {
return this.platform;
}
getClass() {
return this.clazz;
}
}
================================================
FILE: frontend/src/save-formats/PS1/Components/Basics.js
================================================
export default class Ps1Basics {
static NUM_RESERVED_BLOCKS = 1; // The header contains directory information
static NUM_DATA_BLOCKS = 15; // The card has one block that contains the header, then 15 blocks for save data
static NUM_TOTAL_BLOCKS = Ps1Basics.NUM_RESERVED_BLOCKS + Ps1Basics.NUM_DATA_BLOCKS;
static BLOCK_SIZE = 8192; // Each block is this many bytes
static FRAME_SIZE = 128; // The header block contains a set of "frames" which are each this many bytes
static TOTAL_SIZE = Ps1Basics.NUM_TOTAL_BLOCKS * Ps1Basics.BLOCK_SIZE;
static LITTLE_ENDIAN = true;
static MAGIC_ENCODING = 'US-ASCII';
}
================================================
FILE: frontend/src/save-formats/PS1/Components/DirectoryBlock.js
================================================
/* eslint no-bitwise: ["error", { "allow": ["&", "^"] }] */
import Ps1Basics from './Basics';
import Util from '../../../util/util';
const {
LITTLE_ENDIAN,
BLOCK_SIZE,
NUM_DATA_BLOCKS,
NUM_TOTAL_BLOCKS,
FRAME_SIZE,
MAGIC_ENCODING,
} = Ps1Basics;
// Header block
const HEADER_MAGIC = 'MC';
const MAGIC_OFFSET = 0;
// The header contains several mini-blocks called "directory frames", each of which has info about a block of data
const DIRECTORY_FRAME_AVAILABLE_OFFSET = 0x00;
const DIRECTORY_FRAME_NEXT_BLOCK_OFFSET = 0x08;
const DIRECTORY_FRAME_NO_NEXT_BLOCK = 0xFFFF;
const DIRECTORY_FRAME_FILENAME_OFFSET = 0x0A;
const DIRECTORY_FRAME_FILENAME_LENGTH = 20;
const DIRECTORY_FRAME_FILENAME_ENCODING = 'US-ASCII';
const DIRECTORY_FRAME_FILE_SIZE_OFFSET = 0x04;
// These flags are described in the 'available blocks table' here: https://www.psdevwiki.com/ps3/PS1_Savedata#PS1_Single_Save_.3F_.28.PSV.29
const DIRECTORY_FRAME_UNUSED_BLOCK = 0xA0;
const DIRECTORY_FRAME_FIRST_LINK_BLOCK = 0x51;
const DIRECTORY_FRAME_MIDDLE_LINK_BLOCK = 0x52;
const DIRECTORY_FRAME_LAST_LINK_BLOCK = 0x53;
const DIRECTORY_FRAME_UNUSABLE_BLOCK = 0xFF;
function getDirectoryFrame(headerArrayBuffer, blockNum) {
const offset = FRAME_SIZE + (blockNum * FRAME_SIZE); // The first frame contains HEADER_MAGIC, so block 0 is at frame 1
return headerArrayBuffer.slice(offset, offset + FRAME_SIZE);
}
function xorAllBytes(arrayBuffer) {
const array = new Uint8Array(arrayBuffer);
return array.reduce((acc, n) => acc ^ n, 0);
}
function createDirectoryFrame() {
return Util.getFilledArrayBuffer(FRAME_SIZE, 0x00);
}
function createDirectoryFrameMagic() {
const arrayBuffer = Util.setMagic(createDirectoryFrame(), MAGIC_OFFSET, HEADER_MAGIC, MAGIC_ENCODING);
const dataView = new DataView(arrayBuffer);
dataView.setUint8(FRAME_SIZE - 1, xorAllBytes(arrayBuffer));
return arrayBuffer;
}
function createDirectoryFrameEmpty() {
const arrayBuffer = createDirectoryFrame();
const dataView = new DataView(arrayBuffer);
dataView.setUint8(DIRECTORY_FRAME_AVAILABLE_OFFSET, DIRECTORY_FRAME_UNUSED_BLOCK);
dataView.setUint16(DIRECTORY_FRAME_NEXT_BLOCK_OFFSET, DIRECTORY_FRAME_NO_NEXT_BLOCK, LITTLE_ENDIAN);
dataView.setUint8(FRAME_SIZE - 1, xorAllBytes(arrayBuffer));
return arrayBuffer;
}
function createDirectoryFrameUnused() {
const arrayBuffer = createDirectoryFrame();
const dataView = new DataView(arrayBuffer);
dataView.setUint8(DIRECTORY_FRAME_AVAILABLE_OFFSET + 0, DIRECTORY_FRAME_UNUSABLE_BLOCK);
dataView.setUint8(DIRECTORY_FRAME_AVAILABLE_OFFSET + 1, DIRECTORY_FRAME_UNUSABLE_BLOCK);
dataView.setUint8(DIRECTORY_FRAME_AVAILABLE_OFFSET + 2, DIRECTORY_FRAME_UNUSABLE_BLOCK);
dataView.setUint8(DIRECTORY_FRAME_AVAILABLE_OFFSET + 3, DIRECTORY_FRAME_UNUSABLE_BLOCK);
dataView.setUint16(DIRECTORY_FRAME_NEXT_BLOCK_OFFSET, DIRECTORY_FRAME_NO_NEXT_BLOCK, LITTLE_ENDIAN);
return arrayBuffer;
}
function encodeFilename(filename, filenameTextEncoder) {
return filenameTextEncoder.encode(filename).slice(0, DIRECTORY_FRAME_FILENAME_LENGTH);
}
// The filename begins with the country code:
// https://www.psdevwiki.com/ps3/PS1_Savedata#Virtual_Memory_Card_PS1_.28.VM1.29
function getRegionName(filename) {
const firstChar = filename.charAt(0);
if (firstChar === 'B') {
const secondChar = filename.charAt(1);
if (secondChar === 'I') {
return 'Japan';
}
if (secondChar === 'A') {
return 'North America';
}
if (secondChar === 'E') {
return 'Europe';
}
}
return 'Unknown region';
}
function createDirectoryFramesForSave(saveFile, blockNumber, filenameTextEncoder) {
const numBlocks = saveFile.rawData.byteLength / BLOCK_SIZE;
let needEndingBlock = false;
let numMiddleBlocks = 0;
if (numBlocks >= 2) {
needEndingBlock = true;
numMiddleBlocks = numBlocks - 2;
}
const directoryFrames = [];
// First, do the directory frame for the starting block
let currentBlockNumber = blockNumber;
{
const arrayBuffer = createDirectoryFrame();
const array = new Uint8Array(arrayBuffer);
const dataView = new DataView(arrayBuffer);
const encodedFilename = encodeFilename(saveFile.filename, filenameTextEncoder);
dataView.setUint8(DIRECTORY_FRAME_AVAILABLE_OFFSET, DIRECTORY_FRAME_FIRST_LINK_BLOCK);
dataView.setUint32(DIRECTORY_FRAME_FILE_SIZE_OFFSET, saveFile.rawData.byteLength, LITTLE_ENDIAN);
dataView.setUint16(DIRECTORY_FRAME_NEXT_BLOCK_OFFSET, (numBlocks > 1) ? (currentBlockNumber + 1) : DIRECTORY_FRAME_NO_NEXT_BLOCK, LITTLE_ENDIAN);
array.set(encodedFilename, DIRECTORY_FRAME_FILENAME_OFFSET);
dataView.setUint8(FRAME_SIZE - 1, xorAllBytes(arrayBuffer));
directoryFrames.push(arrayBuffer);
}
// Then do the directory frames for any middle blocks
for (let i = 0; i < numMiddleBlocks; i += 1) {
currentBlockNumber += 1;
const arrayBuffer = createDirectoryFrame();
const dataView = new DataView(arrayBuffer);
dataView.setUint8(DIRECTORY_FRAME_AVAILABLE_OFFSET, DIRECTORY_FRAME_MIDDLE_LINK_BLOCK);
dataView.setUint16(DIRECTORY_FRAME_NEXT_BLOCK_OFFSET, currentBlockNumber + 1, LITTLE_ENDIAN);
dataView.setUint8(FRAME_SIZE - 1, xorAllBytes(arrayBuffer));
directoryFrames.push(arrayBuffer);
}
// Then the directory frame for the ending block if needed
if (needEndingBlock) {
const arrayBuffer = createDirectoryFrame();
const dataView = new DataView(arrayBuffer);
dataView.setUint8(DIRECTORY_FRAME_AVAILABLE_OFFSET, DIRECTORY_FRAME_LAST_LINK_BLOCK);
dataView.setUint16(DIRECTORY_FRAME_NEXT_BLOCK_OFFSET, DIRECTORY_FRAME_NO_NEXT_BLOCK, LITTLE_ENDIAN);
dataView.setUint8(FRAME_SIZE - 1, xorAllBytes(arrayBuffer));
directoryFrames.push(arrayBuffer);
}
// All done!
return directoryFrames;
}
export default class Ps1DirectoryBlock {
static DIRECTORY_FRAME_AVAILABLE_OFFSET = DIRECTORY_FRAME_AVAILABLE_OFFSET;
static DIRECTORY_FRAME_NEXT_BLOCK_OFFSET = DIRECTORY_FRAME_NEXT_BLOCK_OFFSET;
static getDirectoryFrame(headerArrayBuffer, blockNum) {
return getDirectoryFrame(headerArrayBuffer, blockNum);
}
static encodeFilename(filename, filenameTextEncoder) {
return encodeFilename(filename, filenameTextEncoder);
}
static createHeaderBlock(saveFiles) {
const directoryFrames = [];
const filenameTextEncoder = new TextEncoder(DIRECTORY_FRAME_FILENAME_ENCODING);
directoryFrames.push(createDirectoryFrameMagic());
saveFiles.forEach((saveFile) => {
const directoryFramesForSaveFile = createDirectoryFramesForSave(saveFile, directoryFrames.length - 1, filenameTextEncoder);
directoryFrames.push(...directoryFramesForSaveFile);
});
// Fill in any remaining space as empty directory frames + save blocks
while (directoryFrames.length < NUM_TOTAL_BLOCKS) { // NUM_TOTAL_BLOCKS rather than NUM_DATA_BLOCKS because we have the magic frame on there first, which corresponds to the header block
directoryFrames.push(createDirectoryFrameEmpty());
}
// Then the rest of the header block is unused directory frames
while (directoryFrames.length < (BLOCK_SIZE / FRAME_SIZE)) {
directoryFrames.push(createDirectoryFrameUnused());
}
// Concat our directory frames together into our header block
return Util.concatArrayBuffers(directoryFrames);
}
static readDirectoryBlock(headerArrayBuffer) {
Util.checkMagic(headerArrayBuffer, MAGIC_OFFSET, HEADER_MAGIC, MAGIC_ENCODING);
const filenameTextDecoder = new TextDecoder(DIRECTORY_FRAME_FILENAME_ENCODING);
const saveFiles = [];
// Go through all the directory frames, and for each block that contains data then get the raw save data and decode its description
for (let i = 0; i < NUM_DATA_BLOCKS; i += 1) {
let directoryFrame = getDirectoryFrame(headerArrayBuffer, i);
let directoryFrameDataView = new DataView(directoryFrame);
const available = directoryFrameDataView.getUint8(DIRECTORY_FRAME_AVAILABLE_OFFSET);
if (((available & 0xF0) === DIRECTORY_FRAME_UNUSED_BLOCK) || (available === DIRECTORY_FRAME_UNUSABLE_BLOCK)) {
// Note that some files have blocks with their 'available' byte set like 0xA1, 0xA2, etc., which
// indicates both that the block is available and it's a link block that's part of a save > 1 block in size.
// So I'm assumeing that the high bits take precidence here and the block is actually available
continue; // eslint-disable-line no-continue
}
if (available === DIRECTORY_FRAME_FIRST_LINK_BLOCK) {
// This block begins a save, which may be comprised of several blocks
const filename = Util.trimNull(filenameTextDecoder.decode(directoryFrame.slice(DIRECTORY_FRAME_FILENAME_OFFSET, DIRECTORY_FRAME_FILENAME_OFFSET + DIRECTORY_FRAME_FILENAME_LENGTH)));
const regionName = getRegionName(filename);
const rawDataSize = directoryFrameDataView.getUint32(DIRECTORY_FRAME_FILE_SIZE_OFFSET, LITTLE_ENDIAN);
const dataBlockNumbers = [i];
// See if there are other blocks that comprise this save
let nextBlockNumber = directoryFrameDataView.getUint16(DIRECTORY_FRAME_NEXT_BLOCK_OFFSET, LITTLE_ENDIAN);
while (nextBlockNumber !== DIRECTORY_FRAME_NO_NEXT_BLOCK) {
dataBlockNumbers.push(nextBlockNumber);
directoryFrame = getDirectoryFrame(headerArrayBuffer, nextBlockNumber);
directoryFrameDataView = new DataView(directoryFrame);
nextBlockNumber = directoryFrameDataView.getUint16(DIRECTORY_FRAME_NEXT_BLOCK_OFFSET, LITTLE_ENDIAN);
}
saveFiles.push({
startingBlock: i,
filename,
regionName,
dataBlockNumbers,
rawDataSize,
});
}
}
return saveFiles;
}
}
================================================
FILE: frontend/src/save-formats/PS1/Components/SaveBlocks.js
================================================
import Ps1Basics from './Basics';
import Util from '../../../util/util';
const {
BLOCK_SIZE,
NUM_DATA_BLOCKS,
MAGIC_ENCODING,
} = Ps1Basics;
const SAVE_BLOCK_MAGIC = 'SC';
const MAGIC_OFFSET = 0;
const SAVE_BLOCK_DESCRIPTION_OFFSET = 0x04;
const SAVE_BLOCK_DESCRIPTION_LENGTH = 64;
const SAVE_BLOCK_DESCRIPTION_ENCODING = 'shift-jis';
function getBlock(dataBlocksArrayBuffer, blockNum) {
const offset = BLOCK_SIZE * blockNum;
return dataBlocksArrayBuffer.slice(offset, offset + BLOCK_SIZE);
}
function createSaveBlockEmpty() {
return Util.getFilledArrayBuffer(BLOCK_SIZE, 0x00);
}
function convertTextToHalfWidth(s) {
// The description stored in the save data is in full-width characters but we'd rather display normal half-width ones
// https://stackoverflow.com/a/58515363
return s.replace(/[\uff01-\uff5e]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)).replace(/\u3000/g, '\u0020');
}
export default class Ps1SaveBlocks {
static checkMagic(rawData) {
Util.checkMagic(rawData, MAGIC_OFFSET, SAVE_BLOCK_MAGIC, MAGIC_ENCODING);
}
static readSaveBlocks(saveFile, dataBlocksArrayBuffer) {
const fileDescriptionTextDecoder = new TextDecoder(SAVE_BLOCK_DESCRIPTION_ENCODING);
const dataBlocks = saveFile.dataBlockNumbers.map((dataBlockNumber) => getBlock(dataBlocksArrayBuffer, dataBlockNumber));
Util.checkMagic(dataBlocks[0], MAGIC_OFFSET, SAVE_BLOCK_MAGIC, MAGIC_ENCODING);
const description = convertTextToHalfWidth(
Util.trimNull(
fileDescriptionTextDecoder.decode(
dataBlocks[0].slice(SAVE_BLOCK_DESCRIPTION_OFFSET, SAVE_BLOCK_DESCRIPTION_OFFSET + SAVE_BLOCK_DESCRIPTION_LENGTH),
),
),
);
// Check that we actually got as many bytes as the file was supposed to have
const rawData = Util.concatArrayBuffers(dataBlocks);
if (rawData.byteLength !== saveFile.rawDataSize) {
throw new Error(`Save file appears to be corrupted: expected file ${description} to be ${saveFile.rawDataSize} bytes, but was actually ${rawData.byteLength} bytes`);
}
return {
...saveFile,
description,
rawData,
};
}
static createSaveBlocks(saveFiles) {
// Divide up the save file itself into blocks
const saveBlocks = [];
saveFiles.forEach((saveFile) => {
const numBlocks = saveFile.rawData.byteLength / BLOCK_SIZE;
for (let i = 0; i < numBlocks; i += 1) {
saveBlocks.push(saveFile.rawData.slice(i * BLOCK_SIZE, (i + 1) * BLOCK_SIZE));
}
});
while (saveBlocks.length < NUM_DATA_BLOCKS) {
saveBlocks.push(createSaveBlockEmpty());
}
return saveBlocks;
}
}
================================================
FILE: frontend/src/save-formats/PS1/Components/SonyUtil.js
================================================
/* eslint no-bitwise: ["error", { "allow": ["^"] }] */
// Class for Sony-specific stuff, such as calculating the signature of a save file
import createHash from 'create-hash';
import CryptoAes from '../../../util/crypto-aes';
import Util from '../../../util/util';
const ENCRYPTION_ALGORITHM = 'aes-128-ecb';
const ENCRYPTION_ALGORITHM_BLOCK_LENGTH = 0x10; // A block in AES is always 128 bits regardless of the key size
const ENCRYPTION_KEY = Buffer.from('AB5ABC9FC1F49DE6A051DBAEFA518859', 'hex'); // https://github.com/dots-tb/vita-mcr2vmp/blob/master/src/main.c#L15
const ENCRYPTION_IV = Buffer.alloc(0); // ECB doesn't use an IV, despite the code we're copying this from specifying one: https://github.com/nodejs/node/issues/10263
const ENCRYPTION_IV_PRETEND = Buffer.from('B30FFEEDB7DC5EB7133DA60D1B6B2CDC', 'hex'); // However, this series of bytes is used for a different purpose in our 'signature' algorithm below
const ENCRYPTION_KEY_LENGTH = ENCRYPTION_KEY.length;
const HASH_ALGORITHM = 'sha1';
const SALT_LENGTH = 0x40;
// It seems that the salt seed can be initialized to anything. vita-mcr2vmp initializes to to all zeros.
// save-editor.com initializes it to a sequence of incrementing values (0, 1, 2, 3, etc)
// After I used a save initially created by save-editor.com on my PSP for some time, I observed that the salt seed was replaced by a different
// sequence of incrementing values, starting at some other number. Perhaps they all get incremented every time the save is updated
// by the hardware?
//
// I'm not sure, but let's just play along and initialize ours to incrementing values as well.
const SALT_SEED_INIT = Buffer.from('000102030405060708090A0B0C0D0E0F10111213', 'hex');
// Based on https://github.com/dots-tb/vita-mcr2vmp/blob/master/src/aes.c#L491
function xorWithIv(arrayBuffer, startingOffset, ivBuffer) {
const outputArrayBuffer = new ArrayBuffer(arrayBuffer.byteLength);
const outputArray = new Uint8Array(outputArrayBuffer);
const inputArray = new Uint8Array(arrayBuffer);
const ivArray = new Uint8Array(ivBuffer);
outputArray.set(inputArray);
for (let i = 0; i < ENCRYPTION_ALGORITHM_BLOCK_LENGTH; i += 1) {
outputArray[i + startingOffset] = inputArray[i + startingOffset] ^ ivArray[i];
}
return outputArrayBuffer;
}
// Based on https://github.com/dots-tb/vita-mcr2vmp/blob/master/src/main.c#L25
function xorWithByte(arrayBuffer, xorValue, length) {
const outputArrayBuffer = new ArrayBuffer(arrayBuffer.byteLength);
const outputArray = new Uint8Array(outputArrayBuffer);
const inputArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < length; i += 1) {
outputArray[i] = inputArray[i] ^ xorValue;
}
return outputArrayBuffer;
}
export default class SonyUtil {
static SALT_SEED_INIT = SALT_SEED_INIT;
// Note that our arrayBuffer is for the entire PSP file, including the header which must in turn include the salt seed
// The signature in the header is zero'ed out before the signature is calculated, but everything else must be there
static calculateSignature(arrayBuffer, saltSeed, saltSeedLength, signatureOffset, signatureLength) {
// First, we calculate our salt
// Implmentation copied from: https://github.com/dots-tb/vita-mcr2vmp/blob/master/src/main.c#L105
let salt = new ArrayBuffer(SALT_LENGTH);
let workBuffer = new ArrayBuffer(ENCRYPTION_KEY_LENGTH); // In the code we're copying from, only this many bytes are actually used from this buffer, until the very end when it's repurposed to receive the final sha1 digest
workBuffer = Util.setArrayBufferPortion(workBuffer, saltSeed, 0, 0, ENCRYPTION_KEY_LENGTH);
workBuffer = CryptoAes.decrypt(workBuffer, ENCRYPTION_ALGORITHM, ENCRYPTION_KEY, ENCRYPTION_IV);
salt = Util.setArrayBufferPortion(salt, workBuffer, 0, 0, ENCRYPTION_KEY_LENGTH);
workBuffer = Util.setArrayBufferPortion(workBuffer, saltSeed, 0, 0, ENCRYPTION_KEY_LENGTH);
workBuffer = CryptoAes.encrypt(workBuffer, ENCRYPTION_ALGORITHM, ENCRYPTION_KEY, ENCRYPTION_IV);
salt = Util.setArrayBufferPortion(salt, workBuffer, ENCRYPTION_KEY_LENGTH, 0, ENCRYPTION_KEY_LENGTH);
salt = xorWithIv(salt, 0, ENCRYPTION_IV_PRETEND); // The only place our IV is actually used: as a random series of bytes
workBuffer = Util.fillArrayBuffer(workBuffer, 0xFF);
workBuffer = Util.setArrayBufferPortion(workBuffer, saltSeed, 0, ENCRYPTION_KEY_LENGTH, saltSeedLength - ENCRYPTION_KEY_LENGTH);
salt = xorWithIv(salt, ENCRYPTION_KEY_LENGTH, workBuffer);
salt = Util.fillArrayBufferPortion(salt, saltSeedLength, SALT_LENGTH - saltSeedLength, 0);
salt = xorWithByte(salt, 0x36, SALT_LENGTH);
// Then we calculate our hash
// Implementation copied from: https://github.com/dots-tb/vita-mcr2vmp/blob/master/src/main.c#L124
const inputArrayBufferWithoutHash = Util.fillArrayBufferPortion(arrayBuffer, signatureOffset, signatureLength, 0);
const hash1 = createHash(HASH_ALGORITHM);
hash1.update(Buffer.from(salt));
hash1.update(Buffer.from(inputArrayBufferWithoutHash));
const hash1Output = hash1.digest();
const hash2 = createHash(HASH_ALGORITHM);
salt = xorWithByte(salt, 0x6A, SALT_LENGTH);
hash2.update(Buffer.from(salt));
hash2.update(hash1Output);
return hash2.digest();
}
}
================================================
FILE: frontend/src/save-formats/PS1/DexDrive.js
================================================
/*
The DexDrive data format is:
- 3904 byte header:
- Magic
- Copy of the 'available' flags from the memcard header
- Copy of the linked blocks from the memcard header
- A comment for each block
- Normal PS1 memory card data
*/
import Ps1MemcardSaveData from './Memcard';
import Ps1DirectoryBlock from './Components/DirectoryBlock';
import Ps1Basics from './Components/Basics';
import Util from '../../util/util';
const {
NUM_DATA_BLOCKS,
TOTAL_SIZE,
LITTLE_ENDIAN,
} = Ps1Basics;
// DexDrive header
const HEADER_LENGTH = 3904;
const HEADER_MAGIC = '123-456-STD';
const MAGIC_ENCODING = 'US-ASCII';
const AVAILABLE_BLOCKS_OFFSET = 22;
const LINK_BLOCKS_OFFSET = 38;
// MemcardRex uses the system default encoding for the comments (https://github.com/ShendoXT/memcardrex/blob/master/MemcardRex/ps1card.cs#L175), which is usually utf8.
// I would guess that an old device like an actual DexDrive may instead use US-ASCII though.
// But the actual devices don't really exist anymore and most/all of these files that exist today would be written out by MemcardRex?
const COMMENT_ENCODING = 'utf8';
const FIRST_COMMENT_OFFSET = 64;
const COMMENT_LENGTH = 256;
function getCommentStartOffset(i) {
return FIRST_COMMENT_OFFSET + (i * COMMENT_LENGTH);
}
// https://github.com/ShendoXT/memcardrex/blob/master/MemcardRex/ps1card.cs#L689
function getComments(headerArrayBuffer) {
const comments = [];
const textDecoder = new TextDecoder(COMMENT_ENCODING);
for (let i = 0; i < NUM_DATA_BLOCKS; i += 1) {
const commentStartOffset = getCommentStartOffset(i);
const commentArrayBuffer = headerArrayBuffer.slice(commentStartOffset, commentStartOffset + COMMENT_LENGTH);
comments.push(Util.trimNull(textDecoder.decode(commentArrayBuffer)).trim());
}
return comments;
}
export default class Ps1DexDriveSaveData {
static createFromDexDriveData(dexDriveArrayBuffer) {
return new Ps1DexDriveSaveData(dexDriveArrayBuffer);
}
static createFromMemoryCardData(memcardArrayBuffer, comment) {
const memcardSaveData = Ps1MemcardSaveData.createFromPs1MemcardData(memcardArrayBuffer);
const memcardSaveDataFilesWithComments = memcardSaveData.getSaveFiles().map((file) => ({ ...file, comment }));
return Ps1DexDriveSaveData.createFromSaveFiles(memcardSaveDataFilesWithComments);
}
static createFromSaveFiles(saveFiles) {
// The DexDrive image is the DexDrive header then the regular memcard data
const memcardSaveData = Ps1MemcardSaveData.createFromSaveFiles(saveFiles);
const headerArrayBuffer = new ArrayBuffer(HEADER_LENGTH);
const headerArray = new Uint8Array(headerArrayBuffer);
const magicTextEncoder = new TextEncoder(MAGIC_ENCODING);
const commentTextEncoder = new TextEncoder(COMMENT_ENCODING);
// Make an array of our comments, arranged by the starting block of each save
const comments = Array.from({ length: NUM_DATA_BLOCKS }, () => null);
const memcardSaveDataFilesWithComments = memcardSaveData.getSaveFiles().map((file, i) => ({ ...file, comment: saveFiles[i].comment })); // Our list of save files from the memcard data is in the same order as the files were passed in
memcardSaveDataFilesWithComments.forEach((file) => { comments[file.startingBlock] = file.comment; });
// Based on https://github.com/ShendoXT/memcardrex/blob/master/MemcardRex/ps1card.cs#L145
headerArray.fill(0);
headerArray.set(magicTextEncoder.encode(HEADER_MAGIC), 0);
headerArray[18] = 0x1;
headerArray[20] = 0x1;
headerArray[21] = 0x4D; // M
for (let i = 0; i < NUM_DATA_BLOCKS; i += 1) {
const directoryFrame = memcardSaveData.getDirectoryFrame(i);
const directoryDataView = new DataView(directoryFrame);
const availableFlags = directoryDataView.getUint8(Ps1DirectoryBlock.DIRECTORY_FRAME_AVAILABLE_OFFSET);
const nextLinkedBlock = directoryDataView.getUint16(Ps1DirectoryBlock.DIRECTORY_FRAME_NEXT_BLOCK_OFFSET, LITTLE_ENDIAN);
headerArray[AVAILABLE_BLOCKS_OFFSET + i] = availableFlags;
headerArray[LINK_BLOCKS_OFFSET + i] = nextLinkedBlock;
// In theory with the link blocks we should do more work. With this implementation, we're just copying over whatever is in the link block, but in
// a real DexDrive file we'd want to set it to 0xFF if the available flags indicate that the block is available or unusable. This is because when data
// is erased the link block can still be set to something even though the block is marked as available.
//
// However, in our case, because we're making the memcard image from scratch, there's no possibility of this.
if (comments[i] !== null) {
const encodedComment = commentTextEncoder.encode(comments[i]).slice(0, COMMENT_LENGTH);
headerArray.set(encodedComment, getCommentStartOffset(i));
}
}
// Now that we've created our DexDrive header, we can create our final memory image. We'll parse it again
// to pull out the file descriptions
const finalArrayBuffer = Util.concatArrayBuffers([headerArrayBuffer, memcardSaveData.getArrayBuffer()]);
return Ps1DexDriveSaveData.createFromDexDriveData(finalArrayBuffer);
}
// This constructor creates a new object from a binary representation of a DexDrive save data file
constructor(arrayBuffer) {
this.arrayBuffer = arrayBuffer;
// Parse the DexDrive-specific header: magic and comments
let dexDriveHeaderArrayBuffer = arrayBuffer.slice(0, HEADER_LENGTH);
let memcardArrayBuffer = arrayBuffer.slice(HEADER_LENGTH); // The remainder of the file is the actual contents of the memory card
try {
Util.checkMagic(dexDriveHeaderArrayBuffer, 0, HEADER_MAGIC, MAGIC_ENCODING);
} catch (e) {
if (arrayBuffer.byteLength === TOTAL_SIZE) {
// Some files, found on gamefaqs primarily, are labeled as being dexdrive but are actually
// raw memcard images. This is likely due to gamefaqs' policy of only allowing "legitimate" saves
// and not those that could have come from an emulator.
dexDriveHeaderArrayBuffer = Util.getFilledArrayBuffer(HEADER_LENGTH, 0x00);
memcardArrayBuffer = arrayBuffer;
} else if (arrayBuffer.byteLength !== (HEADER_LENGTH + TOTAL_SIZE)) {
// For some files found on the Internet they just contain a completely blank header. Not sure what
// program makes them. But they're parseable by the rest of the code here even though they don't
// contain the correct magic
throw new Error('This does not appear to be a PS1 DexDrive file');
}
}
// Note that the DexDrive header also contains the Available flag for each block (beginning at offset 22), copied from the system header,
// and the link order for each block (beginning at offset 38), also copied from the system header.
// https://github.com/ShendoXT/memcardrex/blob/master/MemcardRex/ps1card.cs#L171
const comments = getComments(dexDriveHeaderArrayBuffer);
// Parse the rest of the file
this.memoryCard = Ps1MemcardSaveData.createFromPs1MemcardData(memcardArrayBuffer);
// Add in the comments we found in the header
this.saveFiles = this.memoryCard.getSaveFiles().map((x) => ({ ...x, comment: comments[x.startingBlock] }));
}
getSaveFiles() {
return this.saveFiles;
}
getMemoryCard() {
return this.memoryCard;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/PS1/Memcard.js
================================================
/*
The PS1 memcard format is described here:
https://www.psdevwiki.com/ps3/PS1_Savedata#Virtual_Memory_Card_PS1_.28.VM1.29
*/
import Ps1Basics from './Components/Basics';
import Ps1DirectoryBlock from './Components/DirectoryBlock';
import Ps1SaveBlocks from './Components/SaveBlocks';
import Util from '../../util/util';
const {
BLOCK_SIZE,
NUM_DATA_BLOCKS,
NUM_TOTAL_BLOCKS,
} = Ps1Basics;
function checkFile(file) {
Ps1SaveBlocks.checkMagic(file.rawData);
if (file.rawData.byteLength <= 0) {
throw new Error(`File ${file.filename} does not contain any data`);
}
if ((file.rawData.byteLength % BLOCK_SIZE) !== 0) {
throw new Error(`File ${file.filename} size must be a multiple of ${BLOCK_SIZE} bytes`);
}
}
export default class Ps1MemcardSaveData {
static encodeFilename(filename, filenameTextEncoder) {
return Ps1DirectoryBlock.encodeFilename(filename, filenameTextEncoder);
}
static createFromPs1MemcardData(memcardArrayBuffer) {
return new Ps1MemcardSaveData(memcardArrayBuffer);
}
static createFromSaveFiles(saveFiles) {
// Make sure that each file has the correct magic, is a correct size, and the total size isn't bigger than a single memcard
saveFiles.forEach((f) => checkFile(f));
const totalSize = saveFiles.reduce((total, f) => total + f.rawData.byteLength, 0);
if (totalSize > (NUM_DATA_BLOCKS * BLOCK_SIZE)) {
throw new Error(`Total size of files is ${totalSize} bytes (${totalSize / BLOCK_SIZE} blocks) but max size is ${NUM_DATA_BLOCKS * BLOCK_SIZE} bytes (${NUM_DATA_BLOCKS} blocks)`);
}
// Create our directory frames + save blocks
const headerBlock = Ps1DirectoryBlock.createHeaderBlock(saveFiles);
const saveBlocks = Ps1SaveBlocks.createSaveBlocks(saveFiles);
// Concat all of our blocks together to form our memcard image
const arrayBuffer = Util.concatArrayBuffers([headerBlock, ...saveBlocks]);
// Now go back and re-parse the data we've created to get the description etc for each file
return new Ps1MemcardSaveData(arrayBuffer);
}
// This constructor creates a new object from a binary representation of a PS1 memcard
constructor(arrayBuffer) {
this.arrayBuffer = arrayBuffer;
const headerArrayBuffer = arrayBuffer.slice(0, BLOCK_SIZE); // The first block describes the layout of the card, and is hidden from the user
const dataBlocksArrayBuffer = arrayBuffer.slice(BLOCK_SIZE, BLOCK_SIZE * NUM_TOTAL_BLOCKS); // The remaining blocks contain the actual save data
const saveFilesFromDirectory = Ps1DirectoryBlock.readDirectoryBlock(headerArrayBuffer);
this.saveFiles = saveFilesFromDirectory.map((saveFile) => Ps1SaveBlocks.readSaveBlocks(saveFile, dataBlocksArrayBuffer));
}
getDirectoryFrame(i) {
const headerArrayBuffer = this.arrayBuffer.slice(0, BLOCK_SIZE);
return Ps1DirectoryBlock.getDirectoryFrame(headerArrayBuffer, i);
}
getSaveFiles() {
return this.saveFiles;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/PS1/Ps3.js
================================================
/* eslint no-bitwise: ["error", { "allow": ["^"] }] */
/*
PS3 files are a bit different from the PSP/DexDrive files. The PS3 just imports/exports individual save files, so there's
no representation of a whole memory card (there is one internally in the PS3, which is just a raw PS1 memory card,
but that's not user-facing unless the console is hacked)
The PS3 individual save files consist of a header then the regular PS1 save data. A description of the format can be
found here: https://psdevwiki.com/ps3/PS1_Savedata#PS1_Single_Save_.3F_.28.PSV.29
*/
import Ps1Basics from './Components/Basics';
import Ps1MemcardSaveData from './Memcard';
import SonyUtil from './Components/SonyUtil';
import Util from '../../util/util';
const {
LITTLE_ENDIAN,
FRAME_SIZE,
} = Ps1Basics;
// PS3 header
const HEADER_LENGTH = 0x84;
const HEADER_MAGIC = '\x00VSP\x00\x00\x00\x00';
const MAGIC_ENCODING = 'US-ASCII';
const SALT_SEED_OFFSET = 0x08;
const SALT_SEED_LENGTH = 0x14;
const SIGNATURE_OFFSET = 0x1C;
const SIGNATURE_LENGTH = 0x14;
const PLATFORM_INDICATOR_1_OFFSET = 0x38;
const PLATFORM_INDICATOR_2_OFFSET = 0x3C;
const PLATFORM_INDICATOR_1_PS1 = 0x14;
// const PLATFORM_INDICATOR_1_PS2 = 0x2C;
const PLATFORM_INDICATOR_2_PS1 = 0x1;
// const PLATFORM_INDICATOR_2_PS2 = 0x2;
const SAVE_SIZE_OFFSET = 0x40; // Size of the PS1/2 data in the save (so, excluding the PS3 header)
const SAVE_START_BYTE_OFFSET = 0x44; // How many bytes after the start of the file that the actual PS1/2 save starts
const SAVE_HEADER_SIZE_OFFSET = 0x48; // How big the initial "SC" header is within the save data
const SAVE_HEADER_SIZE_PS1 = FRAME_SIZE * 4;
const FILENAME_PRODUCT_CODE_OFFSET = 0x64;
const FILENAME_PRODUCT_CODE_LENGTH = 0x0C;
const FILENAME_DESCRIPTION_OFFSET = 0x70;
const FILENAME_DESCRIPTION_LENGTH = 0x08;
const FILENAME_ENCODING = 'US-ASCII';
// Filename of a PS1 save in the style of uLaunchElf
function getPs1IndividualFilename(ps3HeaderArrayBuffer, filenameTextDecoder) {
const offset = FILENAME_PRODUCT_CODE_OFFSET;
const length = FILENAME_PRODUCT_CODE_LENGTH + FILENAME_DESCRIPTION_LENGTH;
return Util.trimNull(filenameTextDecoder.decode(ps3HeaderArrayBuffer.slice(offset, offset + length)));
}
// Filename of a PS1 save in the style of a PS3:
// It's the product code concatted with the description encoded as hex bytes
// https://psdevwiki.com/ps3/PS1_Savedata#Filename_Format
function getPs3IndividualFilename(ps3HeaderArrayBuffer, filenameTextDecoder) {
const productCode = Util.trimNull(
filenameTextDecoder.decode(
ps3HeaderArrayBuffer.slice(FILENAME_PRODUCT_CODE_OFFSET, FILENAME_PRODUCT_CODE_OFFSET + FILENAME_PRODUCT_CODE_LENGTH),
),
);
const descriptionArrayBuffer = ps3HeaderArrayBuffer.slice(FILENAME_DESCRIPTION_OFFSET, FILENAME_DESCRIPTION_OFFSET + FILENAME_DESCRIPTION_LENGTH);
const descriptionArray = Util.getNullTerminatedArray(new Uint8Array(descriptionArrayBuffer), 0);
const descriptionEncoded = Util.uint8ArrayToHex(descriptionArray);
return `${productCode}${descriptionEncoded}.PSV`;
}
function createPs3SaveFile(ps1SaveFile) {
// First create the header
const ps3HeaderArrayBuffer = new ArrayBuffer(HEADER_LENGTH);
const ps3HeaderArray = new Uint8Array(ps3HeaderArrayBuffer);
const ps3HeaderDataView = new DataView(ps3HeaderArrayBuffer);
const magicTextEncoder = new TextEncoder(MAGIC_ENCODING);
const filenameTextEncoder = new TextEncoder(FILENAME_ENCODING);
const encodedFilename = Ps1MemcardSaveData.encodeFilename(ps1SaveFile.filename, filenameTextEncoder);
ps3HeaderArray.fill(0);
ps3HeaderArray.set(magicTextEncoder.encode(HEADER_MAGIC), 0);
ps3HeaderArray.set(new Uint8Array(SonyUtil.SALT_SEED_INIT), SALT_SEED_OFFSET);
ps3HeaderArray.set(encodedFilename, FILENAME_PRODUCT_CODE_OFFSET);
ps3HeaderDataView.setUint32(PLATFORM_INDICATOR_1_OFFSET, PLATFORM_INDICATOR_1_PS1, LITTLE_ENDIAN);
ps3HeaderDataView.setUint32(PLATFORM_INDICATOR_2_OFFSET, PLATFORM_INDICATOR_2_PS1, LITTLE_ENDIAN);
ps3HeaderDataView.setUint32(SAVE_SIZE_OFFSET, ps1SaveFile.rawData.byteLength, LITTLE_ENDIAN);
ps3HeaderDataView.setUint32(SAVE_START_BYTE_OFFSET, HEADER_LENGTH, LITTLE_ENDIAN);
ps3HeaderDataView.setUint32(SAVE_HEADER_SIZE_OFFSET, SAVE_HEADER_SIZE_PS1, LITTLE_ENDIAN);
// These are listed as "unknown" on the ps3 dev wiki: https://psdevwiki.com/ps3/PS1_Savedata#PS1_Single_Save_.3F_.28.PSV.29
// And in memcardrex they're filled in with the examples from the wiki: https://github.com/ShendoXT/memcardrex/blob/master/MemcardRex/ps1card.cs#L333
// So we'll follow along with memcardrex
ps3HeaderDataView.setUint32(0x5C, 0x2000, LITTLE_ENDIAN);
ps3HeaderDataView.setUint32(0x60, 0x9003, LITTLE_ENDIAN);
// Now we can calculate a signature
const combinedArrayBuffer = Util.concatArrayBuffers([ps3HeaderArrayBuffer, ps1SaveFile.rawData]);
const signature = SonyUtil.calculateSignature(combinedArrayBuffer, SonyUtil.SALT_SEED_INIT, SALT_SEED_LENGTH, SIGNATURE_OFFSET, SIGNATURE_LENGTH);
const ps3SaveDataArrayBuffer = Util.setArrayBufferPortion(combinedArrayBuffer, signature, SIGNATURE_OFFSET, 0, SIGNATURE_LENGTH);
// We're all done!
const filenameTextDecoder = new TextDecoder(FILENAME_ENCODING);
return {
startingBlock: ps1SaveFile.startingBlock,
filename: getPs3IndividualFilename(ps3HeaderArrayBuffer, filenameTextDecoder),
description: ps1SaveFile.description,
rawData: ps3SaveDataArrayBuffer,
};
}
export default class Ps3SaveData {
static createFromPs3SaveFiles(ps3SaveFiles) {
// The PS3 image is the PS3 header then the regular memcard data
const filenameTextDecoder = new TextDecoder(FILENAME_ENCODING);
const ps1SaveFiles = ps3SaveFiles.map((ps3SaveFile) => {
// Parse the PS3-specific header
const ps1SaveDataArrayBuffer = ps3SaveFile.rawData.slice(HEADER_LENGTH);
const ps3HeaderArrayBuffer = ps3SaveFile.rawData.slice(0, HEADER_LENGTH);
const ps3HeaderDataView = new DataView(ps3HeaderArrayBuffer);
Util.checkMagic(ps3HeaderArrayBuffer, 0, HEADER_MAGIC, MAGIC_ENCODING);
// Check the signature
const saltSeed = ps3HeaderArrayBuffer.slice(SALT_SEED_OFFSET, SALT_SEED_OFFSET + SALT_SEED_LENGTH);
const signatureCalculated = SonyUtil.calculateSignature(ps3SaveFile.rawData, saltSeed, SALT_SEED_LENGTH, SIGNATURE_OFFSET, SIGNATURE_LENGTH);
const signatureFound = Buffer.from(ps3HeaderArrayBuffer.slice(SIGNATURE_OFFSET, SIGNATURE_OFFSET + SIGNATURE_LENGTH));
if (signatureFound.compare(signatureCalculated) !== 0) {
throw new Error(`Save appears to be corrupted: expected signature ${signatureFound.toString('hex')} but calculated signature ${signatureCalculated.toString('hex')}`);
}
// Check the platform
const platformIndicator1 = ps3HeaderDataView.getUint32(PLATFORM_INDICATOR_1_OFFSET, LITTLE_ENDIAN);
const platformIndicator2 = ps3HeaderDataView.getUint32(PLATFORM_INDICATOR_2_OFFSET, LITTLE_ENDIAN);
if ((platformIndicator1 !== PLATFORM_INDICATOR_1_PS1) || (platformIndicator2 !== PLATFORM_INDICATOR_2_PS1)) {
throw new Error('This does not appear to be a PS1 save file');
}
// Check the size etc
const saveSize = ps3HeaderDataView.getUint32(SAVE_SIZE_OFFSET, LITTLE_ENDIAN);
const saveStartByte = ps3HeaderDataView.getUint32(SAVE_START_BYTE_OFFSET, LITTLE_ENDIAN);
const saveHeaderSize = ps3HeaderDataView.getUint32(SAVE_HEADER_SIZE_OFFSET, LITTLE_ENDIAN);
if (saveSize !== ps1SaveDataArrayBuffer.byteLength) {
throw new Error(`Size mismatch: size is specified as ${saveSize} bytes in the header but actual save size is ${ps1SaveDataArrayBuffer.byteLength} bytes`);
}
if (saveStartByte !== HEADER_LENGTH) {
throw new Error('Save appears to be corrupted: save start byte does not match header size');
}
if (saveHeaderSize !== SAVE_HEADER_SIZE_PS1) {
throw new Error(`Save appears to be corrupted: save header size appears incorrect. Got ${saveHeaderSize}`);
}
// Everything checks out
//
// Note that because we're creating this from PS1 save files, the new PS3 files that are created from this
// will have a different salt seed and thus signature than the original ones. In theory, we should be outputting
// the original PS3 saves again, but in practice who would want to convert a PS3 save to a PS3 save? So it shouldn't matter.
return {
startingBlock: null, // Not needed to be set
filename: getPs1IndividualFilename(ps3HeaderArrayBuffer, filenameTextDecoder),
description: null, // Not needed to be set
rawData: ps1SaveDataArrayBuffer,
};
});
const memcardSaveData = Ps1MemcardSaveData.createFromSaveFiles(ps1SaveFiles);
return new Ps3SaveData(memcardSaveData.getArrayBuffer());
}
static createFromPs1SaveFiles(ps1SaveFiles) {
const memcardSaveData = Ps1MemcardSaveData.createFromSaveFiles(ps1SaveFiles);
return new Ps3SaveData(memcardSaveData.getArrayBuffer());
}
// This constructor creates a new object from a binary representation of a PS1 save data file
constructor(arrayBuffer) {
this.memoryCard = Ps1MemcardSaveData.createFromPs1MemcardData(arrayBuffer);
this.saveFiles = this.memoryCard.getSaveFiles();
this.ps3SaveFiles = this.saveFiles.map((ps1SaveFile) => createPs3SaveFile(ps1SaveFile));
}
getSaveFiles() {
return this.saveFiles;
}
getPs3SaveFiles() {
return this.ps3SaveFiles;
}
getMemoryCard() {
return this.memoryCard;
}
}
================================================
FILE: frontend/src/save-formats/PS1/Psp.js
================================================
/*
The PSP data format for PS1 Classics is:
- 128 byte header that contains a seed and a signature
- Normal PS1 memory card data
The format is described here: https://www.psdevwiki.com/ps3/PS1_Savedata#Virtual_Memory_Card_PSP_.28.VMP.29
Note that the description of the signature in that link is incorrect. The signature is generated using a convoluted series
of encryption and hashes with some other random operations thrown in for good measure. The implementation below is
based on https://github.com/dots-tb/vita-mcr2vmp
*/
// Also, rather than importing the node crypto module, which is huge, we're going to use
// just a portion of it as implemented in https://github.com/crypto-browserify/createHash
import Ps1MemcardSaveData from './Memcard';
import SonyUtil from './Components/SonyUtil';
import Util from '../../util/util';
// PSP header
const HEADER_LENGTH = 0x80;
const HEADER_MAGIC = [0, 0x50, 0x4D, 0x56, HEADER_LENGTH, 0, 0, 0, 0, 0, 0, 0]; // 'PMV' + the header length. The 0x80 byte is problematic for decoding into US-ASCII (or other charsets), so just do this one as an array
const SALT_SEED_OFFSET = 0x0C;
const SALT_SEED_LENGTH = 0x14;
const SIGNATURE_OFFSET = 0x20;
const SIGNATURE_LENGTH = 0x14;
export default class PspSaveData {
static createFromPspData(pspArrayBuffer) {
return new PspSaveData(pspArrayBuffer);
}
static createFromMemoryCardData(memcardArrayBuffer) {
// The PSP image is the PSP header then the regular memcard data
// First, construct the basic header from the magic and the initial salt seed
const headerArrayBuffer = new ArrayBuffer(HEADER_LENGTH);
const headerArray = new Uint8Array(headerArrayBuffer);
const memcardSaveData = Ps1MemcardSaveData.createFromPs1MemcardData(memcardArrayBuffer);
headerArray.set(HEADER_MAGIC, 0);
const saltSeedArray = new Uint8Array(SonyUtil.SALT_SEED_INIT);
headerArray.set(saltSeedArray, SALT_SEED_OFFSET);
// Then concat that with the rest of the memcard data
const combinedArrayBuffer = Util.concatArrayBuffers([headerArrayBuffer, memcardSaveData.getArrayBuffer()]);
// Now we can calculate our signature
const saltSeed = Util.bufferToArrayBuffer(SonyUtil.SALT_SEED_INIT);
const signatureCalculated = SonyUtil.calculateSignature(combinedArrayBuffer, saltSeed, SALT_SEED_LENGTH, SIGNATURE_OFFSET, SIGNATURE_LENGTH);
// Inject the signature and we're done! We'll parse it again
// to pull out the file descriptions
const finalArrayBuffer = Util.setArrayBufferPortion(combinedArrayBuffer, signatureCalculated, SIGNATURE_OFFSET, 0, SIGNATURE_LENGTH);
return PspSaveData.createFromPspData(finalArrayBuffer);
}
static createFromSaveFiles(saveFiles) {
const memcardSaveData = Ps1MemcardSaveData.createFromSaveFiles(saveFiles);
return PspSaveData.createFromMemoryCardData(memcardSaveData.getArrayBuffer());
}
// This constructor creates a new object from a binary representation of a PSP PS1 save data file
constructor(arrayBuffer) {
this.arrayBuffer = arrayBuffer;
// Parse the PSP-specific header
const pspHeaderArrayBuffer = arrayBuffer.slice(0, HEADER_LENGTH);
Util.checkMagicBytes(pspHeaderArrayBuffer, 0, HEADER_MAGIC);
const saltSeed = pspHeaderArrayBuffer.slice(SALT_SEED_OFFSET, SALT_SEED_OFFSET + SALT_SEED_LENGTH);
const signatureCalculated = SonyUtil.calculateSignature(arrayBuffer, saltSeed, SALT_SEED_LENGTH, SIGNATURE_OFFSET, SIGNATURE_LENGTH);
// Check the signature we generated against the one we found
const signatureFound = Buffer.from(pspHeaderArrayBuffer.slice(SIGNATURE_OFFSET, SIGNATURE_OFFSET + SIGNATURE_LENGTH));
if (signatureFound.compare(signatureCalculated) !== 0) {
throw new Error(`Save appears to be corrupted: expected signature ${signatureFound.toString('hex')} but calculated signature ${signatureCalculated.toString('hex')}`);
}
// Parse the rest of the file
const memcardArrayBuffer = arrayBuffer.slice(HEADER_LENGTH); // The remainder of the file is the actual contents of the memory card
this.memoryCard = Ps1MemcardSaveData.createFromPs1MemcardData(memcardArrayBuffer);
this.saveFiles = this.memoryCard.getSaveFiles();
}
getSaveFiles() {
return this.saveFiles;
}
getMemoryCard() {
return this.memoryCard;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/PSP/Executable.js
================================================
/* eslint-disable no-bitwise */
import PspEncryptionUtil from './PspEncryptionUtil';
import Util from '../../util/util';
import CompressionGzip from '../../util/CompressionGzip';
// The header structure of this file is described here: https://github.com/hrydgard/ppsspp/blob/master/Core/ELF/PrxDecrypter.h#L26
const LITTLE_ENDIAN = true;
const MAGIC_ENCODING = 'US-ASCII';
const ENCRYPTED_MAGIC_OFFSET = 0;
const UNENCRYPTED_MAGIC_OFFSET = 0;
const ENCRYPTED_MAGIC = '~PSP';
const ELF_MAGIC = '\x7FELF';
const ELF_MAGIC_OFFSET = 0x150;
const COMPRESSION_ATTRIBUTES_OFFSET = 0x06;
const ELF_SIZE_OFFSET = 0x28;
const PSP_SIZE_OFFSET = 0x2C;
const COMPRESSION_ATTRIBUTES_IS_GZIP = 0x01;
export default class PspExecutable {
// This is based on __KernelLoadELFFromPtr() from https://github.com/hrydgard/ppsspp/blob/2a372caef9acbc7ff20bcca3c25b2ab92294f283/Core/HLE/sceKernelModule.cpp#L1250
static createFromEncryptedData(encryptedExecutableArrayBuffer) {
// First get some information from our header
Util.checkMagic(encryptedExecutableArrayBuffer, ENCRYPTED_MAGIC_OFFSET, ENCRYPTED_MAGIC, MAGIC_ENCODING);
const headerDataView = new DataView(encryptedExecutableArrayBuffer);
const compressionAttributes = headerDataView.getUint16(COMPRESSION_ATTRIBUTES_OFFSET, LITTLE_ENDIAN);
const elfSize = headerDataView.getUint32(ELF_SIZE_OFFSET, LITTLE_ENDIAN);
const pspSize = headerDataView.getUint32(PSP_SIZE_OFFSET, LITTLE_ENDIAN);
const isGzipped = ((compressionAttributes & COMPRESSION_ATTRIBUTES_IS_GZIP) !== 0);
const maxElfSize = Math.max(elfSize, pspSize);
// Call our function to decrypt the data
let unencryptedExecutableArrayBuffer = Util.getFilledArrayBuffer(maxElfSize, 0x00);
const unencryptedExecutablePtr = PspEncryptionUtil.bufferToPtr(unencryptedExecutableArrayBuffer);
const encryptedExecutablePtr = PspEncryptionUtil.bufferToPtr(encryptedExecutableArrayBuffer);
let executableSize = PspEncryptionUtil.decryptExecutable(encryptedExecutablePtr, unencryptedExecutablePtr, pspSize);
// Apparently there can be an ELF hiding inside
// Taken from https://github.com/hrydgard/ppsspp/blob/2a372caef9acbc7ff20bcca3c25b2ab92294f283/Core/HLE/sceKernelModule.cpp#L1320
if (executableSize <= 0) {
Util.checkMagic(encryptedExecutableArrayBuffer, ELF_MAGIC_OFFSET, ELF_MAGIC, MAGIC_ENCODING);
executableSize = pspSize - ELF_MAGIC_OFFSET;
unencryptedExecutableArrayBuffer = encryptedExecutableArrayBuffer.slice(ELF_MAGIC_OFFSET, ELF_MAGIC_OFFSET + executableSize);
} else {
unencryptedExecutableArrayBuffer = PspEncryptionUtil.ptrToArrayBuffer(unencryptedExecutablePtr, pspSize);
}
PspEncryptionUtil.free(encryptedExecutablePtr);
PspEncryptionUtil.free(unencryptedExecutablePtr);
// See if we need to decompress our unencrypted data
if (isGzipped) {
unencryptedExecutableArrayBuffer = CompressionGzip.decompress(unencryptedExecutableArrayBuffer);
}
// Final check that we've got an ELF file
Util.checkMagic(unencryptedExecutableArrayBuffer, UNENCRYPTED_MAGIC_OFFSET, ELF_MAGIC, MAGIC_ENCODING);
const executableInfo = {
compressionAttributes,
elfSize,
pspSize,
};
return new PspExecutable(encryptedExecutableArrayBuffer, unencryptedExecutableArrayBuffer, executableInfo);
}
// This constructor creates a new object from the encrypted and unencrypted binary representations of a PSP executable
constructor(encryptedArrayBuffer, unencryptedArrayBuffer, executableInfo) {
this.encryptedArrayBuffer = encryptedArrayBuffer;
this.unencryptedArrayBuffer = unencryptedArrayBuffer;
this.executableInfo = executableInfo;
}
getUnencryptedArrayBuffer() {
return this.unencryptedArrayBuffer;
}
getEncryptedArrayBuffer() {
return this.encryptedArrayBuffer;
}
getExecutableInfo() {
return this.executableInfo;
}
}
================================================
FILE: frontend/src/save-formats/PSP/ParamSfo.js
================================================
// Based on https://github.com/hrydgard/ppsspp/blob/master/Core/ELF/ParamSFO.cpp
// The overall format is:
// - Header
// - N index table entries
// - Key table (all the keys, one after another)
// - Value table (all the values, one after another)
// Some interesting info regarding the 'REGION' key/value pair, which always seems to be set to 32768: https://github.com/hrydgard/ppsspp/issues/12639
import Util from '../../util/util';
const LITTLE_ENDIAN = true;
const MAGIC = '\x00PSF';
const MAGIC_ENCODING = 'US-ASCII';
const EXPECTED_VERSION = 0x00000101; // v1.1
// Header information -- all offsets from the start of the file
const MAGIC_OFFSET = 0;
const VERSION_OFFSET = 4;
const KEY_TABLE_START_OFFSET = 8;
const VALUE_TABLE_START_OFFSET = 12;
const NUM_INDEX_TABLE_ENTRIES_OFFSET = 16;
const FIRST_INDEX_TABLE_ENTRY_OFFSET = 20;
// Index table entry -- all offsets from the start of that entry
const INDEX_TABLE_KEY_OFFSET = 0;
const INDEX_TABLE_VALUE_FORMAT_OFFSET = 2;
const INDEX_TABLE_VALUE_LENGTH_OFFSET = 4;
const INDEX_TABLE_VALUE_MAX_LENGTH_OFFSET = 8;
const INDEX_TABLE_VALUE_OFFSET = 12;
const INDEX_TABLE_ENTRY_SIZE = 16;
const VALUE_FORMAT_UINT32 = 0x0404;
const VALUE_FORMAT_UTF8_STRING = 0x0204;
const VALUE_FORMAT_SPECIAL_UTF8_STRING = 0x0004;
const KEY_ENCODING = 'US-ASCII';
const VALUE_ENCODING_UTF8 = 'UTF-8';
function getErrorMessage(fileVersion) {
if (fileVersion === EXPECTED_VERSION) {
return 'Encountered error parsing PARAM.SFO. File was expected version.';
}
return `Encountered error parsing PARAM.SFO. Found file version 0x${fileVersion.toString(16)}, but expected file version 0x${EXPECTED_VERSION.toString(16)}`;
}
export default class PspParamSfo {
constructor(arrayBuffer) {
const fileSize = arrayBuffer.byteLength;
Util.checkMagic(arrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const fileDataView = new DataView(arrayBuffer);
const fileUint8Array = new Uint8Array(arrayBuffer);
const fileVersion = fileDataView.getUint32(VERSION_OFFSET, LITTLE_ENDIAN);
const keyTableStart = fileDataView.getUint32(KEY_TABLE_START_OFFSET, LITTLE_ENDIAN);
const valueTableStart = fileDataView.getUint32(VALUE_TABLE_START_OFFSET, LITTLE_ENDIAN);
const numIndexTableEntries = fileDataView.getUint32(NUM_INDEX_TABLE_ENTRIES_OFFSET, LITTLE_ENDIAN);
if ((keyTableStart > fileSize) || (valueTableStart > fileSize)) {
throw new Error(getErrorMessage(fileVersion));
}
this.keyValuePairs = {};
for (let i = 0; i < numIndexTableEntries; i += 1) {
// We're iterating through an array of these index table entries in memory, so make
// a slice of our original arraybuffer to make everything a little easier to read
const indexTableEntryOffset = FIRST_INDEX_TABLE_ENTRY_OFFSET + (i * INDEX_TABLE_ENTRY_SIZE);
const indexTableEntryArrayBuffer = arrayBuffer.slice(indexTableEntryOffset, indexTableEntryOffset + INDEX_TABLE_ENTRY_SIZE);
const indexTableEntryDataView = new DataView(indexTableEntryArrayBuffer);
const keyOffset = keyTableStart + indexTableEntryDataView.getUint16(INDEX_TABLE_KEY_OFFSET, LITTLE_ENDIAN);
const valueOffset = valueTableStart + indexTableEntryDataView.getUint32(INDEX_TABLE_VALUE_OFFSET, LITTLE_ENDIAN);
if ((keyOffset > fileSize) || (valueOffset > fileSize)) {
throw new Error(getErrorMessage(fileVersion));
}
const key = Util.readNullTerminatedString(fileUint8Array, keyOffset, KEY_ENCODING);
let value = null;
const valueFormat = indexTableEntryDataView.getUint16(INDEX_TABLE_VALUE_FORMAT_OFFSET, LITTLE_ENDIAN);
const valueLength = indexTableEntryDataView.getUint32(INDEX_TABLE_VALUE_LENGTH_OFFSET, LITTLE_ENDIAN);
const valueMaxLength = indexTableEntryDataView.getUint32(INDEX_TABLE_VALUE_MAX_LENGTH_OFFSET, LITTLE_ENDIAN);
switch (valueFormat) {
case VALUE_FORMAT_UINT32: {
value = fileDataView.getUint32(valueOffset, LITTLE_ENDIAN);
break;
}
case VALUE_FORMAT_UTF8_STRING: {
value = Util.readNullTerminatedString(fileUint8Array, valueOffset, VALUE_ENCODING_UTF8, valueMaxLength);
break;
}
case VALUE_FORMAT_SPECIAL_UTF8_STRING: {
value = fileUint8Array.slice(valueOffset, valueOffset + valueLength); // I'm not sure what this is for, but PPSSPP stores it as a copy of the raw data: https://github.com/hrydgard/ppsspp/blob/309dcb295268af657ee186dc57d825512ad8091c/Core/ELF/ParamSFO.cpp#L260
break;
}
default: {
break;
}
}
if (value !== null) {
this.keyValuePairs[key] = value;
}
}
}
getValue(key) {
return this.keyValuePairs[key];
}
}
================================================
FILE: frontend/src/save-formats/PSP/PspEncryptionUtil.js
================================================
/* eslint-disable no-underscore-dangle */
import createModule from '@/save-formats/PSP/psp-encryption/psp-encryption';
import pspEncryptionWasm from './psp-encryption/psp-encryption.wasm';
async function getModuleInstance() {
// We want to use this formulation when in tests to get the file from our local machine in the src/ dir
// Our tests run within jsdom, which mimics a browser environment
// But the wasm stuff expects node
const isTest = typeof navigator === 'object' && (navigator.userAgent.includes('Node.js') || navigator.userAgent.includes('jsdom')); // https://github.com/jsdom/jsdom/issues/1537
let moduleOverrides = {};
if (isTest) {
// Hacks to bypass checks after ENVIRONMENT_IS_NODE in psp-encryption.js
process.release = {
name: 'node',
};
process.versions = {
node: 'v22.13.1',
};
moduleOverrides = {
locateFile: (s) => `src/save-formats/PSP/psp-encryption/${s}`,
};
} else {
// WASM integration with webpack 5 based on: https://gist.github.com/surma/b2705b6cca29357ebea1c9e6e15684cc
moduleOverrides = {
locateFile: (s) => {
if (s.endsWith('.wasm')) {
return pspEncryptionWasm;
}
return s;
},
};
}
return createModule(moduleOverrides);
}
export default class PspEncryptionUtil {
static async init(deterministicSeed = null) {
PspEncryptionUtil.moduleInstance = await getModuleInstance();
if (deterministicSeed === null) {
PspEncryptionUtil.moduleInstance._kirk_init();
} else {
PspEncryptionUtil.moduleInstance._kirk_init_deterministic(deterministicSeed);
}
PspEncryptionUtil.decryptSaveData = PspEncryptionUtil.moduleInstance.cwrap('decrypt_save_buffer', 'number', ['number', 'number', 'number']);
PspEncryptionUtil.encryptSaveData = PspEncryptionUtil.moduleInstance.cwrap('encrypt_save_buffer', 'number', ['number', 'number', 'number', 'number', 'number', 'string', 'number']);
PspEncryptionUtil.decryptExecutable = PspEncryptionUtil.moduleInstance.cwrap('decrypt_executable', 'number', ['number', 'number', 'number']);
}
static bufferToPtr(buffer) {
const array = new Uint8Array(buffer);
const ptr = PspEncryptionUtil.moduleInstance._malloc(array.length);
for (let i = 0; i < array.length; i += 1) {
PspEncryptionUtil.moduleInstance.setValue(ptr + i, array[i], 'i8');
}
return ptr;
}
static ptrToArrayBuffer(ptr, length) {
const arrayBuffer = new ArrayBuffer(length);
const array = new Uint8Array(arrayBuffer);
for (let i = 0; i < length; i += 1) {
array[i] = PspEncryptionUtil.moduleInstance.getValue(ptr + i, 'i8');
}
return arrayBuffer;
}
static intToPtr(n) {
const ptr = PspEncryptionUtil.moduleInstance._malloc(4);
PspEncryptionUtil.moduleInstance.setValue(ptr, n, 'i32');
return ptr;
}
static ptrToInt(ptr) {
return PspEncryptionUtil.moduleInstance.getValue(ptr, 'i32');
}
static free(ptr) {
PspEncryptionUtil.moduleInstance._free(ptr);
}
}
================================================
FILE: frontend/src/save-formats/PSP/Savefile.js
================================================
import PspEncryptionUtil from './PspEncryptionUtil';
// const INCORRECT_FORMAT_ERROR_MESSAGE = 'This does not appear to be a PSP save file';
export default class PspSaveData {
static createFromEncryptedData(encryptedArrayBuffer, gameKey) {
let dataLength = encryptedArrayBuffer.byteLength;
const encryptedArrayPtr = PspEncryptionUtil.bufferToPtr(encryptedArrayBuffer);
const gameKeyPtr = PspEncryptionUtil.bufferToPtr(gameKey);
const dataLengthPtr = PspEncryptionUtil.intToPtr(dataLength);
const result = PspEncryptionUtil.decryptSaveData(encryptedArrayPtr, dataLengthPtr, gameKeyPtr);
if (result !== 0) {
throw new Error(`Encountered error ${result} trying to decrypt save data`);
}
dataLength = PspEncryptionUtil.ptrToInt(dataLengthPtr);
const unencryptedArrayBuffer = PspEncryptionUtil.ptrToArrayBuffer(encryptedArrayPtr, dataLength);
PspEncryptionUtil.free(dataLengthPtr);
PspEncryptionUtil.free(gameKeyPtr);
PspEncryptionUtil.free(encryptedArrayPtr);
return new PspSaveData(encryptedArrayBuffer, unencryptedArrayBuffer, null);
}
static createFromUnencryptedData(unencryptedArrayBuffer, encryptedFilename, paramSfoArrayBuffer, gameKey) {
let dataLength = unencryptedArrayBuffer.byteLength;
const unencryptedArrayPtr = PspEncryptionUtil.bufferToPtr(unencryptedArrayBuffer);
const encryptedArrayPtrPtr = PspEncryptionUtil.intToPtr(0);
const paramSfoArrayPtr = PspEncryptionUtil.bufferToPtr(paramSfoArrayBuffer);
const gameKeyPtr = PspEncryptionUtil.bufferToPtr(gameKey);
const dataLengthPtr = PspEncryptionUtil.intToPtr(dataLength);
const paramSfoLength = paramSfoArrayBuffer.byteLength;
const result = PspEncryptionUtil.encryptSaveData(unencryptedArrayPtr, encryptedArrayPtrPtr, dataLengthPtr, paramSfoArrayPtr, paramSfoLength, encryptedFilename, gameKeyPtr);
if (result !== 0) {
throw new Error(`Encountered error ${result} trying to decrypt save data`);
}
const encryptedArrayPtr = PspEncryptionUtil.ptrToInt(encryptedArrayPtrPtr);
dataLength = PspEncryptionUtil.ptrToInt(dataLengthPtr);
const encryptedArrayBuffer = PspEncryptionUtil.ptrToArrayBuffer(encryptedArrayPtr, dataLength);
const newParamSfoArrayBuffer = PspEncryptionUtil.ptrToArrayBuffer(paramSfoArrayPtr, paramSfoLength);
PspEncryptionUtil.free(dataLengthPtr);
PspEncryptionUtil.free(encryptedArrayPtrPtr);
PspEncryptionUtil.free(encryptedArrayPtr); // This was allocated in C++ but must be free'd here
PspEncryptionUtil.free(gameKeyPtr);
PspEncryptionUtil.free(paramSfoArrayPtr);
PspEncryptionUtil.free(unencryptedArrayPtr);
return new PspSaveData(encryptedArrayBuffer, unencryptedArrayBuffer, newParamSfoArrayBuffer);
}
// This constructor creates a new object from the encrypted and unencrypted binary representations of a PSP save data file
constructor(encryptedArrayBuffer, unencryptedArrayBuffer, paramSfoArrayBuffer) {
this.encryptedArrayBuffer = encryptedArrayBuffer;
this.unencryptedArrayBuffer = unencryptedArrayBuffer;
this.paramSfoArrayBuffer = paramSfoArrayBuffer;
}
getUnencryptedArrayBuffer() {
return this.unencryptedArrayBuffer;
}
getEncryptedArrayBuffer() {
return this.encryptedArrayBuffer;
}
getParamSfoArrayBuffer() {
return this.paramSfoArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/PSP/psp-encryption/psp-encryption.js
================================================
var createModule = (() => {
var _scriptName = import.meta.url;
return (
async function(moduleArg = {}) {
var moduleRtn;
var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof WorkerGlobalScope!="undefined";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");var require=createRequire("/")}var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("fs");var nodePath=require("path");if(!import.meta.url.startsWith("data:")){scriptDirectory=nodePath.dirname(require("url").fileURLToPath(import.meta.url))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{readAsync=async url=>{if(isFileURI(url)){return new Promise((resolve,reject)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){resolve(xhr.response);return}reject(xhr.status)};xhr.onerror=reject;xhr.send(null)})}var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAP64,HEAPU64,HEAPF64;var runtimeInitialized=false;var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);var isFileURI=filename=>filename.startsWith("file://");function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);Module["HEAP64"]=HEAP64=new BigInt64Array(b);Module["HEAPU64"]=HEAPU64=new BigUint64Array(b)}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){var f="psp-encryption.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("psp-encryption.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&!isFileURI(binaryFile)&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){return{a:wasmImports}}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["c"];updateMemoryViews();addOnInit(wasmExports["d"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();try{var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}catch(e){readyPromiseReject(e);return Promise.reject(e)}}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr];case"i8":return HEAP8[ptr];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}var noExitRuntime=Module["noExitRuntime"]||true;function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr]=value;break;case"i8":HEAP8[ptr]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var _emscripten_date_now=()=>Date.now();var abortOnCannotGrowMemory=requestedSize=>{abort("OOM")};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;abortOnCannotGrowMemory(requestedSize)};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};var wasmImports={b:_emscripten_date_now,a:_emscripten_resize_heap};var wasmExports=await createWasm();var ___wasm_call_ctors=wasmExports["d"];var _malloc=Module["_malloc"]=wasmExports["e"];var _free=Module["_free"]=wasmExports["f"];var _kirk_init_deterministic=Module["_kirk_init_deterministic"]=wasmExports["g"];var _kirk_init=Module["_kirk_init"]=wasmExports["h"];var _decrypt_executable=Module["_decrypt_executable"]=wasmExports["j"];var _decrypt_save_buffer=Module["_decrypt_save_buffer"]=wasmExports["k"];var _encrypt_save_buffer=Module["_encrypt_save_buffer"]=wasmExports["l"];var __emscripten_stack_restore=wasmExports["m"];var __emscripten_stack_alloc=wasmExports["n"];var _emscripten_stack_get_current=wasmExports["o"];Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["setValue"]=setValue;Module["getValue"]=getValue;function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise;
return moduleRtn;
}
);
})();
export default createModule;
================================================
FILE: frontend/src/save-formats/PlatformSaveSizes.js
================================================
// All values are in bytes
// Not guaranteed to be correct: may have missing/estra values! Most of this is guesses
const PLATFORM_SAVE_SIZES = {
nes: [
512,
1024,
2048,
4096,
8192,
16384,
32768, // The usual max size of files that the MiSTer NES core will generate
65536,
131072, // The MiSTer NES core and N64 Everdrive NES core will sometimes generate files this big, so maybe some games require them?
],
snes: [
512,
1024,
2048,
4096,
8192,
16384,
32768,
65536,
131072,
],
n64: [
// From http://micro-64.com/database/gamesave.shtml
512,
2048,
32768,
131072,
786432, // Dezaemon 3D
],
gb: [
512,
1024,
2048,
4096,
8192,
16384,
32768,
65536,
],
gba: [
512,
8192,
32768,
65536,
131072,
],
gamegear: [
512,
1024,
2048,
4096,
8192,
16384,
32768,
65536,
],
sms: [
512,
1024,
2048,
4096,
8192,
16384,
32768,
65536,
],
genesis: [
64, // On the Mega SD, Wonder Boy in Monster World only uses this much data (although it's padded out to be much larger)
128, // Wonder Boy in Monster World (uses EEPROM to save). Files created by the GenesisPlus emulator and Mega Everdrive Pro are this big
256,
512,
1024,
2048,
4096,
8192,
16384,
32768,
65536, // Genesis files on the MiSTer are all padded out to be 64k, so maybe that's the max size?
131072, // But if we byte-expand that largest file inappropriately we end up with sometyhing this big, and we shouldn't show blank in the output size dropdown
],
segacd: [
// From https://segaretro.org/CD_BackUp_RAM_Cart
// The internal backup RAM is fixed at 8kB, the external (cart) backup RAM can be any size
8192,
16384,
32768,
65536,
131072,
262144,
524288,
],
gamecube: [
// From https://github.com/dolphin-emu/dolphin/blob/53b54406bd546b507822a3bd30311aa0cd96ee71/Source/Core/Core/HW/GCMemcard/GCMemcard.cpp#L95
// Offical sizes are listed here: https://en.wikipedia.org/wiki/GameCube#Hardware
524288, // 4 megabits (59 blocks)
1048576, // 8 megabits (123 blocks)
2097152, // 16 megabits (251 blocks)
4194304, // 32 megabits (507 blocks)
8388608, // 64 megabits (1019 blocks)
16777216, // 128 megabits (2043 blocks)
],
};
// These are for memory card images and not cartridges
const OMIT_FROM_ALL_SIZES = [
'segacd',
'gamecube',
];
const ALL_SIZES_KEYS = Object.keys(PLATFORM_SAVE_SIZES).filter((key) => !OMIT_FROM_ALL_SIZES.includes(key));
const ALL_SIZES = ALL_SIZES_KEYS.reduce((accumulator, currentPlatform) => { accumulator.push(...PLATFORM_SAVE_SIZES[currentPlatform]); return accumulator; }, []);
const ALL_SIZES_NO_DUPLICATES_SORTED = [...new Set(ALL_SIZES)].sort((a, b) => a - b);
PLATFORM_SAVE_SIZES.all = ALL_SIZES_NO_DUPLICATES_SORTED;
export default PLATFORM_SAVE_SIZES;
================================================
FILE: frontend/src/save-formats/Retron5/Retron5.js
================================================
/* eslint no-bitwise: ["error", { "allow": ["&", ">>>"] }] */
/*
The Retron5 data format is:
typedef struct
{
uint32_t magic;
uint16_t fmtVer;
uint16_t flags;
uint32_t origSize;
uint32_t packed_size;
uint32_t data_offset;
uint32_t crc32;
uint8_t data[0];
} t_retronDataHdr;
*/
import crc32 from 'crc-32';
import Util from '../../util/util';
import MathUtil from '../../util/Math';
import PaddingUtil from '../../util/Padding';
import CompressionZlibUtil from '../../util/CompressionZlib';
const LITTLE_ENDIAN = true;
const MAGIC = 'RTN5';
const MAGIC_ENCODING = 'US-ASCII';
const FORMAT_VERSION = 1;
const FLAG_ZLIB_PACKED = 0x01;
const MAGIC_OFFSET = 0;
const FORMAT_VERSION_OFFSET = 4;
const FLAGS_OFFSET = 6;
const ORIGINAL_SIZE_OFFSET = 8;
const PACKED_SIZE_OFFSET = 12;
const DATA_OFFSET_OFFSET = 16;
const CRC32_OFFSET = 20;
const HEADER_SIZE = 24;
export default class Retron5SaveData {
static createFromRetron5Data(retron5ArrayBuffer) {
const dataView = new DataView(retron5ArrayBuffer);
// First make sure that the stuff in the header all makes sense
Util.checkMagic(retron5ArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const formatVersion = dataView.getUint16(FORMAT_VERSION_OFFSET, LITTLE_ENDIAN);
const flags = dataView.getUint16(FLAGS_OFFSET, LITTLE_ENDIAN);
const originalSize = dataView.getUint32(ORIGINAL_SIZE_OFFSET, LITTLE_ENDIAN);
const packedSize = dataView.getUint32(PACKED_SIZE_OFFSET, LITTLE_ENDIAN);
const dataOffset = dataView.getUint32(DATA_OFFSET_OFFSET, LITTLE_ENDIAN);
const checksumRead = dataView.getUint32(CRC32_OFFSET, LITTLE_ENDIAN);
if (formatVersion > FORMAT_VERSION) {
throw new Error('Sorry this tool does not support this format of Retron5 save files');
}
if (dataOffset !== HEADER_SIZE) {
throw new Error('Sorry this file appears to be corrupted');
}
const dataSize = retron5ArrayBuffer.byteLength - HEADER_SIZE;
if (packedSize !== dataSize) {
throw new Error('Sorry this file appears to be corrupted');
}
// Now decompress the saved data and check its size and CRC32
let rawArrayBuffer = new Uint8Array(retron5ArrayBuffer.slice(dataOffset));
if ((flags & FLAG_ZLIB_PACKED) !== 0) {
rawArrayBuffer = CompressionZlibUtil.decompress(rawArrayBuffer);
}
if (rawArrayBuffer.byteLength !== originalSize) {
throw new Error('Sorry this file appears to be corrupted');
}
const checksumCalculated = crc32.buf(rawArrayBuffer) >>> 0; // '>>> 0' interprets the result as an unsigned integer: https://stackoverflow.com/questions/1822350/what-is-the-javascript-operator-and-how-do-you-use-it
if (checksumCalculated !== checksumRead) {
throw new Error('Sorry this file appears to be corrupted');
}
// Lastly check for extra padding at the beginning
//
// So far one user has provided a file which decompressed to 0x22000 bytes and the first 0x20000 bytes were
// just all 0x00. Slicing out the final 0x2000 bytes resulted in a file that loads correctly in an emulator.
if (!MathUtil.isPowerOf2(rawArrayBuffer.byteLength)) {
const padding = PaddingUtil.getPadFromStartValueAndCount(rawArrayBuffer);
// Note that this will catch padding values of 0x00 or 0xFF, but we've only seen 1 file in the wild and it
// had a padding value of 0x00. Not sure if we should restrict this to only check for that.
if (padding.count > 0) {
rawArrayBuffer = PaddingUtil.removePaddingFromStart(rawArrayBuffer, padding.count);
}
}
// Everything looks good
return new Retron5SaveData(retron5ArrayBuffer, rawArrayBuffer);
}
static createFromEmulatorData(rawArrayBuffer) {
const originalChecksum = crc32.buf(new Uint8Array(rawArrayBuffer)) >>> 0; // '>>> 0' interprets the result as an unsigned integer: https://stackoverflow.com/questions/1822350/what-is-the-javascript-operator-and-how-do-you-use-it
const packedArrayBuffer = CompressionZlibUtil.compress(rawArrayBuffer);
let headerArrayBuffer = new ArrayBuffer(HEADER_SIZE); // Need to have an ArrayBuffer with a size as a multiple of 4 to have a Uint32View into it, so need to have a separate one for the header
headerArrayBuffer = Util.setMagic(headerArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const headerDataView = new DataView(headerArrayBuffer);
headerDataView.setUint16(FORMAT_VERSION_OFFSET, FORMAT_VERSION, LITTLE_ENDIAN);
headerDataView.setUint16(FLAGS_OFFSET, FLAG_ZLIB_PACKED, LITTLE_ENDIAN);
headerDataView.setUint32(ORIGINAL_SIZE_OFFSET, rawArrayBuffer.byteLength, LITTLE_ENDIAN);
headerDataView.setUint32(PACKED_SIZE_OFFSET, packedArrayBuffer.byteLength, LITTLE_ENDIAN);
headerDataView.setUint32(DATA_OFFSET_OFFSET, HEADER_SIZE, LITTLE_ENDIAN);
headerDataView.setUint32(CRC32_OFFSET, originalChecksum, LITTLE_ENDIAN);
const retron5ArrayBuffer = Util.concatArrayBuffers([headerArrayBuffer, packedArrayBuffer]);
return new Retron5SaveData(retron5ArrayBuffer, rawArrayBuffer);
}
constructor(retron5ArrayBuffer, rawArrayBuffer) {
this.retron5ArrayBuffer = retron5ArrayBuffer;
this.rawArrayBuffer = rawArrayBuffer;
}
getRawArrayBuffer() {
return this.rawArrayBuffer;
}
getRetron5ArrayBuffer() {
return this.retron5ArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/SegaCd/Crc16.js
================================================
/* eslint-disable no-bitwise */
// The Sega CD format uses a 16-bit CRC that I can't seem to find a JS implementation of.
// The reference code calls it "CCITT" but I tried a couple of repos that offer similarly-named
// CRC-16 algorithms and none of them matched. So it just seemed easier to copy the code from
// the reference implementation
const CRC_TABLE_SIZE = 256;
// https://github.com/superctr/buram/blob/master/buram.c#L183
const crcTable = new Array(CRC_TABLE_SIZE).fill(0);
for (let i = 0; i < CRC_TABLE_SIZE; i += 1) {
let d = i << 8;
for (let j = 0; j < 8; j += 1) {
d = (d << 1) ^ (((d & 0x8000) !== 0) ? 0x1021 : 0);
}
crcTable[i] = d;
}
// https://github.com/superctr/buram/blob/master/buram.c#L197
export default function (arrayBuffer) { // eslint-disable-line func-names
let out = 0;
const uint8Array = new Uint8Array(arrayBuffer);
const length = arrayBuffer.byteLength;
for (let i = 0; i < length; i += 1) {
out = ((out << 8) ^ crcTable[uint8Array[i] ^ (out >> 8)]) & 0xFFFF;
}
return out >>> 0; // Convert to unsigned
}
================================================
FILE: frontend/src/save-formats/SegaCd/ReedSolomon.js
================================================
/* eslint-disable no-bitwise */
// The Sega CD format uses a version of Reed Solomon that I can't seem to find a JS implementation of.
// All of the existing implementations seem to be based on the same original Java implementation,
// which was able to parse the data from a real Sega CD when told there was 1 parity byte but not 2.
// I gave up trying and went with the reference implementation from https://github.com/superctr/buram
// reedSolomonType can either be 6 or 8
import Util from '../../util/util';
const GENERATOR_POLYNOMIAL_REED_SOLOMON_8 = [87, 166, 113, 75, 198, 25, 167, 114, 76, 199, 26, 1]; // https://github.com/superctr/buram/blob/master/buram.c#L586
const GENERATOR_POLYNOMIAL_REED_SOLOMON_6 = [20, 58, 56, 18, 26, 6, 59, 57, 19, 27, 7, 1]; // https://github.com/superctr/buram/blob/master/buram.c#L587
const GALOIS_FIELD_TABLE_SIZE = 256;
const DATA_SIZE = 6;
const PARITY_OFFSETS = [DATA_SIZE, DATA_SIZE + 1];
const PARITY_SIZE = PARITY_OFFSETS.length;
const TOTAL_SIZE = DATA_SIZE + PARITY_SIZE;
// https://github.com/superctr/buram/blob/master/buram.c#L85
function initReedSolomon(poly, bitsPerSymbol, generatorPolynomial) {
const symbolsPerBlock = (1 << bitsPerSymbol) - 1;
const galoisFieldIndexOf = new Array(GALOIS_FIELD_TABLE_SIZE).fill(0);
const galoisFieldAlphaTo = new Array(GALOIS_FIELD_TABLE_SIZE).fill(0);
let sr = 1;
for (let i = 1; i <= symbolsPerBlock; i += 1) {
galoisFieldIndexOf[sr] = (i & 0xFF);
galoisFieldAlphaTo[i] = (sr & 0xFF);
sr = (sr << 1) & 0xFFFFFFFF;
if ((sr & (1 << bitsPerSymbol)) !== 0) {
sr ^= poly;
}
sr &= symbolsPerBlock;
}
return {
bitsPerSymbol,
symbolsPerBlock,
generatorPolynomial,
galoisFieldIndexOf,
galoisFieldAlphaTo,
};
}
// https://github.com/superctr/buram/blob/master/buram.c#L584
const REED_SOLOMON = {
8: initReedSolomon(0x1D, 8, GENERATOR_POLYNOMIAL_REED_SOLOMON_8),
6: initReedSolomon(3, 6, GENERATOR_POLYNOMIAL_REED_SOLOMON_6),
};
function checkInputDataSize(inputArrayBuffer) {
if (inputArrayBuffer.byteLength !== TOTAL_SIZE) {
throw new Error(`This Reed-Solomon implementation can only support an input size of ${TOTAL_SIZE} but was given a size of ${inputArrayBuffer.byteLength}`);
}
}
// Add with modulo
// Based on https://github.com/superctr/buram/blob/master/buram.c#L111
function addMod(reedSolomon, i, d) {
const newD = (d + reedSolomon.generatorPolynomial[i]) % reedSolomon.symbolsPerBlock;
return reedSolomon.galoisFieldAlphaTo[newD + 1];
}
export default class ReedSolomon {
// Based on https://github.com/superctr/buram/blob/master/buram.c#L122
static encode(inputArrayBuffer, reedSolomonType) {
checkInputDataSize(inputArrayBuffer);
const reedSolomon = REED_SOLOMON[reedSolomonType];
const outputArrayBuffer = Util.copyArrayBuffer(inputArrayBuffer);
const outputUint8Array = new Uint8Array(outputArrayBuffer);
outputUint8Array[PARITY_OFFSETS[0]] = 0;
outputUint8Array[PARITY_OFFSETS[1]] = 0;
for (let i = 0; i < DATA_SIZE; i += 1) {
let d = outputUint8Array[i];
if (d !== 0) {
d = reedSolomon.galoisFieldIndexOf[d] - 1;
outputUint8Array[PARITY_OFFSETS[0]] ^= addMod(reedSolomon, i, d);
outputUint8Array[PARITY_OFFSETS[1]] ^= addMod(reedSolomon, i + DATA_SIZE, d);
}
}
return outputArrayBuffer;
}
// Based on https://github.com/superctr/buram/blob/master/buram.c#L144
static decode(inputArrayBuffer, reedSolomonType) {
checkInputDataSize(inputArrayBuffer);
const reedSolomon = REED_SOLOMON[reedSolomonType];
const outputArrayBuffer = Util.copyArrayBuffer(inputArrayBuffer);
const outputUint8Array = new Uint8Array(outputArrayBuffer);
let errorMask = 0;
let errorLocation = 0;
// Calculate error location (syndrome)
for (let i = 0; i < TOTAL_SIZE; i += 1) {
let d = outputUint8Array[i];
errorMask = (errorMask ^ d) & 0xFF;
if (d !== 0) {
d = (reedSolomon.galoisFieldIndexOf[d] + DATA_SIZE - i) % reedSolomon.symbolsPerBlock;
errorLocation = (errorLocation ^ reedSolomon.galoisFieldAlphaTo[d + 1]) & 0xFF;
}
}
// Correct a single error
if (errorMask !== 0) {
const d = (reedSolomon.symbolsPerBlock + reedSolomon.galoisFieldIndexOf[errorLocation] - reedSolomon.galoisFieldIndexOf[errorMask]) % reedSolomon.symbolsPerBlock;
if (d < TOTAL_SIZE) {
outputUint8Array[TOTAL_SIZE - 1 - d] ^= errorMask;
} else {
throw new Error(`Unable to correct error found in data. d: ${d}, errorLocation: ${errorLocation}, errorMask: 0x${errorMask.toString(16)}, symbolsPerBlock: ${reedSolomon.symbolsPerBlock}`);
}
}
return outputArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/SegaCd/SegaCd.js
================================================
/* eslint-disable no-bitwise */
/*
This is based on https://github.com/superctr/buram/
All manipulation of Sega CD saves is done via the BIOS, and emulators etc just make BIOS calls and don't manipulate the data directly.
It appears that the BIOS was reverse-engineered to create the above repo.
The file is broken up into blocks of 64 bytes each. There is considerable redundancy and error correction throughout the data.
The first block is reserved. The repo above doesn't alter any data in this block, but the BIOS does. I don't know what it contains.
The last block is reserved for directory information: volume name, number of free blocks, number of save files, etc. The latter 2 values are written 4 times each.
There is potentially an additional block reserved for a future directory entry (see below)
Blocks may have error correction encoding applied to them. If applied, it takes the 64 byte block and uses only the first 48 bytes.
Those 48 bytes actually contain 36 bytes of data, plus error-correction parity information. The 36 bytes of data are actually 32 bytes
of data plus 2 16-bit CRCs to verify the integrety of the data. Thus, when error correction is applied to a block, 64 bytes actually holds 32 bytes of data.
Each of the 2 CRCs is the bitwise-NOT of the other. The CRC algorithm appears to be custom: I couldn't find a match in the library I tried.
From the perspective of the parity information, each 64 byte block is regarded as made up of 8-byte subblocks. Each subblock contains 6 bytes of data
and 2 bytes of parity information. The parity bytes are calculated by what appears to be a custom implementation of Reed-Solomon encoding. I was unable
to make an off-the-shelf Reed-Solomon library properly process the data.
The parity bytes are interleaved (twice) with the actual data. Each set of parity bytes allows for 1 byte to be corrected, and so I think the object
here is to allow for a 1-bit error in any byte of data. I didn't understand why there is both 6-bit and 8-bit Reed-Solomon encoding applied to the data.
The data for the contents of the files themselves is written in blocks from the start of the file (starting from block 1). It's just a concatenation of all of
the data from all of the files. File data may be encoded for error-correction or it may not be.
The file metadata (directory entries) is stored in half-blocks starting at the second-last block of the file and moving upwards towards the start of the file.
Thus, the two sets of data will eventually meet in the middle of the file. Each directory entry is 16 bytes of data long, and directory entries are encoded for
error-correction. Thus, 2 directory entries can fit in each block, and the entire block is used whether there are 1 or 2 entires in it (because the
data is interleaved)
Directory entries contain the filename, starting block number, length, and whether error correction encoding is applied to the file data.
There is always a half-block available for the next directory entry. If the number of saves is odd, this data will be stored in the same block
as the last entry and no extra blocks are reserved. However if the number of saves is even then an additional block is reserved for that
future directory entry. This includes when the file is empty.
*/
import calcCrc16 from './Crc16'; // eslint-disable-line
import PlatformSaveSizes from '../PlatformSaveSizes';
import SegaCdUtil from '../../util/SegaCd';
import Util from '../../util/util';
import reedSolomon from './ReedSolomon';
const LITTLE_ENDIAN = false;
const DIRECTORY_SIZE = 0x40;
const DIRECTORY_FORMAT_OFFSET = 0x20;
const DIRECTORY_VOLUME_OFFSET = 0x00;
const DIRECTORY_ENTRY_SIZE_ENCODED = 0x20; // Half a block
const DIRECTORY_ENTRY_SIZE_PLAINTEXT = DIRECTORY_ENTRY_SIZE_ENCODED / 2; // Encoding doubles the size
const DIRECTORY_ENTRY_FILENAME_OFFSET = 0;
const DIRECTORY_ENTRY_FILENAME_LENGTH = 11;
const DIRECTORY_ENTRY_FILE_DATA_IS_ENCODED_OFFSET = 11;
const DIRECTORY_ENTRY_FILE_DATA_START_BLOCK_OFFSET = 12;
const DIRECTORY_ENTRY_FILE_SIZE_OFFSET = 14;
const BLOCK_SIZE = 0x40;
const SUB_BLOCK_SIZE = 0x08; // A block is made up of sub-blocks, which are interleaved and encoded with Reed-Solomon
const NUM_SUB_BLOCKS = BLOCK_SIZE / SUB_BLOCK_SIZE;
// When deinterleaved, a block becomes 36 bytes of data where the first 2 and last 2 bytes are CRC information
const BLOCK_DATA_BEGIN_OFFSET = 2;
const BLOCK_DATA_SIZE = 32;
const BLOCK_CRC_1_OFFSET = 0;
const BLOCK_CRC_2_OFFSET = BLOCK_DATA_BEGIN_OFFSET + BLOCK_DATA_SIZE;
const BLOCK_TOTAL_CRC_SIZE = 4; // 2 x 16 bit CRCs
const REED_SOLOMON_6_LAST_DATA_BYTE = [0x2d, 0x2e, 0x2f, 0x08, 0x11, 0x1a, 0x23, 0x2c];
const TEXT_ENCODING = 'US-ASCII';
const VOLUME_LENGTH = 11;
const FORMAT_LENGTH = 11;
const MEDIA_ID_LENGTH = 16;
function sanitizeText(rawText) {
return rawText.replace(/(?!([A-Z]|[0-9]|\*))./g, '_'); // Anything other than A-Z, 0-9, or * is replaced with a _
}
// Based on https://github.com/superctr/buram/blob/master/buram.c#L886
function decodeText(arrayBuffer, offset, length) {
const textDecoder = new TextDecoder(TEXT_ENCODING);
const rawText = textDecoder.decode(arrayBuffer.slice(offset, offset + length));
const text = sanitizeText(rawText);
return text.replace(/_*$/g, ''); // Remove trailing _'s. This will remove all trailing _'s, so that the string '_____' -> ''. The implementation linked above will turn it into '_' (leaving a single underscore), which I'm not actually sure is more correct
}
function encodeText(text, length) {
const textEncoder = new TextEncoder(TEXT_ENCODING);
const sanitizedText = sanitizeText(text.toUpperCase()).padEnd(length, '_');
return textEncoder.encode(sanitizedText).slice(0, length);
}
// Based on https://github.com/superctr/buram/blob/master/buram.c#L228
// Deinterleave 36 bytes of data from 48 bytes
function deinterleaveData(inputBlockArrayBuffer) {
const inputBlockUint8Array = new Uint8Array(inputBlockArrayBuffer);
let inputCurrentOffset = 0;
const outputArrayBuffer = new ArrayBuffer((BLOCK_SIZE / 2) + BLOCK_TOTAL_CRC_SIZE);
const outputUint8Array = new Uint8Array(outputArrayBuffer);
let outputCurrentOffset = 0;
for (let i = 0; i < 12; i += 1) {
let sr = inputBlockUint8Array[inputCurrentOffset];
inputCurrentOffset += 1;
sr <<= 6;
sr = (sr & 0xFF00) | inputBlockUint8Array[inputCurrentOffset];
inputCurrentOffset += 1;
outputUint8Array[outputCurrentOffset] = (sr >> 6);
outputCurrentOffset += 1;
sr <<= 6;
sr = (sr & 0xFF00) | inputBlockUint8Array[inputCurrentOffset];
inputCurrentOffset += 1;
outputUint8Array[outputCurrentOffset] = (sr >> 4);
outputCurrentOffset += 1;
sr <<= 6;
sr = (sr & 0xFF00) | inputBlockUint8Array[inputCurrentOffset];
inputCurrentOffset += 1;
outputUint8Array[outputCurrentOffset] = (sr >> 2);
outputCurrentOffset += 1;
}
return outputArrayBuffer;
}
// Based on https://github.com/superctr/buram/blob/master/buram.c#L208
// Interleave 36 bytes of data into 48 bytes
function interleaveData(inputBlockArrayBuffer) {
const inputBlockUint8Array = new Uint8Array(inputBlockArrayBuffer);
let inputCurrentOffset = 0;
const outputArrayBuffer = new ArrayBuffer(BLOCK_SIZE);
const outputUint8Array = new Uint8Array(outputArrayBuffer);
let outputCurrentOffset = 0;
for (let i = 0; i < 12; i += 1) {
let sr = inputBlockUint8Array[inputCurrentOffset];
inputCurrentOffset += 1;
outputUint8Array[outputCurrentOffset] = sr;
outputCurrentOffset += 1;
sr <<= 8;
sr |= inputBlockUint8Array[inputCurrentOffset];
inputCurrentOffset += 1;
outputUint8Array[outputCurrentOffset] = (sr >> 2);
outputCurrentOffset += 1;
sr <<= 8;
sr |= inputBlockUint8Array[inputCurrentOffset];
inputCurrentOffset += 1;
outputUint8Array[outputCurrentOffset] = (sr >> 4);
outputCurrentOffset += 1;
outputUint8Array[outputCurrentOffset] = (sr << 2);
outputCurrentOffset += 1;
}
return outputArrayBuffer;
}
// Interleave the 8-bit Reed-Solomon code
// Based on https://github.com/superctr/buram/blob/master/buram.c#L313
function interleaveReedSolomon8(subBlockIndex, deinterleavedSubBlockArrayBuffer, blockArrayBuffer) {
const outputBlock = Util.copyArrayBuffer(blockArrayBuffer);
const outputUint8Array = new Uint8Array(outputBlock);
const inputUint8Array = new Uint8Array(deinterleavedSubBlockArrayBuffer);
for (let i = 0; i < SUB_BLOCK_SIZE; i += 1) {
let temp = inputUint8Array[i];
for (let j = 0; j < SUB_BLOCK_SIZE; j += 1) {
const outputIndex = subBlockIndex + (j * SUB_BLOCK_SIZE);
outputUint8Array[outputIndex] = (((outputUint8Array[outputIndex] << 1) & 0xFF) | (temp >> 7));
temp = (temp << 1) & 0xFF;
}
}
return outputBlock;
}
// Deinterleave the 8-bit Reed-Solomon code so that parity bits are at the bottom
// Based on https://github.com/superctr/buram/blob/master/buram.c#L329
function deinterleaveReedSolomon8(subBlockIndex, blockArrayBuffer) {
const outputSubBlock = Util.getFilledArrayBuffer(SUB_BLOCK_SIZE, 0);
const outputUint8Array = new Uint8Array(outputSubBlock);
const blockUint8Array = new Uint8Array(blockArrayBuffer);
for (let i = 0; i < SUB_BLOCK_SIZE; i += 1) {
let temp = blockUint8Array[subBlockIndex + (i * SUB_BLOCK_SIZE)];
for (let j = 0; j < SUB_BLOCK_SIZE; j += 1) {
outputUint8Array[j] = (((outputUint8Array[j] << 1) & 0xFF) | (temp >> 7));
temp = (temp << 1) & 0xFF;
}
}
return outputSubBlock;
}
// Interleave the 6-bit Reed-Solomon code
// Based on https://github.com/superctr/buram/blob/master/buram.c#L267
function interleaveReedSolomon6(subBlockIndex, deinterleavedSubBlockArrayBuffer, blockArrayBuffer) {
const outputBlock = Util.copyArrayBuffer(blockArrayBuffer);
const outputUint8Array = new Uint8Array(outputBlock);
const inputUint8Array = new Uint8Array(deinterleavedSubBlockArrayBuffer);
for (let i = 0; i < 5; i += 1) {
outputUint8Array[subBlockIndex + (i * 9)] = inputUint8Array[i] << 2;
}
outputUint8Array[REED_SOLOMON_6_LAST_DATA_BYTE[subBlockIndex]] = inputUint8Array[5] << 2;
outputUint8Array[0x30 + subBlockIndex] = inputUint8Array[6] << 2;
outputUint8Array[0x38 + subBlockIndex] = inputUint8Array[7] << 2;
return outputBlock;
}
// Deinterleave the 6-bit Reed-Solomon code so that parity bits are at the bottom
// Based on https://github.com/superctr/buram/blob/master/buram.c#L280
function deinterleaveReedSolomon6(subBlockIndex, blockArrayBuffer) {
const outputSubBlock = Util.getFilledArrayBuffer(SUB_BLOCK_SIZE, 0);
const outputUint8Array = new Uint8Array(outputSubBlock);
const blockUint8Array = new Uint8Array(blockArrayBuffer);
for (let i = 0; i < 5; i += 1) {
outputUint8Array[i] = blockUint8Array[subBlockIndex + (i * 9)] >> 2;
}
outputUint8Array[5] = blockUint8Array[REED_SOLOMON_6_LAST_DATA_BYTE[subBlockIndex]] >> 2;
outputUint8Array[6] = blockUint8Array[0x30 + subBlockIndex] >> 2;
outputUint8Array[7] = blockUint8Array[0x38 + subBlockIndex] >> 2;
return outputSubBlock;
}
// Perform error correction on the block
// Based on https://github.com/superctr/buram/blob/master/buram.c#L377
function getErrorCorrectedData(blockArrayBuffer) {
let correctedBlockArrayBuffer = Util.copyArrayBuffer(blockArrayBuffer);
for (let subBlockIndex = 0; subBlockIndex < NUM_SUB_BLOCKS; subBlockIndex += 1) {
const deinterleavedSubBlock = deinterleaveReedSolomon8(subBlockIndex, correctedBlockArrayBuffer);
const errorCorrectedSubBlock = reedSolomon.decode(deinterleavedSubBlock, 8);
correctedBlockArrayBuffer = interleaveReedSolomon8(subBlockIndex, errorCorrectedSubBlock, correctedBlockArrayBuffer);
}
for (let subBlockIndex = 0; subBlockIndex < NUM_SUB_BLOCKS; subBlockIndex += 1) {
const deinterleavedSubBlock = deinterleaveReedSolomon6(subBlockIndex, correctedBlockArrayBuffer);
const errorCorrectedSubBlock = reedSolomon.decode(deinterleavedSubBlock, 6);
correctedBlockArrayBuffer = interleaveReedSolomon6(subBlockIndex, errorCorrectedSubBlock, correctedBlockArrayBuffer);
}
return correctedBlockArrayBuffer;
}
// Based on https://github.com/superctr/buram/blob/master/buram.c#L345
function addErrorCorrectionData(blockArrayBuffer) {
let correctedBlockArrayBuffer = Util.copyArrayBuffer(blockArrayBuffer);
for (let subBlockIndex = 0; subBlockIndex < NUM_SUB_BLOCKS; subBlockIndex += 1) {
const deinterleavedSubBlock = deinterleaveReedSolomon6(subBlockIndex, correctedBlockArrayBuffer);
const errorCorrectedSubBlock = reedSolomon.encode(deinterleavedSubBlock, 6);
correctedBlockArrayBuffer = interleaveReedSolomon6(subBlockIndex, errorCorrectedSubBlock, correctedBlockArrayBuffer);
}
for (let subBlockIndex = 0; subBlockIndex < NUM_SUB_BLOCKS; subBlockIndex += 1) {
const deinterleavedSubBlock = deinterleaveReedSolomon8(subBlockIndex, correctedBlockArrayBuffer);
const errorCorrectedSubBlock = reedSolomon.encode(deinterleavedSubBlock, 8);
correctedBlockArrayBuffer = interleaveReedSolomon8(subBlockIndex, errorCorrectedSubBlock, correctedBlockArrayBuffer);
}
return correctedBlockArrayBuffer;
}
// Check if the deinterleaved block is corrupted or not. The 2 check CRCs appear to be for redundancy
function checkCrc(deinterleavedBlockArrayBuffer) {
const dataView = new DataView(deinterleavedBlockArrayBuffer);
const checkCrc1 = dataView.getUint16(BLOCK_CRC_1_OFFSET, LITTLE_ENDIAN);
const checkCrc2 = 0xFFFF & (~(dataView.getUint16(BLOCK_CRC_2_OFFSET, LITTLE_ENDIAN)) >>> 0); // Convert to unsigned and cut off anything beyond the low 16 bits
const crc = calcCrc16(deinterleavedBlockArrayBuffer.slice(BLOCK_DATA_BEGIN_OFFSET, BLOCK_DATA_BEGIN_OFFSET + BLOCK_DATA_SIZE));
if ((crc !== checkCrc1) && (crc !== checkCrc2)) {
throw new Error(`Data appears to be corrupt: calculated CRC 0x${crc.toString(16)} rather than 0x${checkCrc1.toString(16)} or 0x${checkCrc2.toString(16)}`);
}
}
// This is based on https://github.com/superctr/buram/blob/master/buram.c#L433
// (but without the optimization of cacheing the last accessed buffer)
// and https://github.com/superctr/buram/blob/master/buram.c#L377
//
// Note that it works differently than the reference implemnentation by first checking the CRC
// and only if that fails then trying to use error-correction on the data.
//
// It was that way in the reference implementation because apparently that's how it works
// in the BIOS: https://github.com/superctr/buram/issues/1
function decodeBlock(arrayBuffer, offset) {
const alignedOffset = offset & -(BLOCK_SIZE);
const block = arrayBuffer.slice(alignedOffset, alignedOffset + BLOCK_SIZE);
let outputArrayBuffer = deinterleaveData(block);
try {
checkCrc(outputArrayBuffer);
} catch (e) {
const correctedBlock = getErrorCorrectedData(block);
outputArrayBuffer = deinterleaveData(correctedBlock);
checkCrc(outputArrayBuffer); // If this one doesn't work, then throw an error to our caller
}
return outputArrayBuffer.slice(BLOCK_DATA_BEGIN_OFFSET + ((offset ^ alignedOffset) >> 1), BLOCK_DATA_BEGIN_OFFSET + BLOCK_DATA_SIZE);
}
// Based on https://github.com/superctr/buram/blob/master/buram.c#L449 (again with no optimization
// of cacheing the last accessed block)
function encodeBlock(destinationArrayBuffer, sourceArrayBuffer, destinationOffset) {
if (sourceArrayBuffer.byteLength !== (BLOCK_SIZE / 2)) {
throw new Error(`Unable to encode block of length ${sourceArrayBuffer.byteLength} bytes, block must be ${BLOCK_SIZE / 2} bytes`);
}
// Calculate the CRC and make an ArrayBuffer with the data and CRCs in the correct place
const crc = calcCrc16(sourceArrayBuffer);
let writeArrayBuffer = new ArrayBuffer(sourceArrayBuffer.byteLength + BLOCK_TOTAL_CRC_SIZE);
const writeDataView = new DataView(writeArrayBuffer);
writeDataView.setUint16(BLOCK_CRC_1_OFFSET, crc, LITTLE_ENDIAN);
writeDataView.setUint16(BLOCK_CRC_2_OFFSET, ~crc >>> 0, LITTLE_ENDIAN);
writeArrayBuffer = Util.setArrayBufferPortion(writeArrayBuffer, sourceArrayBuffer, BLOCK_CRC_1_OFFSET + 2, 0, sourceArrayBuffer.byteLength);
// Now interleave the data and write it to the destination
const interleavedArrayBuffer = interleaveData(writeArrayBuffer);
const errorCorrectionDataArrayBuffer = addErrorCorrectionData(interleavedArrayBuffer);
return Util.setArrayBufferPortion(destinationArrayBuffer, errorCorrectionDataArrayBuffer, destinationOffset, 0, errorCorrectionDataArrayBuffer.byteLength);
}
// This is based on https://github.com/superctr/buram/blob/master/buram.c#L820
// which is called by https://github.com/superctr/buram/blob/master/buram.c#L1013
function readSaveFiles(arrayBuffer, numSaveFiles) {
const saveFiles = [];
const directoryOffset = arrayBuffer.byteLength - DIRECTORY_SIZE;
let currentOffsetInDirectory = directoryOffset - DIRECTORY_ENTRY_SIZE_ENCODED;
for (let i = 0; i < numSaveFiles; i += 1) {
const decodedBuffer = decodeBlock(arrayBuffer, currentOffsetInDirectory);
const decodedBufferDataView = new DataView(decodedBuffer);
const dataIsEncoded = (decodedBufferDataView.getUint8(DIRECTORY_ENTRY_FILE_DATA_IS_ENCODED_OFFSET) !== 0);
const startBlockNumber = decodedBufferDataView.getUint16(DIRECTORY_ENTRY_FILE_DATA_START_BLOCK_OFFSET, LITTLE_ENDIAN);
const fileSizeBlocks = decodedBufferDataView.getUint16(DIRECTORY_ENTRY_FILE_SIZE_OFFSET, LITTLE_ENDIAN);
let fileData = null;
const fileDataStartOffset = startBlockNumber * BLOCK_SIZE;
if (dataIsEncoded) {
const fileDataDecodedBlocks = [];
let currentOffsetInFile = fileDataStartOffset;
for (let blockNum = 0; blockNum < fileSizeBlocks; blockNum += 1) {
fileDataDecodedBlocks.push(decodeBlock(arrayBuffer, currentOffsetInFile));
currentOffsetInFile += BLOCK_SIZE;
}
fileData = Util.concatArrayBuffers(fileDataDecodedBlocks);
} else {
fileData = arrayBuffer.slice(fileDataStartOffset, fileDataStartOffset + (fileSizeBlocks * BLOCK_SIZE));
}
saveFiles.push({
filename: decodeText(decodedBuffer, DIRECTORY_ENTRY_FILENAME_OFFSET, DIRECTORY_ENTRY_FILENAME_LENGTH),
dataIsEncoded,
startBlockNumber,
fileSizeBlocks,
fileData,
});
currentOffsetInDirectory -= DIRECTORY_ENTRY_SIZE_ENCODED;
}
return saveFiles;
}
function getRequiredBlocks(saveFile) {
const fileSizeBytes = saveFile.fileData.byteLength;
const fileSizeRequiredBytes = saveFile.dataIsEncoded ? fileSizeBytes * 2 : fileSizeBytes; // When encoding the data we can only store 32 bytes of data in every 64 byte block
return Math.ceil(fileSizeRequiredBytes / BLOCK_SIZE);
}
function getEmptyDirectoryEntry() {
// Apparently the BIOS uses uninitialized memory when creating a new block, and the result is that
// in the topmost directory entry only, when there are an odd number of saves, our output
// will not match that of the BIOS. It does match that of our reference implementation, and
// the BIOS is able to read and write our files.
// https://github.com/superctr/buram/issues/1
return Util.getFilledArrayBuffer(DIRECTORY_ENTRY_SIZE_PLAINTEXT, 0);
}
function getDirectoryEntry(saveFile, startingBlock) {
const directoryEntry = new ArrayBuffer(DIRECTORY_ENTRY_SIZE_PLAINTEXT);
const directoryEntryDataView = new DataView(directoryEntry);
const directoryEntryUint8Array = new Uint8Array(directoryEntry);
const encodedFilename = encodeText(saveFile.filename, DIRECTORY_ENTRY_FILENAME_LENGTH);
const fileSizeRequiredBlocks = getRequiredBlocks(saveFile);
directoryEntryUint8Array.set(encodedFilename, DIRECTORY_ENTRY_FILENAME_OFFSET);
directoryEntryDataView.setUint8(DIRECTORY_ENTRY_FILE_DATA_IS_ENCODED_OFFSET, saveFile.dataIsEncoded ? 0xFF : 0x00); // https://github.com/superctr/buram/blob/master/buram.c#L1129
directoryEntryDataView.setUint16(DIRECTORY_ENTRY_FILE_DATA_START_BLOCK_OFFSET, startingBlock, LITTLE_ENDIAN);
directoryEntryDataView.setUint16(DIRECTORY_ENTRY_FILE_SIZE_OFFSET, fileSizeRequiredBlocks, LITTLE_ENDIAN);
return directoryEntry;
}
// Based on https://github.com/superctr/buram/blob/master/buram.c#L766
function writeSaveFile(saveFile, startingBlock, segaCdArrayBuffer) {
const fileSizeRequiredBlocks = getRequiredBlocks(saveFile);
if (saveFile.dataIsEncoded) {
const paddedInputArrayBuffer = Util.padArrayBuffer(saveFile.fileData, fileSizeRequiredBlocks * BLOCK_SIZE, 0x00);
let outputArrayBuffer = Util.copyArrayBuffer(segaCdArrayBuffer);
for (let i = 0; i < fileSizeRequiredBlocks; i += 1) {
const sourceBlockOffset = i * (BLOCK_SIZE / 2);
const destinationBlockOffset = (startingBlock + i) * BLOCK_SIZE;
outputArrayBuffer = encodeBlock(outputArrayBuffer, paddedInputArrayBuffer.slice(sourceBlockOffset, sourceBlockOffset + (BLOCK_SIZE / 2)), destinationBlockOffset);
}
return outputArrayBuffer;
}
return Util.setArrayBufferPortion(segaCdArrayBuffer, saveFile.fileData, startingBlock * BLOCK_SIZE, 0, saveFile.fileData.byteLength);
}
function getVolumeInfo(segaCdArrayBuffer) {
return {
numFreeBlocks: SegaCdUtil.getNumFreeBlocks(segaCdArrayBuffer),
format: decodeText(segaCdArrayBuffer, segaCdArrayBuffer.byteLength - DIRECTORY_SIZE + DIRECTORY_FORMAT_OFFSET, FORMAT_LENGTH),
volume: decodeText(segaCdArrayBuffer, segaCdArrayBuffer.byteLength - DIRECTORY_SIZE + DIRECTORY_VOLUME_OFFSET, VOLUME_LENGTH),
mediaId: decodeText(segaCdArrayBuffer, segaCdArrayBuffer.byteLength - MEDIA_ID_LENGTH, MEDIA_ID_LENGTH),
};
}
export default class SegaCdSaveData {
static createWithNewSize(segaCdSaveData, newSize) {
const newRawSaveData = SegaCdUtil.resize(segaCdSaveData.getArrayBuffer(), newSize);
return SegaCdSaveData.createFromSegaCdData(newRawSaveData);
}
static createFromSegaCdData(arrayBuffer) {
const segaCdArrayBuffer = SegaCdUtil.truncateToActualSize(arrayBuffer);
const numSaveFiles = SegaCdUtil.getNumFiles(segaCdArrayBuffer);
let saveFiles = readSaveFiles(segaCdArrayBuffer, numSaveFiles);
// Some emulators concat their internal memory save with their ram cart save.
// We may wish to build a better UI around this to make it clear where
// the saves are coming from, but for now as a first pass just parse both parts
// and concat the arrays of save files together so the user can see everything
//
// Unsure how common this is: found one save while looking that was in this format
if (segaCdArrayBuffer.byteLength < arrayBuffer.byteLength) {
const arrayBuffer2 = arrayBuffer.slice(segaCdArrayBuffer.byteLength);
if (SegaCdUtil.isCorrectlyFormatted(arrayBuffer2)) {
const segaCdArrayBuffer2 = SegaCdUtil.truncateToActualSize(arrayBuffer2);
const numSaveFiles2 = SegaCdUtil.getNumFiles(segaCdArrayBuffer2);
const saveFiles2 = readSaveFiles(segaCdArrayBuffer2, numSaveFiles2);
saveFiles = saveFiles.concat(saveFiles2);
// Make the size of the new save be the next-larger size of the biggest
// of the 2 saves we're combining. This should ensure that we're able to store all
// of the files in both parts. User will get a cryptic error message about file
// being in the wrong format in the unlikely event that they don't fit. But this
// will be a seldom-used feature and a fix will add even more complexity.
const firstSizeIndex = PlatformSaveSizes.segacd.indexOf(segaCdArrayBuffer.byteLength);
const secondSizeIndex = PlatformSaveSizes.segacd.indexOf(segaCdArrayBuffer2.byteLength);
const sizeIndexToUse = Math.min(Math.max(firstSizeIndex, secondSizeIndex) + 1, PlatformSaveSizes.segacd.length - 1);
return SegaCdSaveData.createFromSaveFiles(saveFiles, PlatformSaveSizes.segacd[sizeIndexToUse]);
}
}
return new SegaCdSaveData(segaCdArrayBuffer, saveFiles);
}
static createFromSaveFiles(saveFiles, size) {
// Setup and make sure we have enough space for the files
let segaCdArrayBuffer = SegaCdUtil.makeEmptySave(size);
const initialFreeBlocks = SegaCdUtil.getTotalAvailableBlocks(segaCdArrayBuffer); // If we call SegaCdUtil.getNumFreeBlocks() it will subtract one because of the extra block that's reserved for the first file's directory entry
const requiredBlocksForSaves = saveFiles.reduce((accumulatedBlocks, saveFile) => accumulatedBlocks + getRequiredBlocks(saveFile), 0);
const requiredBlocksForDirectoryEntries = Math.ceil(saveFiles.length / 2);
const requiredReservedBlocks = ((saveFiles.length % 2) === 0) ? 1 : 0; // We can store 2 directory entries in a block. We always need room for the next future directory entry. So, if there are an odd number of save files we can store the next one in our last block. But if there are an even number of save files we need to reserve the next block
const requiredBlocks = requiredBlocksForSaves + requiredBlocksForDirectoryEntries + requiredReservedBlocks;
if (requiredBlocks > initialFreeBlocks) {
throw new Error(`The specified save files require a total of ${requiredBlocks} blocks of free space, but Sega CD save data of ${size} bytes only has ${initialFreeBlocks} free blocks`);
}
// Write the files
let currentBlock = 1; // Block 0 is reserved
const saveFilesOutput = [];
const directoryEntries = [];
saveFiles.forEach((saveFile) => {
const fileSizeBlocks = getRequiredBlocks(saveFile);
directoryEntries.push(getDirectoryEntry(saveFile, currentBlock));
segaCdArrayBuffer = writeSaveFile(saveFile, currentBlock, segaCdArrayBuffer);
saveFilesOutput.push({
...saveFile,
startBlockNumber: currentBlock,
fileSizeBlocks,
});
currentBlock += fileSizeBlocks;
});
// Combine our directory entries into half-blocks that can be encoded as full blocks
if ((directoryEntries.length % 2) === 1) {
directoryEntries.push(getEmptyDirectoryEntry());
}
const combinedDirectoryEntries = [];
for (let i = 0; i < (directoryEntries.length / 2); i += 1) {
combinedDirectoryEntries.push(Util.concatArrayBuffers([directoryEntries[(i * 2) + 1], directoryEntries[i * 2]])); // Directory entires are written in reverse order: they're read from the bottom up
}
combinedDirectoryEntries.forEach((combinedDirectoryEntry, index) => {
const offset = segaCdArrayBuffer.byteLength - DIRECTORY_SIZE - (BLOCK_SIZE * (index + 1));
segaCdArrayBuffer = encodeBlock(segaCdArrayBuffer, combinedDirectoryEntry, offset);
});
// Finish up
const numFreeBlocks = initialFreeBlocks - requiredBlocks;
SegaCdUtil.setNumFreeBlocks(segaCdArrayBuffer, numFreeBlocks);
SegaCdUtil.setNumFiles(segaCdArrayBuffer, saveFiles.length);
return new SegaCdSaveData(segaCdArrayBuffer, saveFilesOutput);
}
// This constructor creates a new object from a binary representation of Sega CD save data
constructor(arrayBuffer, saveFiles) {
this.arrayBuffer = arrayBuffer;
this.saveFiles = saveFiles;
this.volumeInfo = getVolumeInfo(arrayBuffer);
}
getSaveFiles() {
return this.saveFiles;
}
getNumFreeBlocks() {
return this.volumeInfo.numFreeBlocks;
}
getFormat() {
return this.volumeInfo.format;
}
getVolume() {
return this.volumeInfo.volume;
}
getMediaId() {
return this.volumeInfo.mediaId;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/SegaError.js
================================================
export default class SegaError extends Error {
constructor(internalSaveError = null, ramCartError = null) {
super();
this.internalSaveError = internalSaveError;
this.ramCartError = ramCartError;
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/Emulators/Emulators.js
================================================
/*
There are various emulators for the Saturn, some of which use slightly different save file formats.
Since everywhere on the site we tell people that "emulator/raw" format is the lingua franca, we need to
be able to read/write any emulator format for the Saturn transparently
*/
import MednafenSegaSaturnSaveData from './mednafen';
import YabauseSegaSaturnSaveData from './yabause';
import YabaSanshiroSegaSaturnSaveData from './yabasanshiro';
const EMULATOR_CLASSES = [
MednafenSegaSaturnSaveData,
YabauseSegaSaturnSaveData,
YabaSanshiroSegaSaturnSaveData,
];
export default class EmulatorSegaSaturnSaveData {
static createWithNewSize(segaSaturnSaveData, newSize) {
for (let i = 0; i < EMULATOR_CLASSES.length; i += 1) {
try {
return EMULATOR_CLASSES[i].createWithNewSize(segaSaturnSaveData, newSize);
} catch (e) {
// Try the next one
}
}
throw new Error('This file does not appear to contain Sega Saturn emulator save data');
}
static createFromSegaSaturnData(arrayBuffer) {
for (let i = 0; i < EMULATOR_CLASSES.length; i += 1) {
try {
return EMULATOR_CLASSES[i].createFromSegaSaturnData(arrayBuffer);
} catch (e) {
// Try the next one
}
}
throw new Error('This file does not appear to contain Sega Saturn emulator save data');
}
static createFromSaveFiles(saveFiles, blockSize) {
for (let i = 0; i < EMULATOR_CLASSES.length; i += 1) {
try {
return EMULATOR_CLASSES[i].createFromSaveFiles(saveFiles, blockSize);
} catch (e) {
// Try the next one
}
}
throw new Error('This file does not appear to contain Sega Saturn emulator save data');
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/Emulators/mednafen.js
================================================
/*
The popular emulator mednafen reads/writes raw Saturn BIOS files, but compresses the cartridge saves using gzip
Note that it is able to load uncompressed cartridge saves as well: it will just compress them when you exit the emulator.
Also, our compressed files are very slightly different from the ones that it creates (different by 1 bit in my test case),
probably because of different compression settings. But the emulator is able to load them fine as well.
*/
import SegaSaturnSaveData from '../SegaSaturn';
import CompressionGzip from '../../../util/CompressionGzip';
export default class MednafenSegaSaturnSaveData {
static createWithNewSize(/* segaSaturnSaveData, newSize */) {
/*
const newRawSaveData = SegaSaturnUtil.resize(segaSaturnSaveData.getArrayBuffer(), newSize);
return SegaSaturnSaveData.createFromSegaSaturnData(newRawSaveData);
*/
}
static createFromSegaSaturnData(arrayBuffer) {
// Cartridge saves from mednafen are compressed using gzip, but internal saves are not
let uncompressedArrayBuffer = null;
try {
uncompressedArrayBuffer = CompressionGzip.decompress(arrayBuffer);
} catch (e) {
uncompressedArrayBuffer = arrayBuffer;
}
return SegaSaturnSaveData.createFromSegaSaturnData(uncompressedArrayBuffer);
}
static createFromSaveFiles(saveFiles, blockSize) {
const segaSaturnSaveData = SegaSaturnSaveData.createFromSaveFiles(saveFiles, blockSize);
if (blockSize === SegaSaturnSaveData.CARTRIDGE_BLOCK_SIZE) {
return new SegaSaturnSaveData(
CompressionGzip.compress(segaSaturnSaveData.getArrayBuffer()),
segaSaturnSaveData.getSaveFiles(),
segaSaturnSaveData.getVolumeInfo(),
);
}
return segaSaturnSaveData;
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/Emulators/yabasanshiro.js
================================================
/*
The emulator yaba sanshiro reads/writes raw Saturn BIOS files which are byte-expanded.
They're also a nonstandard length (0x800000 bytes, byte-expanded -- so 0x400000 regular) that's much longer than
regular BIOS files (0x8000 bytes) or regular backup cart files (0x80000 bytes)
It appears that at this time, this emulator doesn't emulate the backup cartridge for saves.
*/
import SegaSaturnSaveData from '../SegaSaturn';
import GenesisUtil from '../../../util/Genesis';
const PADDING_VALUE = 0xFF;
const BLOCK_SIZE = 0x40;
const FILE_SIZE = 0x800000; // The final, byte-expanded, file size
export default class YabaSanshiroSegaSaturnSaveData {
static createWithNewSize(/* segaSaturnSaveData, newSize */) {
/*
const newRawSaveData = SegaSaturnUtil.resize(segaSaturnSaveData.getArrayBuffer(), newSize);
return SegaSaturnSaveData.createFromSegaSaturnData(newRawSaveData);
*/
}
static createFromSegaSaturnData(arrayBuffer) {
// Saves from yaba sanshiro are byte-expanded
if (!GenesisUtil.isByteExpanded(arrayBuffer)) {
throw new Error('This does not appear to be a yaba sanshiro save file');
}
const byteCollapsedArrayBuffer = GenesisUtil.byteCollapse(arrayBuffer);
return SegaSaturnSaveData.createFromSegaSaturnData(byteCollapsedArrayBuffer, BLOCK_SIZE);
}
static createFromSaveFiles(saveFiles) {
const segaSaturnSaveData = SegaSaturnSaveData.createFromSaveFiles(saveFiles, BLOCK_SIZE, FILE_SIZE / 2);
return new SegaSaturnSaveData(
GenesisUtil.byteExpand(segaSaturnSaveData.getArrayBuffer(), PADDING_VALUE),
segaSaturnSaveData.getSaveFiles(),
segaSaturnSaveData.getVolumeInfo(),
);
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/Emulators/yabause.js
================================================
/*
The older emulator yabause reads/writes raw Saturn BIOS files which are byte-expanded
I'm unsure of what cartridge saves look like from this emulator because I haven't seen an example yet
*/
import SegaSaturnSaveData from '../SegaSaturn';
import GenesisUtil from '../../../util/Genesis';
const PADDING_VALUE = 0xFF;
export default class YabauseSegaSaturnSaveData {
static createWithNewSize(/* segaSaturnSaveData, newSize */) {
/*
const newRawSaveData = SegaSaturnUtil.resize(segaSaturnSaveData.getArrayBuffer(), newSize);
return SegaSaturnSaveData.createFromSegaSaturnData(newRawSaveData);
*/
}
static createFromSegaSaturnData(arrayBuffer) {
// Saves from yabause are byte-expanded
if (!GenesisUtil.isByteExpanded(arrayBuffer)) {
throw new Error('This does not appear to be a yabause save file');
}
const byteCollapsedArrayBuffer = GenesisUtil.byteCollapse(arrayBuffer);
return SegaSaturnSaveData.createFromSegaSaturnData(byteCollapsedArrayBuffer);
}
static createFromSaveFiles(saveFiles, blockSize) {
const segaSaturnSaveData = SegaSaturnSaveData.createFromSaveFiles(saveFiles, blockSize);
return new SegaSaturnSaveData(
GenesisUtil.byteExpand(segaSaturnSaveData.getArrayBuffer(), PADDING_VALUE),
segaSaturnSaveData.getSaveFiles(),
segaSaturnSaveData.getVolumeInfo(),
);
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/IndividualSaves/Bup.js
================================================
/* eslint-disable no-bitwise */
/*
The standard format for individual saves on the Saturn appears to be the .BUP format. It contains all the stuff found in the header
for each save in the files created by the Saturn's BIOS, plus some other extra stuff.
The format is described here: https://github.com/slinga-homebrew/Save-Game-BUP-Scripts/blob/main/bup_header.h#L94
And here is some code that populates all of the fields: https://github.com/slinga-homebrew/Save-Game-BUP-Scripts/blob/main/bup_parse.py#L248
Like the code above, we're going to ignore some of the fields like stats and block size.
Here's the structure as assembled from reading https://github.com/slinga-homebrew/Save-Game-BUP-Scripts/blob/main/bup_parse.py#L248
0x00 - 0x03: Magic
0x04 - 0x07: Save ID
0x08 - 0x0B: stats: we leave as zero. Details: https://github.com/cafe-alpha/pskai_wtfpl/blob/main/vmem_defs.h#L82
0x0C - 0x0F: unused
0x10 - 0x1B: archive name
0x1C - 0x26: comment
0x27: language code
0x28 - 0x2B: date code 1: when game was last saved
0x2C - 0x2F: data size in bytes
0x30 - 0x31: data size in blocks: we leave as zero (we don't know whether this save will be written to internal memory or a backup cart, and they have different block sizes)
0x32 - 0x33: padding
0x34 - 0x37: date code 2: when Pseudo Saturn Kai last started
0x38 - 0x3F: unused
The portion from 0x10 - 0x31 inclusive is the official BupDir struct, as detailed here: http://ppcenter.free.fr/satdocs/ST-162-R1-092994.html (page 42)
Note that we are supposed to prefer the first date in the structure (when the game was last saved) over the second date (when Pseudo Saturn Kai last started)
https://github.com/cafe-alpha/pskai_wtfpl/blob/main/vmem_defs.h#L127
*/
import SegaSaturnSaveData from '../SegaSaturn';
import SegaSaturnUtil from '../Util';
import Util from '../../../util/util';
const LITTLE_ENDIAN = false;
const HEADER_SIZE = 64;
const MAGIC = 'Vmem';
const MAGIC_OFFSET = 0x00;
const MAGIC_ENCODING = 'US-ASCII';
const SAVE_ID_OFFSET = 0x04;
const SAVE_NAME_OFFSET = 0x10;
const SAVE_NAME_LENGTH = SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_LENGTH + 1; // +1 to hold a NULL at the end
const COMMENT_OFFSET = 0x1C;
const COMMENT_LENGTH = SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_LENGTH + 1; // +1 to hold a NULL at the end
const LANGUAGE_OFFSET = 0x27;
const DATE_OFFSET_1 = 0x28;
const SAVE_SIZE_OFFSET = 0x2C;
const DATE_OFFSET_2 = 0x34;
export default class SegaSaturnBupSaveData {
// We take in a list of save files so that we can assign a unique ID to each BUP file that we return
static convertSaveFilesToBups(saveFiles) {
return saveFiles.map((saveFile, index) => {
let headerArrayBuffer = Util.getFilledArrayBuffer(HEADER_SIZE, 0x00); // In the BUP header structure there's lots of padding and also fields that we don't need to fill in
headerArrayBuffer = Util.setMagic(headerArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
headerArrayBuffer = Util.setString(headerArrayBuffer, SAVE_NAME_OFFSET, saveFile.name, SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_ENCODING, SAVE_NAME_LENGTH - 1);
headerArrayBuffer = Util.setString(headerArrayBuffer, COMMENT_OFFSET, saveFile.comment, SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_ENCODING, COMMENT_LENGTH - 1);
const headerDataView = new DataView(headerArrayBuffer);
headerDataView.setUint32(SAVE_ID_OFFSET, index, LITTLE_ENDIAN);
headerDataView.setUint8(LANGUAGE_OFFSET, saveFile.languageCode);
headerDataView.setUint32(DATE_OFFSET_1, saveFile.dateCode, LITTLE_ENDIAN);
headerDataView.setUint32(SAVE_SIZE_OFFSET, saveFile.saveSize, LITTLE_ENDIAN);
headerDataView.setUint32(DATE_OFFSET_2, saveFile.dateCode, LITTLE_ENDIAN);
return Util.concatArrayBuffers([headerArrayBuffer, saveFile.rawData]);
});
}
static convertBupsToSaveFiles(arrayBuffers) {
return arrayBuffers.map((arrayBuffer) => {
const headerArrayBuffer = arrayBuffer.slice(0, HEADER_SIZE);
const headerDataView = new DataView(headerArrayBuffer);
const headerUint8Array = new Uint8Array(headerArrayBuffer);
const rawData = arrayBuffer.slice(HEADER_SIZE);
try {
Util.checkMagic(headerArrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
} catch (e) {
throw new Error('This does not appear to be a Sega Saturn save file in .BUP format');
}
const languageCode = headerDataView.getUint8(LANGUAGE_OFFSET);
const dateCode1 = headerDataView.getUint32(DATE_OFFSET_1, LITTLE_ENDIAN);
const dateCode2 = headerDataView.getUint32(DATE_OFFSET_2, LITTLE_ENDIAN);
const saveSize = headerDataView.getUint32(SAVE_SIZE_OFFSET, LITTLE_ENDIAN);
const dateCode = (dateCode1 !== 0) ? dateCode1 : dateCode2; // See note above about which date to prefer
if (saveSize !== rawData.byteLength) {
throw new Error(`Specified save size of ${saveSize} bytes does not match actual save size of ${rawData.byteLength} bytes`);
}
return {
name: Util.readNullTerminatedString(headerUint8Array, SAVE_NAME_OFFSET, SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_ENCODING, SAVE_NAME_LENGTH - 1),
languageCode,
language: SegaSaturnUtil.getLanguageString(languageCode),
comment: Util.readNullTerminatedString(headerUint8Array, COMMENT_OFFSET, SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_ENCODING, COMMENT_LENGTH - 1),
dateCode,
date: SegaSaturnUtil.getDate(dateCode),
saveSize,
rawData,
};
});
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/Saroo/Cart.js
================================================
/* eslint-disable no-bitwise */
/*
This is the SS_MEMS.BIN file created by the Saroo
The official save converter describes as "SAROO extend save": https://github.com/tpunix/SAROO/blob/master/tools/savetool/main.c#L131
The Saroo reads/writes here when the game wants to access a backup memory cart: https://github.com/tpunix/SAROO/issues/232
It's parsed by https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_mems.c
This file has 8064 blocks of 1024 bytes each (see below about the "missing" 128 blocks from the occupancy bitmap)
Block 0: Header
0x00 - 0x07: Magic
0x08 - 0x0B: Total size
0x0C - 0x0D: Free block
0x0E - 0x0F: Unused (listed as "first save", but not used and set to 0x00)
0x10 - 0x3FF: Block occupancy bitmap
Blocks 1 - 7: Directory entries
Directory entry:
0x00 - 0x0B: Name
0x0C - 0x0D: Unused (listed as save size and read as a uint32. But it's only 2 bytes from the starting block num. This appears to be a bug, and they also this is always set to 0x00: https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_mems.c#L314
0x0E - 0x0F: Archive entry block number
Archive entry:
0x00 - 0x0B: Name
0x0C - 0x0F: Size in bytes
0x10 - 0x1A: Comment
0x1B: Language code
0x1C - 1x1F: Date code
0x40 - ????: Save data (if small enough to fit in this block), or list of blocks containing the save data (terminated with ARCHIVE_ENTRY_BLOCK_LIST_END)
*/
import Util from '../../../util/util';
import ArrayUtil from '../../../util/Array';
import SegaSaturnSaveData from '../SegaSaturn';
import SegaSaturnUtil from '../Util';
import SegaSaturnSarooUtil from './Util';
const LITTLE_ENDIAN = false;
const BLOCK_SIZE = 1024;
const TOTAL_BLOCKS = 8192; // The file is this many blocks long, but only 8064 are usable: see note below about the "missing" blocks
const FILL_VALUE = 0x00;
const HEADER_NUM_BLOCKS = 1;
const MAGIC = 'SaroMems';
const MAGIC_OFFSET = 0;
const MAGIC_ENCODING = 'US-ASCII';
const TOTAL_SIZE_OFFSET = 0x08;
const FREE_BLOCKS_OFFSET = 0x0C;
const BITMAP_OFFSET = 0x10;
const BITMAP_LENGTH = BLOCK_SIZE - BITMAP_OFFSET; // Note that the bitmap is "missing" 16 bytes, which are used by the header. 16 bytes * 8 = 128 blocks not included in the bitmap. Hence the file has 8064 blocks instead of 8192
const AVAILABLE_BLOCKS = BITMAP_LENGTH * 8;
const DIRECTORY_OFFSET = BLOCK_SIZE;
const DIRECTORY_NUM_BLOCKS = 7;
const DIRECTORY_ENTRY_NAME_OFFSET = 0x00;
const DIRECTORY_ENTRY_STARTING_BLOCK = 0x0E;
const DIRECTORY_ENTRY_LENGTH = 0x10;
const TOTAL_DIRECTORY_ENTRIES = (DIRECTORY_NUM_BLOCKS * BLOCK_SIZE) / DIRECTORY_ENTRY_LENGTH;
const ARCHIVE_ENTRY_NAME_OFFSET = 0x00;
const ARCHIVE_ENTRY_SIZE_OFFSET = 0x0C;
const ARCHIVE_ENTRY_COMMENT_OFFSET = 0x10;
const ARCHIVE_ENTRY_LANGUAGE_OFFSET = 0x1B;
const ARCHIVE_ENTRY_DATE_OFFSET = 0x1C;
const ARCHIVE_ENTRY_BLOCK_LIST_OFFSET = 0x40;
const ARCHIVE_ENTRY_BLOCK_LIST_ENTRY_SIZE = 2;
const ARCHIVE_ENTRY_BLOCK_LIST_END = 0x0000;
const ARCHIVE_ENTRY_BLOCK_LIST_AVAILABLE_SIZE = BLOCK_SIZE - ARCHIVE_ENTRY_BLOCK_LIST_OFFSET;
const ARCHIVE_ENTRY_MAX_NUM_BLOCKS = (ARCHIVE_ENTRY_BLOCK_LIST_AVAILABLE_SIZE / ARCHIVE_ENTRY_BLOCK_LIST_ENTRY_SIZE) - 1; // -1 for the end of list marker. We have space for a list of 479 blocks, which would support files of 479kB: much larger than the original Saturn's cart memory
const NUM_RESERVED_BLOCKS = HEADER_NUM_BLOCKS + DIRECTORY_NUM_BLOCKS;
function createEmptyBlock() {
return Util.getFilledArrayBuffer(BLOCK_SIZE, FILL_VALUE);
}
function createHeaderBlock(volumeInfo) {
const blockOccupancyBitmapArrayBuffer = SegaSaturnSarooUtil.createBlockOccupancyBitmap(volumeInfo.usedBlocks, BITMAP_LENGTH);
let headerBlock = createEmptyBlock();
headerBlock = Util.setMagic(headerBlock, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
headerBlock = Util.setArrayBufferPortion(headerBlock, blockOccupancyBitmapArrayBuffer, BITMAP_OFFSET, 0, BITMAP_LENGTH);
const headerBlockDataView = new DataView(headerBlock);
headerBlockDataView.setUint32(TOTAL_SIZE_OFFSET, volumeInfo.totalSize, LITTLE_ENDIAN);
headerBlockDataView.setUint16(FREE_BLOCKS_OFFSET, volumeInfo.numFreeBlocks, LITTLE_ENDIAN);
return headerBlock;
}
function createDirectoryEntry(saveFile, startingBlockNum) {
let directoryEntry = Util.getFilledArrayBuffer(DIRECTORY_ENTRY_LENGTH, FILL_VALUE);
directoryEntry = Util.setString(directoryEntry, DIRECTORY_ENTRY_NAME_OFFSET, saveFile.name, SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_ENCODING, SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_LENGTH);
const directoryEntryDataView = new DataView(directoryEntry);
directoryEntryDataView.setUint16(DIRECTORY_ENTRY_STARTING_BLOCK, startingBlockNum, LITTLE_ENDIAN);
return directoryEntry;
}
function createDirectory(gameSaveFilesWithBlockList) {
let currentBlockNum = NUM_RESERVED_BLOCKS;
const directoryEntries = gameSaveFilesWithBlockList.map((saveFileWithBlockList) => {
const saveFileStartingBlockNum = currentBlockNum;
currentBlockNum += saveFileWithBlockList.blockList.length;
return createDirectoryEntry(saveFileWithBlockList, saveFileStartingBlockNum);
});
const remainingDirectorySize = (TOTAL_DIRECTORY_ENTRIES - directoryEntries.length) * DIRECTORY_ENTRY_LENGTH;
if (remainingDirectorySize > 0) {
const remainingDirectoryArrayBuffer = Util.getFilledArrayBuffer(remainingDirectorySize, FILL_VALUE);
return Util.concatArrayBuffers([...directoryEntries, remainingDirectoryArrayBuffer]);
}
return Util.concatArrayBuffers(directoryEntries);
}
function createBlockListForSaveFile(saveFile, startingBlockNum) {
// First create the archive entry block
let archiveEntry = createEmptyBlock();
archiveEntry = Util.setString(archiveEntry, ARCHIVE_ENTRY_NAME_OFFSET, saveFile.name, SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_ENCODING, SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_LENGTH);
archiveEntry = Util.setString(archiveEntry, ARCHIVE_ENTRY_COMMENT_OFFSET, saveFile.comment, SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_ENCODING, SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_LENGTH);
const archiveEntryDataView = new DataView(archiveEntry);
archiveEntryDataView.setUint32(ARCHIVE_ENTRY_SIZE_OFFSET, saveFile.rawData.byteLength, LITTLE_ENDIAN);
archiveEntryDataView.setUint8(ARCHIVE_ENTRY_LANGUAGE_OFFSET, saveFile.languageCode);
archiveEntryDataView.setUint32(ARCHIVE_ENTRY_DATE_OFFSET, saveFile.dateCode, LITTLE_ENDIAN);
const blockList = [];
// If the save data can fit within the archive block then it's found there
// Otherwise, the rest of the archive block is a list of blocks which contain the save data
if (saveFile.rawData.byteLength <= ARCHIVE_ENTRY_BLOCK_LIST_AVAILABLE_SIZE) {
archiveEntry = Util.setArrayBufferPortion(archiveEntry, saveFile.rawData, ARCHIVE_ENTRY_BLOCK_LIST_OFFSET, 0, saveFile.rawData.byteLength);
blockList.push(archiveEntry);
} else {
// If the file is too big to fit into the archive block then we need to fill in the block list,
// and divide the file into blocks
// Fill in the block list
const numBlocks = Math.ceil(saveFile.rawData.byteLength / BLOCK_SIZE);
if (numBlocks > ARCHIVE_ENTRY_MAX_NUM_BLOCKS) {
throw new Error(`Not enough space to store file ${saveFile.name} - ${saveFile.comment}: requires ${numBlocks} but the maximum is ${ARCHIVE_ENTRY_MAX_NUM_BLOCKS}`);
}
for (let i = 0; i < numBlocks; i += 1) {
const blockListEntryOffset = ARCHIVE_ENTRY_BLOCK_LIST_OFFSET + (ARCHIVE_ENTRY_BLOCK_LIST_ENTRY_SIZE * i);
archiveEntryDataView.setUint16(blockListEntryOffset, startingBlockNum + 1 + i, LITTLE_ENDIAN); // +1 because of the archive entry block. We're cheating a bit here because we know we will lay everything our sequentially
}
const blockListEndOffset = ARCHIVE_ENTRY_BLOCK_LIST_OFFSET + (ARCHIVE_ENTRY_BLOCK_LIST_ENTRY_SIZE * numBlocks);
archiveEntryDataView.setUint16(blockListEndOffset, ARCHIVE_ENTRY_BLOCK_LIST_END, LITTLE_ENDIAN);
blockList.push(archiveEntry);
// Now divide the data into blocks. We need to pad the data in the last block to be exactly a block size
let rawDataPadded = saveFile.rawData;
if ((saveFile.rawData % BLOCK_SIZE) !== 0) {
const paddingBytes = BLOCK_SIZE - (saveFile.rawData.byteLength % BLOCK_SIZE);
rawDataPadded = Util.concatArrayBuffers([rawDataPadded, Util.getFilledArrayBuffer(paddingBytes, FILL_VALUE)]);
}
for (let i = 0; i < numBlocks; i += 1) {
const blockStartingOffset = i * BLOCK_SIZE;
blockList.push(rawDataPadded.slice(blockStartingOffset, blockStartingOffset + BLOCK_SIZE));
}
}
return {
...saveFile,
blockList,
};
}
function getBlock(arrayBuffer, blockNumber) {
return arrayBuffer.slice(blockNumber * BLOCK_SIZE, (blockNumber + 1) * BLOCK_SIZE);
}
function getDirectoryArrayBuffer(arrayBuffer) {
return arrayBuffer.slice(DIRECTORY_OFFSET, DIRECTORY_OFFSET + (DIRECTORY_NUM_BLOCKS * BLOCK_SIZE));
}
function getOccupiedDirectoryEntryIndexes(arrayBuffer) {
const directoryArrayBuffer = getDirectoryArrayBuffer(arrayBuffer);
const directoryUint8Array = new Uint8Array(directoryArrayBuffer);
const allDirectoryEntryIndexes = ArrayUtil.createSequentialArray(0, TOTAL_DIRECTORY_ENTRIES);
return allDirectoryEntryIndexes.filter((index) => (directoryUint8Array[index * DIRECTORY_ENTRY_LENGTH] !== 0)); // Test if first character of name is non-zero. Same as https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_mems.c#L308
}
function getSaveFile(arrayBuffer, directoryEntryIndex) {
// First read the directory entry to get the name and block number of the archive entry block
const directoryArrayBuffer = getDirectoryArrayBuffer(arrayBuffer);
const directoryDataView = new DataView(directoryArrayBuffer);
const directoryUint8Array = new Uint8Array(directoryArrayBuffer);
const directoryEntryOffset = directoryEntryIndex * DIRECTORY_ENTRY_LENGTH;
const directoryName = Util.readNullTerminatedString(
directoryUint8Array,
directoryEntryOffset + DIRECTORY_ENTRY_NAME_OFFSET,
SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_ENCODING,
SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_LENGTH,
);
const startingBlockNum = directoryDataView.getUint16(directoryEntryOffset + DIRECTORY_ENTRY_STARTING_BLOCK);
// Now read the archive entry block to get all of the save file metadata
const archiveEntryBlock = getBlock(arrayBuffer, startingBlockNum);
const archiveEntryBlockDataView = new DataView(archiveEntryBlock);
const archiveEntryBlockUint8Array = new Uint8Array(archiveEntryBlock);
const name = Util.readNullTerminatedString(
archiveEntryBlockUint8Array,
ARCHIVE_ENTRY_NAME_OFFSET,
SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_ENCODING,
SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_LENGTH,
);
const comment = Util.readNullTerminatedString(
archiveEntryBlockUint8Array,
ARCHIVE_ENTRY_COMMENT_OFFSET,
SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_ENCODING,
SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_LENGTH,
);
const saveSize = archiveEntryBlockDataView.getUint32(ARCHIVE_ENTRY_SIZE_OFFSET, LITTLE_ENDIAN);
const languageCode = archiveEntryBlockDataView.getUint8(ARCHIVE_ENTRY_LANGUAGE_OFFSET);
const dateCode = archiveEntryBlockDataView.getUint32(ARCHIVE_ENTRY_DATE_OFFSET, LITTLE_ENDIAN);
if (name !== directoryName) {
throw new Error(`File appears to be corrupt: found directory entry with name ${directoryName} but the corresponding archive entry has name ${name}`);
}
const rawDataBlockList = [];
let rawData = null;
// Lastly we can get the actual save data
// If the save data can fit within the archive block then it's found there
// Otherwise, the rest of the archive block is a list of blocks which contain the save data
if (saveSize <= ARCHIVE_ENTRY_BLOCK_LIST_AVAILABLE_SIZE) {
rawData = archiveEntryBlock.slice(ARCHIVE_ENTRY_BLOCK_LIST_OFFSET, ARCHIVE_ENTRY_BLOCK_LIST_OFFSET + saveSize);
} else {
let blockListCurrentOffset = ARCHIVE_ENTRY_BLOCK_LIST_OFFSET;
let blockListEntry = archiveEntryBlockDataView.getUint16(blockListCurrentOffset);
while (blockListEntry !== ARCHIVE_ENTRY_BLOCK_LIST_END) {
rawDataBlockList.push(blockListEntry);
blockListCurrentOffset += ARCHIVE_ENTRY_BLOCK_LIST_ENTRY_SIZE;
blockListEntry = archiveEntryBlockDataView.getUint16(blockListCurrentOffset);
}
const rawDataBlocks = rawDataBlockList.map((blockNum) => getBlock(arrayBuffer, blockNum));
rawData = Util.concatArrayBuffers(rawDataBlocks).slice(0, saveSize);
}
return {
name,
languageCode,
language: SegaSaturnUtil.getLanguageString(languageCode),
comment,
dateCode,
date: SegaSaturnUtil.getDate(dateCode),
blockList: rawDataBlockList,
saveSize,
rawData,
};
}
export default class SarooSegaSaturnCartSaveData {
static createWithNewSize(/* segaSaturnSaveData, newSize */) {
/*
const newRawSaveData = SegaSaturnUtil.resize(segaSaturnSaveData.getArrayBuffer(), newSize);
return SarooSegaSaturnCartSaveData.createFromSegaSaturnData(newRawSaveData);
*/
}
static saveFilesContainsFile(saveFiles, saveFile) {
return (saveFiles.findIndex((x) => SarooSegaSaturnCartSaveData.saveFilesAreEqual(x, saveFile)) >= 0);
}
static saveFilesAreEqual(saveFile1, saveFile2) {
return (saveFile1.name === saveFile2.name);
}
static upsertGameSaveFiles(existingGameSaveFiles, newGameSaveFiles) {
const existingCopy = existingGameSaveFiles.slice(0); // Shallow copy
// Merge in the new game save files into the existing game save files
// Uses an 'upsert' style operation where missing records are inserted, and existing records are updated
newGameSaveFiles.forEach((newSaveFile) => {
const existingSaveFileIndex = existingCopy.findIndex((existing) => SarooSegaSaturnCartSaveData.saveFilesAreEqual(existing, newSaveFile));
if (existingSaveFileIndex < 0) {
// If this save file does not exist for this game, then add it
existingCopy.push(newSaveFile);
} else {
// If this save file does exist for this game, then update it
existingCopy[existingSaveFileIndex] = newSaveFile;
}
});
return existingCopy;
}
static isCartSarooData(arrayBuffer) {
try {
SarooSegaSaturnCartSaveData.createFromSarooData(arrayBuffer);
return true;
} catch (e) {
return false;
}
}
static createFromSarooData(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
Util.checkMagic(arrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const totalSize = dataView.getUint32(TOTAL_SIZE_OFFSET, LITTLE_ENDIAN);
const numFreeBlocks = dataView.getUint16(FREE_BLOCKS_OFFSET, LITTLE_ENDIAN);
const bitmap = arrayBuffer.slice(BITMAP_OFFSET, BITMAP_OFFSET + BITMAP_LENGTH);
const { usedBlocks } = SegaSaturnSarooUtil.getBlockOccupancy(bitmap, totalSize, BLOCK_SIZE);
const occupiedDirectoryEntryIndexes = getOccupiedDirectoryEntryIndexes(arrayBuffer);
const saveFiles = occupiedDirectoryEntryIndexes.map((index) => getSaveFile(arrayBuffer, index));
const volumeInfo = {
totalSize,
numFreeBlocks,
numUsedBlocks: usedBlocks.length,
usedBlocks,
};
return new SarooSegaSaturnCartSaveData(arrayBuffer, saveFiles, volumeInfo);
}
static createFromSaveFiles(gameSaveFiles) {
if (gameSaveFiles.length > TOTAL_DIRECTORY_ENTRIES) {
throw new Error(`Not enough space to hold all saves. Requires ${gameSaveFiles.length} saves, but directory only has room for ${TOTAL_DIRECTORY_ENTRIES} saves`);
}
// First figure out how many blocks each save file requires
let startingBlockNum = NUM_RESERVED_BLOCKS;
const gameSaveFilesWithBlockList = gameSaveFiles.map((saveFile) => {
const saveFileWithBlockList = createBlockListForSaveFile(saveFile, startingBlockNum);
startingBlockNum += saveFileWithBlockList.blockList.length;
return saveFileWithBlockList;
});
const gameBlocksList = gameSaveFilesWithBlockList.map((saveFileWithBlocks) => saveFileWithBlocks.blockList);
const gameBlocks = Util.concatArrayBuffers(gameBlocksList.flat());
// Now that we know how many blocks each save takes we can calculate the volume info and directory blocks
const directoryBlocks = createDirectory(gameSaveFilesWithBlockList);
const numUsedBlocks = NUM_RESERVED_BLOCKS + gameBlocksList.flat().length;
const usedBlocks = ArrayUtil.createSequentialArray(0, numUsedBlocks); // Cheating, because we know we're going to lay everything out sequentially
if (numUsedBlocks > AVAILABLE_BLOCKS) {
throw new Error(`Not enough space to hold all saves. Requires ${numUsedBlocks} but we only have ${AVAILABLE_BLOCKS} of space`);
}
const volumeInfo = {
totalSize: TOTAL_BLOCKS * BLOCK_SIZE,
numFreeBlocks: AVAILABLE_BLOCKS - numUsedBlocks,
numUsedBlocks,
usedBlocks,
};
// With the volume info we can make the header block and the number of empty blocks we need to pad out the file
const headerBlock = createHeaderBlock(volumeInfo);
const emptyBlocks = Util.getFilledArrayBuffer((TOTAL_BLOCKS - numUsedBlocks) * BLOCK_SIZE, FILL_VALUE); // Note that we already checked that numUsedBlocks is <= AVAILABLE_BLOCKS, which is < TOTAL_BLOCKS. So we'll always have some number of bytes here
// Concat all the portions to create the final file
const arrayBuffer = Util.concatArrayBuffers([headerBlock, directoryBlocks, gameBlocks, emptyBlocks]);
return new SarooSegaSaturnCartSaveData(arrayBuffer, gameSaveFiles, volumeInfo);
}
// This constructor creates a new object from a binary representation of Sega Saturn save data
constructor(arrayBuffer, saveFiles, volumeInfo) {
this.arrayBuffer = arrayBuffer;
this.saveFiles = saveFiles;
this.volumeInfo = volumeInfo;
}
getSaveFiles() {
return this.saveFiles;
}
getVolumeInfo() {
return this.volumeInfo;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/Saroo/Internal.js
================================================
/* eslint-disable no-bitwise */
/*
This is the SS_SAVE.BIN file created by the Saroo.
The official save converter describes as "SAROO save file": https://github.com/tpunix/SAROO/blob/master/tools/savetool/main.c#L131
The Saroo reads/writes here when the game wants to access the system's internal memory: https://github.com/tpunix/SAROO/issues/232
It's parsed by https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_bup.c
The file is divided into slots of 0x10000 bytes, one per game (this is double the Saturn's internal memory size, per game). Slot 0 is reserved.
It appears that the file expands as necessary to fit new slots.
Reserved slot format:
0x00 - 0x0F: Magic
0x10 - 0x1F: Game ID 1 (corresponds to the save slot beginning at 0x10000)
0x20 - 0x2F: Game ID 2 (corresponds to the save slot beginning at 0x20000)
(etc)
Save slot format:
0x00 - 0x07: Magic
0x08 - 0x11: Total size
0x0C - 0x0D: Block size
0x0E - 0x0F: Free blocks
0x10 - 0x1F: Unused
0x20 - 0x2F: Game ID
0x30 - 0x3D: Unused
0x3E - 0x3F: Block number of the first save
0x40 - 0x7F: Block occupancy bitmap for the entire slot (includes the slot header block and archive entry blocks)
Archive entry format:
0x00 - 0x0A: Archive name, 11 bytes
0x0C - 0x0F: Archive byte size, 4 bytes
0x10 - 0x19: Archive comment, 10 bytes
0x1A: 00
0x1B: Language code
0x1C - 0x1F: Archive date encoding, 4 bytes
0x3E - 0x3F: The block number of the next save
0x40 - 0x7F: Block occupancy bitmap for the data for this save (does not include the archive entry block)
*/
import Util from '../../../util/util';
import ArrayUtil from '../../../util/Array';
import SegaSaturnSaveData from '../SegaSaturn';
import SegaSaturnUtil from '../Util';
import SegaSaturnSarooUtil from './Util';
const LITTLE_ENDIAN = false;
const MAGIC = 'Saroo Save File'; // 16 bytes long, the same as the length of a game ID in the reserved slot
const MAGIC_OFFSET = 0;
const MAGIC_ENCODING = 'US-ASCII';
const SLOT_SIZE = 0x10000;
const DEFAULT_BLOCK_SIZE = 0x80;
const BITMAP_LENGTH = 64;
const GAME_ID_LENGTH = 0x10;
const GAME_ID_ENCODING = 'US-ASCII';
const NUM_RESERVED_SLOTS = 1; // For the magic string above
const NUM_SLOTS = SLOT_SIZE / GAME_ID_LENGTH; // In theory, the reserved slot can store this many game IDs
const NUM_AVAILABLE_SLOTS = NUM_SLOTS - NUM_RESERVED_SLOTS;
const FILL_VALUE = 0x00;
const SLOT_MAGIC = 'SaroSave';
const SLOT_MAGIC_OFFSET = 0;
const SLOT_MAGIC_ENCODING = 'US-ASCII';
const SLOT_TOTAL_SIZE_OFFSET = 0x08;
const SLOT_BLOCK_SIZE_OFFSET = 0x0C;
const SLOT_FREE_BLOCKS_OFFSET = 0x0E;
const SLOT_GAME_ID_OFFSET = 0x20;
const SLOT_FIRST_SAVE_BLOCK_OFFSET = 0x3E; // This contains NO_NEXT_SAVE if there's no first save
const SLOT_BITMAP_OFFSET = 0x40;
const NUM_RESERVED_BLOCKS = 1; // The first block in a slot contains the slot information: magic, total size, block size, bitmap, etc
const ARCHIVE_ENTRY_NAME_OFFSET = 0x00;
const ARCHIVE_ENTRY_SAVE_SIZE_OFFSET = 0x0C;
const ARCHIVE_ENTRY_COMMENT_OFFSET = 0x10;
const ARCHIVE_ENTRY_LANGUAGE_OFFSET = 0x1B;
const ARCHIVE_ENTRY_DATE_OFFSET = 0x1C;
const ARCHIVE_ENTRY_NEXT_SAVE_BLOCK_OFFSET = 0x3E; // This contains NO_NEXT_SAVE if there's no next save
const ARCHIVE_ENTRY_BITMAP_OFFSET = 0x40;
const NO_NEXT_SAVE = 0;
function slotContainsValidSaves(slotNum, arrayBuffer) {
// Reserved slots can't contain valid saves
if (slotNum < NUM_RESERVED_SLOTS) {
return false;
}
// A slot contains a valid save if there's text in its game ID spot in the reserved slot,
// and if the file is big enough to hold the data for that slot
const dataView = new DataView(arrayBuffer);
const dummy = dataView.getUint32(slotNum * GAME_ID_LENGTH, LITTLE_ENDIAN); // Anything non-zero in the first 4 bytes is sufficient to say there's text there: https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_bup.c#L397
return (dummy !== 0) && (arrayBuffer.byteLength >= ((slotNum + 1) * SLOT_SIZE));
}
function getSlot(slotNum, arrayBuffer) {
return arrayBuffer.slice(slotNum * SLOT_SIZE, (slotNum + 1) * SLOT_SIZE);
}
function getBlock(slotArrayBuffer, blockSize, blockNumber) {
return slotArrayBuffer.slice(blockNumber * blockSize, (blockNumber + 1) * blockSize);
}
// Based on get_next_block() from https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_bup.c#L74
// It just finds the next occupied block in sequential order and returns it
//
// I don't think that this deals with fragmentation correctly. When adding a new save it just finds the first free block
// https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_bup.c#L279 and then keeps finding the next available
// free block https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_bup.c#L296 but they're not linked together
// in any way. And, the occupancy bitmap doesn't provide any ability to link blocks together
//
// We'll just replicate the functionality from that tool here.
function getNextBlockNum(blockNum, blockOccupancy) {
const subsequentBlockOccupancy = blockOccupancy.slice(blockNum);
const subsequentBlockIndex = subsequentBlockOccupancy.find((blockIsOccupied) => blockIsOccupied);
if (subsequentBlockIndex !== undefined) {
return subsequentBlockIndex + blockNum;
}
throw new Error('No further block is occupied');
}
// Based on access_data() from https://github.com/tpunix/SAROO/blob/master/tools/savetool/sr_bup.c#L97
// Read the save data one block at a time and concat them all together
function getRawDataBlockList(blockNum, blockSize, saveSize, blockOccupancy) {
const blockIndexes = ArrayUtil.createSequentialArray(0, Math.ceil(saveSize / blockSize));
let currentBlockNum = blockNum;
return blockIndexes.map(() => {
currentBlockNum = getNextBlockNum(currentBlockNum, blockOccupancy);
return currentBlockNum;
});
}
function getRawData(blockList, blockSize, saveSize, slotArrayBuffer) {
const blocks = blockList.map((blockNum) => getBlock(slotArrayBuffer, blockSize, blockNum));
return Util.concatArrayBuffers(blocks).slice(0, saveSize);
}
function getSaveFiles(slotNum, arrayBuffer) {
const slotArrayBuffer = getSlot(slotNum, arrayBuffer);
const slotDataView = new DataView(slotArrayBuffer);
const slotUint8Array = new Uint8Array(slotArrayBuffer);
try {
Util.checkMagic(slotArrayBuffer, SLOT_MAGIC_OFFSET, SLOT_MAGIC, SLOT_MAGIC_ENCODING);
} catch (e) {
// It's possible to have a slot with a valid game ID listed in the reserved block but completely blank data in its slot
// In this case, we just return that there's no save files here
return {
gameId: null,
saveFiles: [],
};
}
const totalSize = slotDataView.getUint32(SLOT_TOTAL_SIZE_OFFSET, LITTLE_ENDIAN);
const blockSize = slotDataView.getUint16(SLOT_BLOCK_SIZE_OFFSET, LITTLE_ENDIAN);
const freeBlocks = slotDataView.getUint16(SLOT_FREE_BLOCKS_OFFSET, LITTLE_ENDIAN);
const gameId = Util.readNullTerminatedString(slotUint8Array, SLOT_GAME_ID_OFFSET, GAME_ID_ENCODING, GAME_ID_LENGTH);
let nextSaveBlockNum = slotDataView.getUint16(SLOT_FIRST_SAVE_BLOCK_OFFSET, LITTLE_ENDIAN);
const slotBitmap = slotArrayBuffer.slice(SLOT_BITMAP_OFFSET, SLOT_BITMAP_OFFSET + BITMAP_LENGTH);
const slotBlockOccupancy = SegaSaturnSarooUtil.getBlockOccupancy(slotBitmap, totalSize, blockSize);
const saveFiles = [];
while (nextSaveBlockNum !== NO_NEXT_SAVE) {
const archiveEntryBlockArrayBuffer = getBlock(slotArrayBuffer, blockSize, nextSaveBlockNum);
const archiveEntryBlockDataView = new DataView(archiveEntryBlockArrayBuffer);
const archiveEntryBlockUint8Array = new Uint8Array(archiveEntryBlockArrayBuffer);
const name = Util.readNullTerminatedString(archiveEntryBlockUint8Array, ARCHIVE_ENTRY_NAME_OFFSET, SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_ENCODING, SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_LENGTH);
const languageCode = archiveEntryBlockDataView.getUint8(ARCHIVE_ENTRY_LANGUAGE_OFFSET);
const comment = Util.readNullTerminatedString(
archiveEntryBlockUint8Array,
ARCHIVE_ENTRY_COMMENT_OFFSET,
SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_ENCODING,
SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_LENGTH,
);
const dateCode = archiveEntryBlockDataView.getUint32(ARCHIVE_ENTRY_DATE_OFFSET, LITTLE_ENDIAN);
const saveSize = archiveEntryBlockDataView.getUint32(ARCHIVE_ENTRY_SAVE_SIZE_OFFSET, LITTLE_ENDIAN);
const saveFileBitmap = archiveEntryBlockArrayBuffer.slice(ARCHIVE_ENTRY_BITMAP_OFFSET, ARCHIVE_ENTRY_BITMAP_OFFSET + BITMAP_LENGTH);
const saveFileBlockOccupancy = SegaSaturnSarooUtil.getBlockOccupancy(saveFileBitmap, totalSize, blockSize);
const rawDataBlockList = getRawDataBlockList(nextSaveBlockNum, blockSize, saveSize, saveFileBlockOccupancy.blockOccupancy);
const rawData = getRawData(rawDataBlockList, blockSize, saveSize, slotArrayBuffer);
nextSaveBlockNum = archiveEntryBlockDataView.getUint16(ARCHIVE_ENTRY_NEXT_SAVE_BLOCK_OFFSET, LITTLE_ENDIAN);
saveFiles.push({
name,
languageCode,
language: SegaSaturnUtil.getLanguageString(languageCode),
comment,
dateCode,
date: SegaSaturnUtil.getDate(dateCode),
blockList: rawDataBlockList,
saveSize,
rawData,
});
}
return {
gameId,
freeBlocks,
slotBlockOccupancy,
saveFiles,
};
}
function getVolumeInfo(arrayBuffer) {
// Not much I can think of to say about the volume as a whole, since each slot is more like a mini-volume
// with a max size and number of free blocks and etc
return {
totalSlots: Math.floor(arrayBuffer.byteLength / SLOT_SIZE),
};
}
function createReservedSlot(gameSaveFiles) {
// The reserved slot is the magic, followed by the ID of every game that has a save. The game IDs have the same length as the magic
let reservedSlot = Util.getFilledArrayBuffer(SLOT_SIZE, FILL_VALUE);
reservedSlot = Util.setMagic(reservedSlot, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
gameSaveFiles.forEach((gameInfo, index) => {
reservedSlot = Util.setString(reservedSlot, (index + 1) * GAME_ID_LENGTH, gameInfo.gameId, GAME_ID_ENCODING, GAME_ID_LENGTH);
});
return reservedSlot;
}
function createGameSlot(gameInfo) {
// First calculate how many blocks we need for all of our saves and create our block occupancy bitmap
let nextSaveBlockNum = NUM_RESERVED_BLOCKS;
const saveFilesWithBlocksNeeded = gameInfo.saveFiles.map((saveFile) => {
const numDataBlocksNeeded = Math.ceil(saveFile.rawData.byteLength / DEFAULT_BLOCK_SIZE);
const startingBlockNum = nextSaveBlockNum;
nextSaveBlockNum += (numDataBlocksNeeded + 1);
return {
...saveFile,
numDataBlocksNeeded,
nextSaveBlockNum,
usedBlocks: ArrayUtil.createSequentialArray(startingBlockNum + 1, numDataBlocksNeeded), // Cheating, because we know we're going to lay everything out sequentially
};
});
if (saveFilesWithBlocksNeeded.length > 0) {
saveFilesWithBlocksNeeded[saveFilesWithBlocksNeeded.length - 1].nextSaveBlockNum = NO_NEXT_SAVE;
}
let numUsedBlocks = NUM_RESERVED_BLOCKS; // First block is the header
saveFilesWithBlocksNeeded.forEach((saveFile) => {
numUsedBlocks += (saveFile.numDataBlocksNeeded + 1); // +1 for the archive entry block for this save
});
const totalBlocks = Math.floor(SLOT_SIZE / DEFAULT_BLOCK_SIZE);
const freeBlocks = totalBlocks - numUsedBlocks;
if (freeBlocks < 0) {
throw new Error(`Not enough space to store ${gameInfo.saveFiles.length} saves for the game '${gameInfo.gameId}'. Need ${numUsedBlocks} blocks but only have ${totalBlocks} blocks`);
}
const slotUsedBlocks = ArrayUtil.createSequentialArray(0, numUsedBlocks); // Cheating, because we know we're going to lay everything out sequentially
const slotBlockOccupancyBitmapArrayBuffer = SegaSaturnSarooUtil.createBlockOccupancyBitmap(slotUsedBlocks, BITMAP_LENGTH);
// Now we can create our header block that contains the number of used blocks and the slot block occupancy bitmap
let headerBlock = Util.getFilledArrayBuffer(DEFAULT_BLOCK_SIZE, FILL_VALUE);
headerBlock = Util.setMagic(headerBlock, SLOT_MAGIC_OFFSET, SLOT_MAGIC, SLOT_MAGIC_ENCODING);
headerBlock = Util.setString(headerBlock, SLOT_GAME_ID_OFFSET, gameInfo.gameId, GAME_ID_ENCODING, GAME_ID_LENGTH);
headerBlock = Util.setArrayBufferPortion(headerBlock, slotBlockOccupancyBitmapArrayBuffer, SLOT_BITMAP_OFFSET, 0, BITMAP_LENGTH);
const headerBlockDataView = new DataView(headerBlock);
headerBlockDataView.setUint32(SLOT_TOTAL_SIZE_OFFSET, SLOT_SIZE, LITTLE_ENDIAN);
headerBlockDataView.setUint16(SLOT_BLOCK_SIZE_OFFSET, DEFAULT_BLOCK_SIZE, LITTLE_ENDIAN);
headerBlockDataView.setUint16(SLOT_FREE_BLOCKS_OFFSET, freeBlocks);
headerBlockDataView.setUint16(SLOT_FIRST_SAVE_BLOCK_OFFSET, gameInfo.saveFiles.length > 0 ? NUM_RESERVED_BLOCKS : NO_NEXT_SAVE); // If there's a first save file, it goes in the next block
// Next we can create the archive block for each save, and append the data (rounded up to the next block)
const allSlotPortions = [headerBlock];
saveFilesWithBlocksNeeded.forEach((saveFile) => {
const saveFileBlockOccupancyBitmapArrayBuffer = SegaSaturnSarooUtil.createBlockOccupancyBitmap(saveFile.usedBlocks, BITMAP_LENGTH);
let archiveEntryBlock = Util.getFilledArrayBuffer(DEFAULT_BLOCK_SIZE, FILL_VALUE);
archiveEntryBlock = Util.setString(
archiveEntryBlock,
ARCHIVE_ENTRY_NAME_OFFSET,
saveFile.name,
SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_ENCODING,
SegaSaturnSaveData.ARCHIVE_ENTRY_NAME_LENGTH,
);
archiveEntryBlock = Util.setString(
archiveEntryBlock,
ARCHIVE_ENTRY_COMMENT_OFFSET,
saveFile.comment,
SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_ENCODING,
SegaSaturnSaveData.ARCHIVE_ENTRY_COMMENT_LENGTH,
);
archiveEntryBlock = Util.setArrayBufferPortion(archiveEntryBlock, saveFileBlockOccupancyBitmapArrayBuffer, ARCHIVE_ENTRY_BITMAP_OFFSET, 0, BITMAP_LENGTH);
const archiveEntryBlockDataView = new DataView(archiveEntryBlock);
archiveEntryBlockDataView.setUint32(ARCHIVE_ENTRY_SAVE_SIZE_OFFSET, saveFile.rawData.byteLength, LITTLE_ENDIAN);
archiveEntryBlockDataView.setUint8(ARCHIVE_ENTRY_LANGUAGE_OFFSET, saveFile.languageCode);
archiveEntryBlockDataView.setUint32(ARCHIVE_ENTRY_DATE_OFFSET, saveFile.dateCode, LITTLE_ENDIAN);
archiveEntryBlockDataView.setUint16(ARCHIVE_ENTRY_NEXT_SAVE_BLOCK_OFFSET, saveFile.nextSaveBlockNum, LITTLE_ENDIAN);
allSlotPortions.push(archiveEntryBlock);
allSlotPortions.push(saveFile.rawData);
if ((saveFile.rawData.byteLength % DEFAULT_BLOCK_SIZE) !== 0) {
// Round us out to the nearest block
allSlotPortions.push(Util.getFilledArrayBuffer(DEFAULT_BLOCK_SIZE - (saveFile.rawData.byteLength % DEFAULT_BLOCK_SIZE), FILL_VALUE));
}
});
// Fill in the rest of the empty space and combine everything to create the slot
allSlotPortions.push(Util.getFilledArrayBuffer(freeBlocks * DEFAULT_BLOCK_SIZE, FILL_VALUE));
return Util.concatArrayBuffers(allSlotPortions);
}
export default class SarooSegaSaturnInternalSaveData {
static createWithNewSize(/* segaSaturnSaveData, newSize */) {
/*
const newRawSaveData = SegaSaturnUtil.resize(segaSaturnSaveData.getArrayBuffer(), newSize);
return SarooSegaSaturnInternalSaveData.createFromSegaSaturnData(newRawSaveData);
*/
}
static gameSaveFilesContainsFile(gameSaveFiles, gameId, saveFile) {
const gameIdIndex = gameSaveFiles.findIndex((x) => x.gameId === gameId);
if (gameIdIndex < 0) {
return false;
}
return (gameSaveFiles[gameIdIndex].saveFiles.findIndex((x) => x.name === saveFile.name) >= 0);
}
static gameSaveFilesAreEqual(gameId1, saveFile1, gameId2, saveFile2) {
return (gameId1 === gameId2) && (saveFile1.name === saveFile2.name);
}
static upsertGameSaveFiles(existingGameSaveFiles, newGameSaveFiles) {
const existingCopy = existingGameSaveFiles.slice(0); // Shallow copy
// Merge in the new game save files into the existing game save files
// Uses an 'upsert' style operation where missing records are inserted, and existing records are updated
newGameSaveFiles.forEach((newGame) => {
const existingGameIdIndex = existingCopy.findIndex((existing) => existing.gameId === newGame.gameId);
// If the game isn't present at all in the existing files, then insert all the saves for it
if (existingGameIdIndex < 0) {
existingCopy.push(newGame);
} else {
// If the game is present, then go through each save file and either insert or update it
newGame.saveFiles.forEach((newSaveFile) => {
const existingSaveFileIndex = existingCopy[existingGameIdIndex].saveFiles.findIndex((existing) => existing.name === newSaveFile.name);
if (existingSaveFileIndex < 0) {
// If this save file does not exist for this game, then add it
existingCopy[existingGameIdIndex].saveFiles.push(newSaveFile);
} else {
// If this save file does exist for this game, then update it
existingCopy[existingGameIdIndex].saveFiles[existingSaveFileIndex] = newSaveFile;
}
});
}
});
return existingCopy;
}
static isInternalSarooData(arrayBuffer) {
try {
SarooSegaSaturnInternalSaveData.createFromSarooData(arrayBuffer);
return true;
} catch (e) {
return false;
}
}
static createFromSarooData(arrayBuffer) {
Util.checkMagic(arrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
const allSlotNums = ArrayUtil.createSequentialArray(NUM_RESERVED_SLOTS, NUM_AVAILABLE_SLOTS);
const firstInvalidSlotIndex = allSlotNums.findIndex((slotNum) => !slotContainsValidSaves(slotNum, arrayBuffer));
const validSlotNums = (firstInvalidSlotIndex >= 0) ? allSlotNums.slice(0, firstInvalidSlotIndex) : allSlotNums;
const gameSaveFiles = validSlotNums.map((slotNum) => getSaveFiles(slotNum, arrayBuffer));
const volumeInfo = getVolumeInfo(arrayBuffer);
return new SarooSegaSaturnInternalSaveData(arrayBuffer, gameSaveFiles, volumeInfo);
}
static createFromSaveFiles(gameSaveFiles) {
if (gameSaveFiles.length > NUM_AVAILABLE_SLOTS) {
throw new Error(`Too many games to fit in file: found ${gameSaveFiles.length} different games, but can only store ${NUM_AVAILABLE_SLOTS}`);
}
const reservedSlot = createReservedSlot(gameSaveFiles);
const gameSlots = gameSaveFiles.map((gameInfo) => createGameSlot(gameInfo));
const arrayBuffer = Util.concatArrayBuffers([reservedSlot, ...gameSlots]);
const volumeInfo = getVolumeInfo(arrayBuffer);
return new SarooSegaSaturnInternalSaveData(arrayBuffer, gameSaveFiles, volumeInfo);
}
// This constructor creates a new object from a binary representation of Sega Saturn save data
constructor(arrayBuffer, gameSaveFiles, volumeInfo) {
this.arrayBuffer = arrayBuffer;
this.gameSaveFiles = gameSaveFiles;
this.saveFiles = gameSaveFiles.map((gameInfo) => gameInfo.saveFiles).flat();
this.volumeInfo = volumeInfo;
}
getGameSaveFiles() {
return this.gameSaveFiles;
}
getSaveFiles() {
return this.saveFiles;
}
getVolumeInfo() {
return this.volumeInfo;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/Saroo/System.js
================================================
/*
Prior to firmware 0.7, the Saroo writes out a copy of the Saturn's internal memory to SS_BUP.BIN when the user goes into the CD player
https://github.com/tpunix/SAROO/issues/232#issuecomment-2574200546
The format is the same as the emulator format for internal saves, except it's byte expanded with 0xFF
*/
import SegaSaturnSaveData from '../SegaSaturn';
import GenesisUtil from '../../../util/Genesis';
const PADDING_VALUE = 0xFF;
export default class SarooSegaSaturnSystemSaveData {
static createWithNewSize(/* segaSaturnSaveData, newSize */) {
/*
const newRawSaveData = SegaSaturnUtil.resize(segaSaturnSaveData.getArrayBuffer(), newSize);
return SegaSaturnSaveData.createFromSegaSaturnData(newRawSaveData);
*/
}
static isSystemSarooData(arrayBuffer) {
try {
SarooSegaSaturnSystemSaveData.createFromSarooData(arrayBuffer);
return true;
} catch (e) {
return false;
}
}
static createFromSarooData(arrayBuffer) {
const internalArrayBuffer = GenesisUtil.byteCollapse(arrayBuffer);
return SegaSaturnSaveData.createFromSegaSaturnData(internalArrayBuffer);
}
static createFromSaveFiles(saveFiles) {
const segaSaturnSaveData = SegaSaturnSaveData.createFromSaveFiles(saveFiles, SegaSaturnSaveData.INTERNAL_BLOCK_SIZE);
return new SegaSaturnSaveData(
GenesisUtil.byteExpand(segaSaturnSaveData.getArrayBuffer(), PADDING_VALUE),
segaSaturnSaveData.getSaveFiles(),
segaSaturnSaveData.getVolumeInfo(),
);
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/Saroo/Util.js
================================================
/* eslint-disable no-bitwise */
import ArrayUtil from '../../../util/Array';
import Util from '../../../util/util';
function getByteAndBitForBlockNum(blockNum) {
return {
byteNum: Math.floor(blockNum / 8),
bitNum: blockNum % 8,
};
}
function isBlockOccupied(blockNum, bitmapUint8Array) {
const { byteNum, bitNum } = getByteAndBitForBlockNum(blockNum);
return ((bitmapUint8Array[byteNum] & (1 << bitNum)) !== 0);
}
function setBlockOccupied(blockNum, bitmapUint8Array) {
const { byteNum, bitNum } = getByteAndBitForBlockNum(blockNum);
if (byteNum > bitmapUint8Array.length) {
throw new Error(`Cannot address block number ${blockNum} in an occupancy bitmap of size ${bitmapUint8Array.length} bytes`);
}
bitmapUint8Array[byteNum] |= (1 << bitNum); // eslint-disable-line no-param-reassign
}
export default class SegaSaturnSarooUtil {
static getBlockOccupancy(bitmapArrayBuffer, totalSize, blockSize) {
const bitmapUint8Array = new Uint8Array(bitmapArrayBuffer);
const numBlocks = Math.min(totalSize / blockSize, bitmapArrayBuffer.byteLength * 8);
const blockOccupancy = ArrayUtil.createSequentialArray(0, numBlocks).map((blockNum) => isBlockOccupied(blockNum, bitmapUint8Array));
const usedBlocks = blockOccupancy.reduce((blockList, blockIsOccupied, blockNum) => {
if (blockIsOccupied) {
blockList.push(blockNum);
}
return blockList;
}, []);
return {
blockOccupancy,
usedBlocks,
};
}
static createBlockOccupancyBitmap(usedBlocks, bitmapSize) {
const bitmapArrayBuffer = Util.getFilledArrayBuffer(bitmapSize, 0x00);
const bitmapUint8Array = new Uint8Array(bitmapArrayBuffer);
usedBlocks.forEach((blockNum) => setBlockOccupied(blockNum, bitmapUint8Array));
return bitmapArrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/SegaSaturn.js
================================================
/* eslint-disable no-bitwise */
/*
This is based on a description of the Saturn Archive Format from the Saroo repo:
https://github.com/tpunix/SAROO/blob/6f6e18289bbdc9b23b4c91b9da343a1362ed921c/doc/SAROO%E6%8A%80%E6%9C%AF%E7%82%B9%E6%BB%B4.txt#L448
which was translated here:
https://www.reddit.com/r/SegaSaturn/comments/1acty0v/comment/kjz73ft/
Date format from: https://segaxtreme.net/threads/backup-memory-structure.16803/post-156645
Unlike the Sega CD and other consoles like the PS1 and N64, there is no directory at the beginning of the memory. So the entire file must
be parsed to get a list of all of the saves it contains and to get a list of occupied blocks.
The file is divided into equal-sized blocks. Everything is big endian.
The first 2 blocks are reserved. The first block is the string 'BackUpRam Format' repeated over and over. The BIOS writes out 0x40
bytes of this regardless of whether the block size is 0x40 or 0x200 bytes. mednafen manually fills the entire first block (either
0x40 or 0x200 bytes), presumably to allow the reader to infer the block size of the file by counting the number of repetitions of that string.
We determine the block size of the file by looking at the file length.
The second block is all 0x00.
From there, blocks are of 2 types: archive entry blocks begin with 0x80000000, and data entry blocks begin with 0x00000000
For an archive entry block, the format is as follows:
0x00 - 0x03: Block type (0x80000000)
0x04 - 0x0E: Archive name (null-terminated, encoded as US-ASCII)
0x0F: Language flag
0x10 - 0x19: Comment (null-terminated, encoded as shift-jis)
0x1A - 0x1D: Date (encoded as number of minutes since Jan 1, 1980)
0x1E - 0x21: Save size in bytes
0x22 - ????: List of 2-byte block numbers containing save data for this entry. Ends with 0x0000. See notes below.
???? - end: Beginning of save data for this entry
For a data entry block, the format is as follows:
0x00 - 0x03: Block type (0x00000000)
0x04 - end: save data
Note that the save size does not include space for the block type flag at the beginning of each block.
Block numbers list notes:
- If the total save data size is < the remaining block size, the marker at 0x22 is set to ARCHIVE_ENTRY_BLOCK_LIST_END.
Some sources imply that the marker at 0x22 is missing altogether, which is incorrect: the block list is merely empty.
- If the block numbers list doesn't fit within the remaining block size, then it continues at the first, second, etc block specified in the block list.
i.e. the blocks containing the continuation of the block list are specified as part of the block list. This has the effect of ballooning the size
of a large save with a small block size: it needs extra blocks to contain the portion of the block list specifying where the block list is.
At a larger block size, there's more room for the first block to contain the entire block list, which is also in turn shorter because of the larger blocks.
The blocks containing the block list are regular data blocks with 0x00000000 in bytes 0x00 - 0x03.
*/
import SegaSaturnUtil from './Util';
import Util from '../../util/util';
const LITTLE_ENDIAN = false;
const MAGIC = 'BackUpRam Format';
const MAGIC_ENCODING = 'US-ASCII';
const MIN_LENGTH_OF_REPEATING_MAGIC = 0x40;
const TOTAL_BLOCKS = new Map([ // Total number of blocks in a save file, indexed by block size
[0x40, 512],
[0x200, 1024],
]);
const POSSIBLE_BLOCK_SIZES = Array.from(TOTAL_BLOCKS.keys());
const INTERNAL_SAVE_SIZE = TOTAL_BLOCKS.get(POSSIBLE_BLOCK_SIZES[0]) * POSSIBLE_BLOCK_SIZES[0];
const CARTRIDGE_SAVE_SIZE = TOTAL_BLOCKS.get(POSSIBLE_BLOCK_SIZES[1]) * POSSIBLE_BLOCK_SIZES[1];
const RESERVED_BLOCKS = [0, 1];
const BLOCK_TYPE_OFFSET = 0x00;
const BLOCK_TYPE_ARCHIVE_ENTRY = 0x80000000;
const BLOCK_TYPE_DATA = 0x00000000;
const DATA_BLOCK_DATA_OFFSET = 0x04;
const ARCHIVE_ENTRY_NAME_OFFSET = 0x04;
const ARCHIVE_ENTRY_NAME_LENGTH = 11;
const ARCHIVE_ENTRY_NAME_ENCODING = 'US-ASCII';
const ARCHIVE_ENTRY_LANGUAGE_OFFSET = 0x0F;
const ARCHIVE_ENTRY_COMMENT_OFFSET = 0x10;
const ARCHIVE_ENTRY_COMMENT_LENGTH = 10;
const ARCHIVE_ENTRY_COMMENT_ENCODING = 'shift-jis';
const ARCHIVE_ENTRY_DATE_OFFSET = 0x1A;
const ARCHIVE_ENTRY_SAVE_SIZE_OFFSET = 0x1E;
const ARCHIVE_ENTRY_BLOCK_LIST_OFFSET = 0x22;
const ARCHIVE_ENTRY_BLOCK_LIST_END = 0x0000;
const ARCHIVE_ENTRY_BLOCK_LIST_ENTRY_SIZE = 2; // Each entry in the block list is a uint16
function getBlockSize(arrayBuffer) {
// Get our block size from looking at the size of the file
let blockSize = 0;
switch (arrayBuffer.byteLength) {
case INTERNAL_SAVE_SIZE: {
blockSize = POSSIBLE_BLOCK_SIZES[0]; // eslint-disable-line prefer-destructuring
break;
}
case CARTRIDGE_SAVE_SIZE: {
blockSize = POSSIBLE_BLOCK_SIZES[1]; // eslint-disable-line prefer-destructuring
break;
}
default:
throw new Error(`Invalid file length of ${arrayBuffer.byteLength}. Cannot infer block size`);
}
return blockSize;
}
function checkHeader(arrayBuffer, blockSize) {
// First block contains the MAGIC repeated for 0x40 bytes or longer
// Second block is all 0x00
if (arrayBuffer.byteLength < (blockSize * RESERVED_BLOCKS.length)) {
throw new Error('This does not appear to be a valid Sega Saturn save file: it is not long enough to contain the required reserved blocks');
}
// Check our first block
let currentOffset = 0;
while (currentOffset < MIN_LENGTH_OF_REPEATING_MAGIC) {
try {
Util.checkMagic(arrayBuffer, currentOffset, MAGIC, MAGIC_ENCODING);
} catch (e) {
break;
}
currentOffset += MAGIC.length;
}
if (currentOffset === 0) {
throw new Error('This does not appear to be a valid Sega Saturn save file: couldn\'t find any magic.');
}
if (currentOffset < MIN_LENGTH_OF_REPEATING_MAGIC) {
throw new Error('This does not appear to be a valid Sega Saturn save file: didn\'t find enough magic.');
}
// Check our second block
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = blockSize; i < (blockSize * 2); i += 1) {
if (uint8Array[i] !== 0x00) {
throw new Error('This does not appear to be a valid Sega Saturn save file: the second block is not all 0x00');
}
}
return blockSize;
}
function getBlock(arrayBuffer, blockSize, blockNumber) {
return arrayBuffer.slice(blockNumber * blockSize, (blockNumber + 1) * blockSize);
}
function makeEmptyBlock(blockSize) {
return Util.getFilledArrayBuffer(blockSize, 0x00);
}
function makeSequentialArray(startValue, numValues) {
return Array(numValues).fill().map((_, index) => index + startValue);
}
function makeReservedBlocks(blockSize) {
let reservedBlock0 = makeEmptyBlock(blockSize);
let currentOffset = 0;
while (currentOffset < reservedBlock0.byteLength) {
reservedBlock0 = Util.setMagic(reservedBlock0, currentOffset, MAGIC, MAGIC_ENCODING);
currentOffset += MAGIC.length;
}
return [reservedBlock0, makeEmptyBlock(blockSize)];
}
function getVolumeInfo(segaSaturnArrayBuffer, blockSize, usedBlocks) {
const totalNumBlocks = segaSaturnArrayBuffer.byteLength / blockSize;
return {
blockSize,
totalBytes: segaSaturnArrayBuffer.byteLength,
totalBlocks: totalNumBlocks - RESERVED_BLOCKS.length,
usedBlocks: usedBlocks.length,
freeBlocks: totalNumBlocks - usedBlocks.length - RESERVED_BLOCKS.length,
};
}
function readSaveFiles(arrayBuffer, blockSize) {
const totalNumBlocks = (arrayBuffer.byteLength / blockSize);
const saveFiles = [];
let usedBlocks = [];
let searchBlockNumber = RESERVED_BLOCKS.length; // The block number where we are currently searching for an archive entry
while (searchBlockNumber < totalNumBlocks) {
let currentBlock = getBlock(arrayBuffer, blockSize, searchBlockNumber);
let currentBlockUint8Array = new Uint8Array(currentBlock);
let currentBlockDataView = new DataView(currentBlock);
if (currentBlockDataView.getUint32(BLOCK_TYPE_OFFSET, LITTLE_ENDIAN) === BLOCK_TYPE_ARCHIVE_ENTRY) {
usedBlocks.push(searchBlockNumber);
const name = Util.readNullTerminatedString(currentBlockUint8Array, ARCHIVE_ENTRY_NAME_OFFSET, ARCHIVE_ENTRY_NAME_ENCODING, ARCHIVE_ENTRY_NAME_LENGTH);
const languageCode = currentBlockDataView.getUint8(ARCHIVE_ENTRY_LANGUAGE_OFFSET);
const comment = Util.readNullTerminatedString(currentBlockUint8Array, ARCHIVE_ENTRY_COMMENT_OFFSET, ARCHIVE_ENTRY_COMMENT_ENCODING, ARCHIVE_ENTRY_COMMENT_LENGTH);
const dateCode = currentBlockDataView.getUint32(ARCHIVE_ENTRY_DATE_OFFSET, LITTLE_ENDIAN);
const saveSize = currentBlockDataView.getUint32(ARCHIVE_ENTRY_SAVE_SIZE_OFFSET, LITTLE_ENDIAN);
const blockList = [];
let blockListEntryOffset = ARCHIVE_ENTRY_BLOCK_LIST_OFFSET;
let nextBlockNumber = currentBlockDataView.getUint16(blockListEntryOffset, LITTLE_ENDIAN); // Note that this is always present, even when the entire save file fits within the starting block. In that case, this value is set to ARCHIVE_ENTRY_BLOCK_LIST_END
let blockListReadingIndex = 0; // If the block list doesn't fit within the starting block, it continues in the blocks specified in the block list
while (nextBlockNumber !== ARCHIVE_ENTRY_BLOCK_LIST_END) {
blockList.push(nextBlockNumber);
blockListEntryOffset += 2;
if (blockListEntryOffset >= blockSize) {
currentBlock = getBlock(arrayBuffer, blockSize, blockList[blockListReadingIndex]);
blockListReadingIndex += 1;
currentBlockUint8Array = new Uint8Array(currentBlock);
currentBlockDataView = new DataView(currentBlock);
blockListEntryOffset = DATA_BLOCK_DATA_OFFSET;
const blockType = currentBlockDataView.getUint32(BLOCK_TYPE_OFFSET, LITTLE_ENDIAN);
if (blockType !== BLOCK_TYPE_DATA) {
throw new Error(`Found block type 0x${Buffer.from(blockType).toString('hex')} where there should be a data block that continues a block list`);
}
}
nextBlockNumber = currentBlockDataView.getUint16(blockListEntryOffset, LITTLE_ENDIAN);
}
usedBlocks = usedBlocks.concat(blockList);
// The data segments are the remainder of the current block, plus all of the blocks listed in the block list
// Note that if the last block list entry was right at the end of currentBlock, the slice below will correctly return a zero-length ArrayBuffer
const dataBlockList = blockList.slice(blockListReadingIndex); // Remove the blocks used to specify the block list
const dataSegments = [currentBlock.slice(blockListEntryOffset + 2)].concat(dataBlockList.map((blockNumber) => {
const block = getBlock(arrayBuffer, blockSize, blockNumber);
const blockDataView = new DataView(block);
const blockType = blockDataView.getUint32(BLOCK_TYPE_OFFSET, LITTLE_ENDIAN);
if (blockType !== BLOCK_TYPE_DATA) {
throw new Error(`Found block type 0x${Buffer.from(blockType).toString('hex')} where there should be a data block`);
}
return block.slice(DATA_BLOCK_DATA_OFFSET);
}));
const rawData = Util.concatArrayBuffers(dataSegments).slice(0, saveSize); // We may have appended too many bytes by appending the entire final block, so slice it down to the specified size
saveFiles.push({
name,
languageCode,
language: SegaSaturnUtil.getLanguageString(languageCode),
comment,
dateCode,
date: SegaSaturnUtil.getDate(dateCode),
blockList,
saveSize,
rawData,
});
}
searchBlockNumber += 1;
}
const volumeInfo = getVolumeInfo(arrayBuffer, blockSize, usedBlocks);
return {
saveFiles,
volumeInfo,
};
}
function getNumDataBlocksForSaveFile(saveFile, blockSize) {
// The difficulty in calculating this is that we need an unknown number of bytes to store the list of blocks,
// because the block list points to both the blocks that actually store the save data but also to the blocks
// that store the block list. So, the longer the block list is the more blocks are needed to store it,
// so the longer the block list is
// Also, some data fits in the first archive block
const numDataBytesInArchiveBlock = blockSize - ARCHIVE_ENTRY_BLOCK_LIST_OFFSET;
const numDataBytesInDataBlock = blockSize - DATA_BLOCK_DATA_OFFSET;
let blocksAddedForBlockList = 0;
let numBytesToStoreInDataBlocks = 0; // Our first approximation is that we need no data blocks for this save
let approxDataSizeInBlocks = 0;
do {
const blockListSizeBytes = (approxDataSizeInBlocks + 1) * ARCHIVE_ENTRY_BLOCK_LIST_ENTRY_SIZE; // Need +1 for the end of list entry
numBytesToStoreInDataBlocks = Math.max(saveFile.rawData.byteLength + blockListSizeBytes - numDataBytesInArchiveBlock, 0);
const newApproxBlockListSizeInBlocks = Math.ceil(numBytesToStoreInDataBlocks / numDataBytesInDataBlock);
blocksAddedForBlockList = newApproxBlockListSizeInBlocks - approxDataSizeInBlocks;
approxDataSizeInBlocks = newApproxBlockListSizeInBlocks;
} while (blocksAddedForBlockList > 0);
return approxDataSizeInBlocks;
}
function getBlocksForSaveFile(saveFile, blockSize, startingBlockNumber) {
// First, fill in all of the fields in the archive entry block
let archiveEntryBlock = makeEmptyBlock(blockSize);
archiveEntryBlock = Util.setString(archiveEntryBlock, ARCHIVE_ENTRY_NAME_OFFSET, saveFile.name, ARCHIVE_ENTRY_NAME_ENCODING, ARCHIVE_ENTRY_NAME_LENGTH);
archiveEntryBlock = Util.setString(archiveEntryBlock, ARCHIVE_ENTRY_COMMENT_OFFSET, saveFile.comment, ARCHIVE_ENTRY_COMMENT_ENCODING, ARCHIVE_ENTRY_COMMENT_LENGTH);
const archiveEntryBlockDataView = new DataView(archiveEntryBlock);
archiveEntryBlockDataView.setUint32(BLOCK_TYPE_OFFSET, BLOCK_TYPE_ARCHIVE_ENTRY, LITTLE_ENDIAN);
archiveEntryBlockDataView.setUint8(ARCHIVE_ENTRY_LANGUAGE_OFFSET, saveFile.languageCode);
archiveEntryBlockDataView.setUint32(ARCHIVE_ENTRY_DATE_OFFSET, saveFile.dateCode, LITTLE_ENDIAN);
archiveEntryBlockDataView.setUint32(ARCHIVE_ENTRY_SAVE_SIZE_OFFSET, saveFile.rawData.byteLength, LITTLE_ENDIAN);
// Now create all of our blocks that contain the block list
const numDataBlocksRequired = getNumDataBlocksForSaveFile(saveFile, blockSize);
const saveFileBlocks = [];
let currentDataBlockIndex = 0;
let currentBlock = archiveEntryBlock;
let currentBlockDataView = new DataView(currentBlock);
let currentBlockOffset = ARCHIVE_ENTRY_BLOCK_LIST_OFFSET;
while (currentDataBlockIndex < numDataBlocksRequired) {
currentBlockDataView.setUint16(currentBlockOffset, currentDataBlockIndex + startingBlockNumber + 1, LITTLE_ENDIAN); // +1 because of the archive entry block
currentBlockOffset += ARCHIVE_ENTRY_BLOCK_LIST_ENTRY_SIZE;
if (currentBlockOffset >= blockSize) {
saveFileBlocks.push(currentBlock);
currentBlock = makeEmptyBlock(blockSize);
currentBlockDataView = new DataView(currentBlock);
currentBlockDataView.setUint32(BLOCK_TYPE_OFFSET, BLOCK_TYPE_DATA, LITTLE_ENDIAN);
currentBlockOffset = DATA_BLOCK_DATA_OFFSET;
}
currentDataBlockIndex += 1;
}
// Add an end of list marker
currentBlockDataView.setUint16(currentBlockOffset, ARCHIVE_ENTRY_BLOCK_LIST_END, LITTLE_ENDIAN);
currentBlockOffset += ARCHIVE_ENTRY_BLOCK_LIST_ENTRY_SIZE;
// Now create all of the blocks that contain the save data
let currentOffsetInSaveData = 0;
while (currentOffsetInSaveData < saveFile.rawData.byteLength) {
if (currentBlockOffset >= blockSize) {
saveFileBlocks.push(currentBlock);
currentBlock = makeEmptyBlock(blockSize);
currentBlockDataView = new DataView(currentBlock);
currentBlockDataView.setUint32(BLOCK_TYPE_OFFSET, BLOCK_TYPE_DATA, LITTLE_ENDIAN);
currentBlockOffset = DATA_BLOCK_DATA_OFFSET;
}
const saveFileNumBytesToCopyToBlock = Math.min(saveFile.rawData.byteLength, blockSize - currentBlockOffset);
currentBlock = Util.setArrayBufferPortion(currentBlock, saveFile.rawData, currentBlockOffset, currentOffsetInSaveData, saveFileNumBytesToCopyToBlock);
currentOffsetInSaveData += saveFileNumBytesToCopyToBlock;
currentBlockOffset += saveFileNumBytesToCopyToBlock;
}
saveFileBlocks.push(currentBlock);
// All done!
return saveFileBlocks;
}
export default class SegaSaturnSaveData {
static INTERNAL_BLOCK_SIZE = POSSIBLE_BLOCK_SIZES[0];
static CARTRIDGE_BLOCK_SIZE = POSSIBLE_BLOCK_SIZES[1];
static INTERNAL_SAVE_SIZE = INTERNAL_SAVE_SIZE
static CARTRIDGE_SAVE_SIZE = CARTRIDGE_SAVE_SIZE
static ARCHIVE_ENTRY_NAME_LENGTH = ARCHIVE_ENTRY_NAME_LENGTH;
static ARCHIVE_ENTRY_NAME_ENCODING = ARCHIVE_ENTRY_NAME_ENCODING;
static ARCHIVE_ENTRY_COMMENT_LENGTH = ARCHIVE_ENTRY_COMMENT_LENGTH;
static ARCHIVE_ENTRY_COMMENT_ENCODING = ARCHIVE_ENTRY_COMMENT_ENCODING;
static createEmptySave(blockSize) {
return SegaSaturnSaveData.createFromSaveFiles([], blockSize).getArrayBuffer();
}
static isCorrectlyFormatted(arrayBuffer) {
try {
SegaSaturnSaveData.createFromSegaSaturnData(arrayBuffer);
return true;
} catch (e) {
return false;
}
}
static createWithNewSize(/* segaSaturnSaveData, newSize */) {
/*
const newRawSaveData = SegaSaturnUtil.resize(segaSaturnSaveData.getArrayBuffer(), newSize);
return SegaSaturnSaveData.createFromSegaSaturnData(newRawSaveData);
*/
}
static createFromSegaSaturnData(arrayBuffer, forceBlockSize) {
let blockSize = forceBlockSize;
if (forceBlockSize === undefined) {
blockSize = getBlockSize(arrayBuffer);
}
checkHeader(arrayBuffer, blockSize);
const { saveFiles, volumeInfo } = readSaveFiles(arrayBuffer, blockSize);
return new SegaSaturnSaveData(arrayBuffer, saveFiles, volumeInfo);
}
static createFromSaveFiles(saveFiles, blockSize, forceFileSize) {
if (POSSIBLE_BLOCK_SIZES.find((possibleBlockSize) => possibleBlockSize === blockSize) === undefined) {
throw new Error(`Cannot create Saturn save file: ${blockSize} bytes is not a valid block size`);
}
// Transform our save files into blocks
let currentBlockNumber = RESERVED_BLOCKS.length;
const saveFilesBlocks = saveFiles.map((saveFile) => {
const blocksForSaveFile = getBlocksForSaveFile(saveFile, blockSize, currentBlockNumber);
currentBlockNumber += blocksForSaveFile.length;
return blocksForSaveFile;
}).flat();
// Figure out how many blocks we need to use
let totalNumBlocks = TOTAL_BLOCKS.get(blockSize);
if (forceFileSize !== undefined) {
totalNumBlocks = forceFileSize / blockSize;
}
const blockList = makeReservedBlocks(blockSize).concat(saveFilesBlocks);
if (blockList.length > totalNumBlocks) {
throw new Error(`Not enough space to hold all saves. Requires ${saveFilesBlocks.length} blocks and only has space for ${totalNumBlocks - RESERVED_BLOCKS.length} blocks`);
}
const usedBlocks = makeSequentialArray(RESERVED_BLOCKS.length, saveFilesBlocks.length);
// Append empty blocks until we have enough total blocks
while (blockList.length < totalNumBlocks) {
blockList.push(makeEmptyBlock(blockSize));
}
// Now that we have all of our blocks, we can assemble them to make our save file image
const segaSaturnArrayBuffer = Util.concatArrayBuffers(blockList);
const volumeInfo = getVolumeInfo(segaSaturnArrayBuffer, blockSize, usedBlocks);
return new SegaSaturnSaveData(segaSaturnArrayBuffer, saveFiles, volumeInfo);
}
constructor(arrayBuffer, saveFiles, volumeInfo) {
this.arrayBuffer = arrayBuffer;
this.saveFiles = saveFiles;
this.volumeInfo = volumeInfo;
}
getSaveFiles() {
return this.saveFiles;
}
getVolumeInfo() {
return this.volumeInfo;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/save-formats/SegaSaturn/Util.js
================================================
// Taken from https://github.com/slinga-homebrew/Save-Game-BUP-Scripts/blob/main/bup_header.h#L31
const LANGUAGE_DECODE = new Map([
[0, 'Japanese'],
[1, 'English'],
[2, 'French'],
[3, 'German'],
[4, 'Spanish'],
[5, 'Italian'],
]);
const UNKNOWN_LANGUAGE_STRING = 'Unknown';
const UNKNOWN_LANGUAGE_CODE = 0xFF;
const POSSIBLE_LANGUAGE_CODES = Array.from(LANGUAGE_DECODE.keys());
// The epoch for Javascript Dates is Jan 1, 1970. For Saturn dates, it's Jan 1, 1980
const MILLISECONDS_BETWEEN_EPOCHS = 315529200000;
export default class SegaSaturnUtil {
static getLanguageString(languageEncoded) {
if (LANGUAGE_DECODE.has(languageEncoded)) {
return LANGUAGE_DECODE.get(languageEncoded);
}
// There are save files where the comment field is too long and stomps on the language and date fields
// So we have to consider this to be valid
return UNKNOWN_LANGUAGE_STRING;
}
static getLanguageCode(languageString) {
const languageEncoded = POSSIBLE_LANGUAGE_CODES.find((key) => LANGUAGE_DECODE.get(key) === languageString);
if (languageEncoded === undefined) {
return UNKNOWN_LANGUAGE_CODE;
}
return languageEncoded;
}
static getDate(dateEncoded) {
// Date conversion from: https://segaxtreme.net/threads/backup-memory-structure.16803/post-156645
// The Saturn stores the date as the number of minutes since Jan 1, 1980. So to convert to a javascript Date,
// we multiply to get milliseconds, and add the number of milliseconds between Jan 1, 1970 and Jan 1, 1980
return new Date((dateEncoded * 60 * 1000) + MILLISECONDS_BETWEEN_EPOCHS);
}
static getDateCode(date) {
return (date.valueOf() - MILLISECONDS_BETWEEN_EPOCHS) / 1000 / 60;
}
}
================================================
FILE: frontend/src/save-formats/Wii/ConvertFrom/ConvertFromN64.js
================================================
// Converts from the N64 format on the Wii NAND to something that's usable by N64 emulators like
// Mupen64Plus.
//
// Based on
// - https://github.com/JanErikGunnar/vcromclaim/blob/master/wiimetadata.py#L505
// - https://github.com/JanErikGunnar/vcromclaim/blob/master/n64save.py
//
// Looks like there's 2 possible save media for N64 games: SRAM and EEPROM, and different rules
// for how to interpret each one into something usable by an emulator
// Check out this list of known saving types for N64 games:
// - http://micro-64.com/database/gamesave.shtml
//
// It lists:
// - Controller Pak: 32kB SRAM
// - 0.5kB EEPROM
// - 2kB EEPROM
// - 32kB SRAM
// - 128kB Flash RAM
// - 96kB SRAM (one game only: Dezaemon 3D, which wasn't on Virtual Console and was Japan-only)
import N64Util from '../../../util/N64';
const EEPROM_SIZES = [4 * 1024, 16 * 1024];
const SRAM_SIZES_SRA = [32 * 1024];
const SRAM_SIZES_FLA = [128 * 1024, 256 * 1024];
const SRAM_SIZES = SRAM_SIZES_SRA.concat(SRAM_SIZES_FLA);
const ALL_SIZES = EEPROM_SIZES.concat(SRAM_SIZES);
function convertSram(arrayBuffer) {
let fileExtension = null;
// Choose the file extension
if (SRAM_SIZES_SRA.indexOf(arrayBuffer.byteLength) >= 0) {
fileExtension = 'sra';
} else if (SRAM_SIZES_FLA.indexOf(arrayBuffer.byteLength) >= 0) {
fileExtension = 'fla';
} else {
throw new Error(`Unknown N64 SRAM file size = ${arrayBuffer.byteLength} bytes`);
}
// Byte swap from big endian to little endian
return {
saveData: N64Util.endianSwap(arrayBuffer),
fileExtension,
};
}
function convertEeprom(arrayBuffer) {
return {
saveData: arrayBuffer.slice(0, 2048),
fileExtension: 'eep',
};
}
export default (arrayBuffer, fileName) => {
let truncatedArrayBuffer = arrayBuffer;
// All N64 games are prefixed either 'EEP_' or 'RAM_' with the game ID afterward
//
// RAM_ is for SRAM games
// EEP_ is for everything else (2 sizes of EEPROM, and also FLashRAM)
//
// For Mario Golf specifically, we need to truncate the file size. Other SRAM games like F-Zero X are fine.
//
// https://forums.dolphin-emu.org/archive/index.php?thread-35067-95.html
//
// NMFE is the game ID for Mario Golf 64 in North America. This game was released on Wii VC
// in PAL regions and Japan as well: is the filename different in those saves?
// Should we rework the logic to look for "RAM_*" filenames and just do the same handling?
if (fileName === 'RAM_NMFE') {
for (let i = ALL_SIZES.length - 1; i >= 0; i -= 1) {
if (ALL_SIZES[i] < arrayBuffer.byteLength) {
truncatedArrayBuffer = arrayBuffer.slice(0, ALL_SIZES[i]);
break;
}
}
}
// In the code we got this from, they decide the type of file based on its size.
// However, the filename stored inside the Wii save also seems to hint at the type.
// We're going to use the size for now just because that's how the other code did it,
// but if it leads to problems then consider keying off the name instead.
//
// Hmmm Paper Mario uses Flash RAM, and so needs a .fla extension, but the filename begins with "EEP_".
// Mario Kart 64 uses a Controller Pak SRAM save, but the filename also begins with "EEP_".
if (EEPROM_SIZES.indexOf(truncatedArrayBuffer.byteLength) >= 0) {
return convertEeprom(truncatedArrayBuffer);
}
if (SRAM_SIZES.indexOf(truncatedArrayBuffer.byteLength) >= 0) {
return convertSram(truncatedArrayBuffer);
}
throw new Error(`Unknown N64 save type with size = ${arrayBuffer.byteLength} bytes`);
};
================================================
FILE: frontend/src/save-formats/Wii/ConvertFrom/ConvertFromPcEngine.js
================================================
/* eslint no-bitwise: ["error", { "allow": ["^", "^=", "~"] }] */
// Converts from the PC Engine format on the Wii NAND to something that's usable by PC Engine emulators
//
// The Wii files appear to be 8kB + 32B whereas 'real' files are 2kB. The Wii files appear to have a 16B header and a 16B footer.
//
// Format reverse engineered by https://github.com/JanErikGunnar
//
// These files appear to be purposefully obfuscated for some reason. To decode them, work in blocks of 4 bytes and xor each block
// with bitwise-not the block before it. There is a seed block in the header for use with the first block of data.
//
// Note that this looks a lot like Cipher Block Chaining (CBC), with the seed block as an initialization vector: https://searchsecurity.techtarget.com/definition/cipher-block-chaining
//
// Additional resources:
//
// 1) More information about PC Engine saving: https://blackfalcongames.net/?p=190
// 2) More information about the PC Engine Backup RAM format: http://blockos.github.io/HuDK/doc/files/include/bram-s.html
// 3) Info about how BRAM is mapped into the console's memory space: https://github.com/asterick/TurboSharp/blob/master/Text/pcetech.txt#L1379
// 4) Info about how memory is mapped to the CPU memory space: https://www.lorenzomoretti.com/wp-content/files/chapter_0.txt
// 5) Homebrew tool for exploring BRAM: https://pdroms.de/files/nec-turbografx16-tg16-pcengine-pce/bram-tool-v1-0
//
// According to 2), BRAM is mapped to the CPU memory space using MPR4, which according to 4) puts it at 0x8000 - 0x8800 (0x8000 + 2kB).
// We see the value 0x8800 at offset 4-5 in output from emulators, as expected from the description for those bytes in 2).
// However, the Wii files are 8kB in size and correspondingly they write 0xA000 (0x8000 + 8 kB) to those bytes in their output.
// So when we truncate the file to 2kB we need to update those values as well.
import Util from '../../../util/util';
const LITTLE_ENDIAN = true;
const BLOCK_SIZE = 4;
const BRAM_MEMORY_ADDRESS = 0x8000; // The address in the CPU memory space that the BRAM is mapped to (see above)
const BRAM_SIZE = 2048; // In bytes. On a real PCE many games would share this memory. Most emulators, though, create a new virtual BRAM for each game
const POINTER_TO_FIRST_BYTE_AFTER_BRAM_OFFSET = 4; // Offset in BRAM of the pointer to the first byte in CPU memory after BRAM
const HEADER_LENGTH = 16;
const SEED_START = 4;
const FOOTER_LENGTH = 16;
const MAGIC = 'HUBM'; // Marker at the beginning that signifies correctly-formatted BRAM
const MAGIC_ENCODING = 'US-ASCII';
const MAGIC_OFFSET = 0;
function getBlock(array, currentByte) {
return array.slice(currentByte, currentByte + BLOCK_SIZE);
}
function xorBlocks(block1, block2) {
const output = new Uint8Array(BLOCK_SIZE);
for (let i = 0; i < BLOCK_SIZE; i += 1) {
output[i] = block1[i] ^ block2[i];
}
return output;
}
function notBlock(block) {
const output = new Uint8Array(BLOCK_SIZE);
for (let i = 0; i < BLOCK_SIZE; i += 1) {
output[i] = ~block[i];
}
return output;
}
export default (arrayBuffer) => {
const header = arrayBuffer.slice(0, HEADER_LENGTH);
const inputArrayBuffer = arrayBuffer.slice(HEADER_LENGTH, -FOOTER_LENGTH);
const inputArray = new Uint8Array(inputArrayBuffer);
if (inputArray.byteLength % BLOCK_SIZE !== 0) {
throw new Error(`PC Engine input data length ${inputArray.byteLength} is not a multiple of the block size ${BLOCK_SIZE}`);
}
const seedArrayBuffer = header.slice(SEED_START, SEED_START + BLOCK_SIZE);
const seedArray = new Uint8Array(seedArrayBuffer);
const outputArrayBuffer = new ArrayBuffer(inputArrayBuffer.byteLength);
const outputArray = new Uint8Array(outputArrayBuffer);
// Decode the data by xor'ing each block with NOT the previous block
let currentByte = 0;
let previousInputBlock = notBlock(seedArray); // Because we NOT our previous input block in the code below, but we want to use our seed as-is. So we need to pre-NOT it
while (currentByte < inputArray.byteLength) {
const inputBlock = getBlock(inputArray, currentByte);
const outputBlock = xorBlocks(inputBlock, notBlock(previousInputBlock));
outputArray.set(outputBlock, currentByte);
previousInputBlock = inputBlock;
currentByte += BLOCK_SIZE;
}
// Now check that we got actual PC Engine data
Util.checkMagic(outputArray, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
// Return data truncated to the correct size
// We need to change the pointer to the first byte after BRAM when we truncate the file. The Wii version of the file provides
// 8kB of BRAM (and so this pointer contains 0x8000 + 8192 = 0xA000). But for compatibility with other emulators we're going to
// truncate the file to 2kB: the size of BRAM in a normal PC Engine. So we'll change this value to 0x8000 + 2048 = 0x8800.
const outputDataView = new DataView(outputArrayBuffer);
outputDataView.setUint16(POINTER_TO_FIRST_BYTE_AFTER_BRAM_OFFSET, BRAM_MEMORY_ADDRESS + BRAM_SIZE, LITTLE_ENDIAN);
const truncatedSaveData = outputArrayBuffer.slice(BRAM_SIZE);
if (!Util.allBytesEqual(truncatedSaveData, 0x00)) {
throw new Error('Attempting to truncate save that contains extra data');
}
return {
saveData: outputArrayBuffer.slice(0, BRAM_SIZE),
fileExtension: 'sav',
};
};
================================================
FILE: frontend/src/save-formats/Wii/ConvertFrom/ConvertFromPlatform.js
================================================
/*
The Wii save files that we get from the SD card are copied from the Wii's internal NAND memory and then
encrypted/obfuscated before being written to the SD card.
Once we've decrypted them, we're left with the file as it was stored on the NAND. However, that file may still be
in a strange format that needs more massaging before it can be loaded by an emulator.
More information can be found here:
https://github.com/JanErikGunnar/vcromclaim/blob/master/wiimetadata.py#L473
*/
import ConvertFromN64 from './ConvertFromN64';
import ConvertFromSega from './ConvertFromSega';
import ConvertFromPcEngine from './ConvertFromPcEngine';
export default (arrayBuffer, fileName, platformType, gameId) => {
let output = null;
switch (platformType) {
case 'VC-NES':
output = {
saveData: arrayBuffer.slice(64), // NES games have a 64 byte header: https://github.com/JanErikGunnar/vcromclaim/blob/master/nes_extract.py#L242
fileExtension: 'sav',
};
break;
case 'VC-PCE':
output = ConvertFromPcEngine(arrayBuffer);
break;
case 'VC-MD':
case 'VC-SMS':
output = ConvertFromSega(arrayBuffer, platformType, gameId);
break;
case 'VC-N64':
output = ConvertFromN64(arrayBuffer, fileName);
break;
case 'VC-C64': // A few of these games offered saving, usually of high scores. Impossible Mission II (PAL regions) offered saving the game. I couldn't find any Wii VC save files online to test with
case 'VC-SNES': // https://github.com/JanErikGunnar/vcromclaim/blob/master/wiimetadata.py#L482
case 'VC-NEOGEO': // https://github.com/JanErikGunnar/vcromclaim/blob/master/wiimetadata.py#L501 Haven't been able to find any saves online to test this with. Partial (?) list of Neo Geo games that supported saves: https://www.nintendolife.com/forums/virtual_console/neogeo_games_that_allow_saves#reply-05
case 'VC-Arcade': // Do these even have saves?
case 'Wii':
case 'WiiWare':
case 'Homebrew':
case 'Unknown':
// Nothing needs to be done for these platforms
output = {
saveData: arrayBuffer,
fileExtension: 'sav',
};
break;
default:
throw new Error(`Unknown Wii platform: '${platformType}'`);
}
return output;
};
================================================
FILE: frontend/src/save-formats/Wii/ConvertFrom/ConvertFromSega.js
================================================
// Converts from the Sega Genesis and Master System format on the Wii NAND to something that can
// be used by an emulator like Gens/Kega Fusion
//
// Based on:
// - // https://github.com/JanErikGunnar/vcromclaim/blob/master/gensave.py
//
// The format is:
// 4 bytes: 'VCSD'
// 4 bytes: total size
// 4 or 5 bytes: magic number relating to the specific game the save is for
// 4 bytes: 'SRAM'
// 4 bytes: inner size
// if next bytes are 'compound data':
// 4 bytes: 'SRAM'
// 4 bytes: inner inner size
// 4 bytes: size of the save
// else the inner size above is the size of the save
// N bytes: save data
//
// The Genesis and Master System output is slightly different: just whether it's written out as
// shorts or bytes
import GenesisUtil from '../../../util/Genesis';
const MAGIC = 'VCSD';
const SRAM = 'SRAM';
const COMPOUND_DATA = 'compound data'.concat(String.fromCharCode(0, 0, 0));
const CHARSET = 'US-ASCII';
const LITTLE_ENDIAN = true; // Little endian file inside a big endian file (the original Wii file written to an SD card)
// I don't see a better way of determining whether a Genesis game has SRAM/EEPROM/FRAM saving than by
// just having a hardcoded list of game IDs. The Genesis save files all say "SRAM" in them.
// EEPROM list taken from https://krikzz.com/pub/support/everdrive-md/v2/gen_eeprom.pdf
// FRAM list taken from https://forum.digitpress.com/forum/showthread.php?134961-NES-SNES-Genny-Games-with-Battery-Back-up-Save-feature&p=1614576&viewfull=1#post1614576
// Note that "Wonder Boy in Monster World" and "Wonder Boy V - Moster World III" are the same game but in different regions
const GENESIS_EEPROM_GAME_IDS = [
// NBA Jam (UE)(J)
// Blockbuster World Video Game Championship II (U)
// NBA Jam Tournament Edition (UE)(J)
// NFL Quarterback Club (JUE)
// NFL Quarterback Club 96 (UE)
// College Slam (U)
// Frank Thomas Big Hurt Baseball (UE)
// NHLPA Hockey 93 (UE)
// Rings of Power (UE)
// Evander 'Real Deal' Holyfield's Boxing (UE)(J)
// Greatest Heavyweights of the Ring (J)(U)(E)
'MAVE', // Wonder Boy in Monster World (UE)
'MAVJ', // Wonder Boy V - Monster World III (J)
// The above game was released in PAL regions as well, but I can't find a code for that one on gametdb
// Sports Talk Baseball
// Megaman - The Wily Wars (E)
// Rockman Mega World (J) [alt]
// Micro Machines 2 - Turbo Tournament (E) (J-Cart)
// Micro Machines Military (E) (J-Cart)
// Micro Machines Turbo Tournament 96 (E) (J-Cart)
// Brian Lara Cricket 96
// Shane Warne Cricket
];
const GENESIS_FRAM_GAME_IDS = [
'MBME', // Sonic the Hedgehog 3 (FRAM) (NTSC)
'MBMP', // Sonic the Hedgehog 3 (FRAM) (PAL)
];
function seekEndOfString(desiredString, arrayBuffer, startingByteOffset, textDecoder) {
let currentByte = startingByteOffset;
while ((currentByte + desiredString.length) < arrayBuffer.byteLength) {
const textArray = new Uint8Array(arrayBuffer.slice(currentByte, currentByte + desiredString.length));
const text = textDecoder.decode(textArray);
if (text === desiredString) {
return currentByte + desiredString.length;
}
currentByte += 1;
}
return -1;
}
export default (arrayBuffer, platformType, gameId) => {
const textDecoder = new TextDecoder(CHARSET);
const dataView = new DataView(arrayBuffer);
let currentByte = 0;
// First look for our MAGIC string
currentByte = seekEndOfString(MAGIC, arrayBuffer, currentByte, textDecoder);
if (currentByte < 0) {
throw new Error(`Save appears corrupted: could not find magic string ${MAGIC}`);
}
// Then the total size
const totalSize = dataView.getUint32(currentByte, LITTLE_ENDIAN);
currentByte += 4;
// Next is the SRAM string, which skips over the game-specific magic head value which may be 4 or 5 bytes
const endOfSramByte = seekEndOfString(SRAM, arrayBuffer, currentByte, textDecoder);
if (endOfSramByte < 0) {
throw new Error(`Save appears corrupted: could not find string ${SRAM}`);
}
const headSize = endOfSramByte - currentByte - SRAM.length;
currentByte = endOfSramByte;
// Then is the inner size,
const innerSize = dataView.getUint32(currentByte, LITTLE_ENDIAN);
currentByte += 4;
if (innerSize !== (totalSize - headSize - 4)) {
throw new Error(`Save appears corrupted: found inner size ${innerSize} but total size found was ${totalSize} and head size was ${headSize}`);
}
let saveSize = innerSize;
// Next may be the "compound data" block which is another series of sizes. If it's missing,
// the inner size above is the actual save size and the save data is next
const endOfCompoundDataByte = seekEndOfString(COMPOUND_DATA, arrayBuffer, currentByte, textDecoder);
if (endOfCompoundDataByte >= 0) {
currentByte = endOfCompoundDataByte;
// We found a compound data block, so next we have the SRAM string again
const endOfSram2Byte = seekEndOfString(SRAM, arrayBuffer, currentByte, textDecoder);
if (endOfSram2Byte < 0) {
throw new Error(`Save appears corrupted: could not find string ${SRAM} inside compound data`);
}
currentByte = endOfSram2Byte;
// Then the inner inner size
const innerInnerSize = dataView.getUint32(currentByte, LITTLE_ENDIAN);
currentByte += 4;
if (innerInnerSize !== (innerSize - COMPOUND_DATA.length - SRAM.length - 4)) {
throw new Error(`Save appears corrupted: found inner inner size ${innerInnerSize} but inner size was ${innerSize}`);
}
// Then the actual save size
saveSize = dataView.getUint32(currentByte, LITTLE_ENDIAN);
currentByte += 4;
if (saveSize !== (innerInnerSize - 4)) {
throw new Error(`Save appears corrupted: found save size ${saveSize} but inner inner size was ${innerInnerSize}`);
}
}
// Next we have the actual save data
// Only read the number of bytes specified, because the actual file length may be padded out considerably
if ((currentByte + saveSize) > arrayBuffer.byteLength) {
throw new Error(`Save appears corrupted: found save size of ${saveSize} but there are only ${currentByte - arrayBuffer.byteLength} bytes remaining in file`);
}
// Master system data is as-is
if (platformType === 'VC-SMS') {
return {
saveData: arrayBuffer.slice(currentByte, currentByte + saveSize),
fileExtension: 'sav',
};
}
// Genesis data may need a bit more finessing
// Genesis SRAM/FRAM data needs to be converted from bytes into shorts
// Genesis EEPROM data does not.
// First determine the save type
let saveType = 'SRAM';
if (GENESIS_EEPROM_GAME_IDS.indexOf(gameId) >= 0) {
saveType = 'EEPROM';
} else if (GENESIS_FRAM_GAME_IDS.indexOf(gameId) >= 0) {
saveType = 'FRAM';
}
// Then figure out what to do based on the save type
let saveData = arrayBuffer.slice(currentByte, currentByte + saveSize);
if ((saveType === 'SRAM') || (saveType === 'FRAM')) {
saveData = GenesisUtil.byteExpand(saveData, 0x00);
}
return {
saveData,
fileExtension: 'srm',
};
};
================================================
FILE: frontend/src/save-formats/Wii/GetPlatform/GetPlatform.js
================================================
/* Gets the platform of a Wii Virtual Console game based on its Game ID
Note that we do this by asking gametdb.com
Our options are:
1) Include their 16 MB file along with our distribution, and download and parse it in the user's browser
2) Have a build-time process that strips out the unneeded stuff from this file then download that to the user's browser instead
3) Have a server backend that periodically injests this file
4) Hit their website
Reasons for not doing these:
1 is a nonstarter because the file is way too big to download
2 sounds like extra work for a save format I'm not sure will be popular
3 I'd rather not have a server backend to keep hosting costs to a minimum.
Plus this site gets very little traffic. May need to reevaluate if we start getting lots of traffic but I don't expect that given our tiny niche.
*/
import { parse } from 'node-html-parser';
// Here, we proxy our requests through the public service for allOrigins (https://allorigins.win/).
// This adds CORS headers to the responses from gametdb.com, without seeming to add too much latency on top of gametdb's approx 2 seconds.
//
// We may need to reevaluate usage of this proxy (and directly hitting the gametdb service) if our traffic increases.
const BASE_URL = 'https://api.allorigins.win/raw?url=https://www.gametdb.com/Wii/';
// Note that these types are listed in the downloadable XML version of the website's data, which can be found at: https://www.gametdb.com/Wii/Downloads
const PLATFORMS = [
'VC-NES', // Nintendo Entertainment System
'VC-SNES', // Super Nintendo Entertainment System
'VC-N64', // Nintendo 64
'VC-PCE', // PC Engine aka Turbografx 16
'VC-SMS', // Sega Master System
'VC-MD', // Mega Drive aka Sega Genesis
'VC-NEOGEO', // Neo Geo
'VC-C64', // Commodore 64
'VC-Arcade', // Arcade
'WiiWare', // WiiWare
'Wii', // Wii native title
'Homebrew', // Homebrew title
];
const REGIONS = {
'NTSC-U': 'North America',
'NTSC-J': 'Japan',
'PAL': 'Europe', // eslint-disable-line quote-props
};
const PLATFORMS_LOWERCASE = PLATFORMS.map((x) => x.toLowerCase());
const UNKNOWN_PLATFORM = 'Unknown';
const UNKNOWN_REGION = 'Unknown region';
export default class GetPlatform {
constructor(httpClient) {
this.httpClient = httpClient;
}
static getBaseUrl() {
return BASE_URL;
}
static unknownPlatform() {
return UNKNOWN_PLATFORM;
}
static unknownRegion() {
return UNKNOWN_REGION;
}
async get(gameId) {
try {
const response = await this.httpClient.get(gameId);
const root = parse(response.data);
// The gametdb page looks like:
// ...
//
//
//
TD
[Game ID]
//
//
//
region
[Region]
//
//
//
type
[Platform type]
//
// ...
//
// ...
// All done with query selectors to try and accompdate any potential future small changes in the html
const gameDataTable = root.querySelector('.GameData');
// The platform type can be listed on rows 2 or 3. The former happens if the region is omitted,
// such as in the case of homebrew titles
let platformType = null;
let region = null;
for (let i = 3; i >= 1; i -= 1) { // Count backwards because almost every game has it on row 3
const row = gameDataTable.querySelector(`tr:nth-child(${i})`);
let rowName = row.querySelector('td:first-child').firstChild.textContent;
if (rowName !== null) {
rowName = rowName.trim().toLowerCase();
}
if ((rowName === 'type') && (platformType === null)) {
platformType = row.querySelector('td:last-child').firstChild.textContent;
}
if ((rowName === 'region') && (region === null)) {
region = row.querySelector('td:last-child').firstChild.textContent;
}
}
// Make sure our region has every word capitalized, so that we get something like "North America"
if (region !== null) {
region = region.trim().toUpperCase();
if (region in REGIONS) {
region = REGIONS[region]; // Turn "NTSC-U" into "North America", etc
} else {
region = null;
}
}
// Make sure we find the platform we parsed in our list of all possible platforms
if (platformType !== null) {
platformType = platformType.trim().toLowerCase();
}
const platformTypeIndex = PLATFORMS_LOWERCASE.indexOf(platformType);
let platform = UNKNOWN_PLATFORM;
if (platformTypeIndex >= 0) {
platform = PLATFORMS[platformTypeIndex];
}
if (region === null) {
region = UNKNOWN_REGION;
}
return {
platform,
region,
};
} catch (error) {
if ((error.response !== undefined) && (error.response.status === 404)) {
return {
platform: UNKNOWN_PLATFORM,
region: UNKNOWN_REGION,
};
}
return {
platform: UNKNOWN_PLATFORM,
region: UNKNOWN_REGION,
}; // Unsure if we should do something different here
}
}
}
================================================
FILE: frontend/src/save-formats/Wii/GetPlatform/HttpClient.js
================================================
import axios from 'axios';
import axiosRetry from 'axios-retry';
const NUM_RETRIES = 3; // Definitely need some retries given that we're dealing with a free proxy and a free website (that's not intended to be an API)
const RETRY_DELAY = 500; // ms
export default function getHttpClient(baseUrl) {
const client = axios.create({
baseURL: baseUrl,
});
axiosRetry(client, {
retries: NUM_RETRIES,
retryDelay: () => RETRY_DELAY,
});
return client;
}
================================================
FILE: frontend/src/save-formats/Wii/Wii.js
================================================
/* eslint no-bitwise: ["error", { "allow": ["|", ">>>", "<<"] }] */
/*
The Wii save data format is documented here: https://wiibrew.org/wiki/Savegame_Files
Encryption keys are from: https://hackmii.com/2008/04/keys-keys-keys/
The overall file structure looks like:
- Main header (contains the banner header size)
- Banner header (contains the icon that shows up in the Wii UI)
- Backup header (contains the number of files and their sizes)
- Then a number of files, each of which contains:
- File header (contains the file size and decryption info)
- File data
- This may also contains a header, depending on which console the save is for
Some parts are encrypted and some aren't
*/
import MathUtil from '@/util/Math';
import CryptoAes from '../../util/crypto-aes';
const LITTLE_ENDIAN = false;
const GAME_TITLE_ENCODING = 'utf-16be';
const GAME_ID_ENCODING = 'US-ASCII';
const ENCRYPTION_ALGORITHM = 'aes-128-cbc';
const SD_KEY = Buffer.from('ab01b9d8e1622b08afbad84dbfc2a55d', 'hex');
const SD_INITIALIZATION_VECTOR = Buffer.from('216712e6aa1f689f95c5a22324dc6a98', 'hex');
const MAIN_HEADER_SIZE = 0x20;
const BANNER_MAGIC = 0x5749424E; // 'WIBN' ('Wii banner'?)
const BACKUP_HEADER_SIZE = 0x70;
const BACKUP_HEADER_PADDING_SIZE = 0x10;
const BACKUP_HEADER_MAGIC = 0x426B; // 'Bk'
const FILE_HEADER_SIZE = 0x80;
const FILE_HEADER_MAGIC = 0x03ADF17E;
const INCORRECT_FORMAT_ERROR_MESSAGE = 'This does not appear to be a Wii save file';
function getString(arrayBuffer, byteOffset, byteLength, textDecoder) {
const bytes = new Uint8Array(arrayBuffer.slice(byteOffset, byteOffset + byteLength));
return textDecoder.decode(bytes).replace(/\0/g, ''); // Remove trailing nulls
}
function getNullTerminatedString(arrayBuffer, byteOffset, textDecoder) {
const array = new Uint8Array(arrayBuffer.slice(byteOffset));
const nullIndex = array.indexOf(0);
if (nullIndex === -1) {
return '';
}
return getString(arrayBuffer, byteOffset, nullIndex, textDecoder);
}
function parseFile(arrayBuffer, currentByte, asciiDecoder) {
// Parse the file header
const fileHeader = arrayBuffer.slice(currentByte, currentByte + FILE_HEADER_SIZE);
const fileHeaderDataView = new DataView(fileHeader);
if (fileHeaderDataView.getUint32(0, LITTLE_ENDIAN) !== FILE_HEADER_MAGIC) {
throw new Error(INCORRECT_FORMAT_ERROR_MESSAGE);
}
const size = fileHeaderDataView.getUint32(0x4, LITTLE_ENDIAN);
const name = getNullTerminatedString(fileHeader, 0xB, asciiDecoder);
const initializationVector = Buffer.from(fileHeader.slice(0x50, 0x60));
// Use the info from the file header to decrypt the raw save
const encryptedData = arrayBuffer.slice(currentByte + FILE_HEADER_SIZE, currentByte + FILE_HEADER_SIZE + size);
let decryptedData = null;
try {
decryptedData = CryptoAes.decrypt(encryptedData, ENCRYPTION_ALGORITHM, SD_KEY, initializationVector);
} catch (e) {
throw new Error(INCORRECT_FORMAT_ERROR_MESSAGE, e); // Error trying to decrypt indicates that something is malformed
}
return {
size,
name,
data: decryptedData,
containsSaveData: (
(name === 'savedata.bin') // Most games have this filename
|| (name === 'pcengine.bup') // TG-16/PCE games have this filename
|| name.startsWith('RAM_') // SRAM games on the N64 (Mario Golf, F-Zero) start with 'RAM_' then the game ID
|| name.startsWith('EEP_')), // All non-SRAM N64 games (EEPROM, Flash RAM) start with 'EEP_' then the game ID
};
}
export default class WiiSaveData {
static createFromWiiData(wiiArrayBuffer) {
return new WiiSaveData(wiiArrayBuffer);
}
static createFromEmulatorData(emulatorArrayBuffer) {
const wiiArrayBuffer = emulatorArrayBuffer;
// A bit inefficient to promptly go and decrypt the save data, but this
// has the nice benefit of verifying that we put everything in the correct endianness
// and got everything in the right spot. Yes I suppose that should be a test instead.
return new WiiSaveData(wiiArrayBuffer);
}
// This constructor creates a new object from a binary representation of a Wii save data file
constructor(arrayBuffer) {
this.arrayBuffer = arrayBuffer;
// First, decrypt the file
const encryptedArrayBuffer = arrayBuffer;
let decryptedArrayBuffer = null;
try {
decryptedArrayBuffer = CryptoAes.decrypt(arrayBuffer, ENCRYPTION_ALGORITHM, SD_KEY, SD_INITIALIZATION_VECTOR);
} catch (e) {
throw new Error(INCORRECT_FORMAT_ERROR_MESSAGE, e); // Error trying to decrypt indicates that something is malformed
}
// Parse the main header
const mainHeader = decryptedArrayBuffer.slice(0, MAIN_HEADER_SIZE);
const mainHeaderDataView = new DataView(mainHeader);
// Parse the banner
const bannerSize = mainHeaderDataView.getUint32(0x8, LITTLE_ENDIAN);
const banner = decryptedArrayBuffer.slice(MAIN_HEADER_SIZE, MAIN_HEADER_SIZE + bannerSize);
const bannerDataView = new DataView(banner);
if (bannerDataView.getUint32(0, LITTLE_ENDIAN) !== BANNER_MAGIC) {
throw new Error(INCORRECT_FORMAT_ERROR_MESSAGE);
}
const gameTitleDecoder = new TextDecoder(GAME_TITLE_ENCODING);
this.gameTitle = getString(banner, 0x20, 64, gameTitleDecoder);
this.gameSubtitle = getString(banner, 0x60, 64, gameTitleDecoder);
// Parse the backup header
const backupHeader = encryptedArrayBuffer.slice(MAIN_HEADER_SIZE + bannerSize, MAIN_HEADER_SIZE + bannerSize + BACKUP_HEADER_SIZE);
const backupHeaderDataView = new DataView(backupHeader);
if (backupHeaderDataView.getUint32(0, LITTLE_ENDIAN) !== BACKUP_HEADER_SIZE) {
throw new Error(INCORRECT_FORMAT_ERROR_MESSAGE);
}
if (backupHeaderDataView.getUint16(0x4, LITTLE_ENDIAN) !== BACKUP_HEADER_MAGIC) {
throw new Error(INCORRECT_FORMAT_ERROR_MESSAGE);
}
const asciiDecoder = new TextDecoder(GAME_ID_ENCODING);
this.numberOfFiles = backupHeaderDataView.getUint32(0xC, LITTLE_ENDIAN);
this.sizeOfFiles = backupHeaderDataView.getUint32(0x10, LITTLE_ENDIAN);
this.totalSize = backupHeaderDataView.getUint32(0x1C, LITTLE_ENDIAN);
this.gameId = getString(backupHeader, 0x64, 4, asciiDecoder);
// Parse the files
let currentByte = MAIN_HEADER_SIZE + bannerSize + BACKUP_HEADER_SIZE + BACKUP_HEADER_PADDING_SIZE;
this.files = [];
for (let i = 0; i < this.numberOfFiles; i += 1) {
const file = parseFile(encryptedArrayBuffer, currentByte, asciiDecoder);
this.files.push(file);
currentByte += (FILE_HEADER_SIZE + MathUtil.roundUpToNearest64Bytes(file.size));
}
}
getGameTitle() {
return this.gameTitle;
}
getGameSubtitle() {
return this.gameSubtitle;
}
getNumberOfFiles() {
return this.numberOfFiles;
}
getSizeOfFiles() {
return this.sizeOfFiles;
}
getFiles() {
return this.files;
}
getTotalSize() {
return this.totalSize;
}
getGameId() {
return this.gameId;
}
getArrayBuffer() {
return this.arrayBuffer;
}
}
================================================
FILE: frontend/src/store/index.js
================================================
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
},
});
================================================
FILE: frontend/src/util/Array.js
================================================
export default class ArrayUtil {
static arraysEqual(a1, a2) {
if (a1.length !== a2.length) {
return false;
}
for (let i = 0; i < a1.length; i += 1) {
if (a1[i] !== a2[i]) {
return false;
}
}
return true;
}
static createSequentialArray(startValue, numValues) {
return Array(numValues).fill().map((_, index) => index + startValue);
}
static createReverseSequentialArray(endValue, numValues) {
return ArrayUtil.createSequentialArray(endValue - numValues + 1, numValues).reverse();
}
}
================================================
FILE: frontend/src/util/CompressionGzip.js
================================================
// Note that we can potentially remove the dependancy on pako by using the Compression Streams API:
// https://stackoverflow.com/questions/36185110/is-there-a-way-to-use-browsers-native-gzip-decompression-using-javascript
//
// But this requires making these functions async
import pako from 'pako';
import Util from './util';
export default class CompressionGzip {
static decompress(arrayBuffer) {
try {
return Util.bufferToArrayBuffer(pako.ungzip(arrayBuffer));
} catch (e) {
// pako throws a string rather than an error
throw new Error(`Could not decompress the data using gzip: ${e}`);
}
}
static compress(arrayBuffer) {
try {
return Util.bufferToArrayBuffer(pako.gzip(arrayBuffer));
} catch (e) {
// pako throws a string rather than an error
throw new Error(`Could not compress the data using gzip: ${e}`);
}
}
}
================================================
FILE: frontend/src/util/CompressionLzo.js
================================================
import lzo from '../../lib/minlzo-js/lzo1x';
import Util from './util';
export default class CompressionLzo {
static decompress(arrayBuffer, uncompressedSize) {
const state = {
inputBuffer: new Uint8Array(arrayBuffer),
outputBuffer: null,
};
lzo.setOutputEstimate(uncompressedSize);
const returnVal = lzo.decompress(state);
if (returnVal === lzo.OK) {
return Util.bufferToArrayBuffer(state.outputBuffer);
}
throw new Error(`Encountered error ${returnVal} when trying to decompress LZO buffer`);
}
static compress(arrayBuffer) {
const state = {
inputBuffer: new Uint8Array(arrayBuffer),
outputBuffer: null,
};
const returnVal = lzo.compress(state);
if (returnVal === lzo.OK) {
return Util.bufferToArrayBuffer(state.outputBuffer);
}
throw new Error(`Encountered error ${returnVal} when trying to compress buffer with LZO`);
}
}
================================================
FILE: frontend/src/util/CompressionRzip.js
================================================
import { RzipJS } from 'rzipjs';
import Util from './util';
export default class CompressionRzip {
static decompress(arrayBuffer) {
try {
const rzip = new RzipJS(new Uint8Array(arrayBuffer));
if (!rzip.is_rzip_compressed()) {
throw new Error('Data is not compressed with rzip');
}
return Util.bufferToArrayBuffer(rzip.rzip_inflate());
} catch (e) {
throw new Error('Could not decompress the data using rzip', e);
}
}
static compress(arrayBuffer) {
try {
const rzip = new RzipJS(new Uint8Array(arrayBuffer));
if (rzip.is_rzip_compressed()) {
throw new Error('Data is already compressed using rzip');
}
return Util.bufferToArrayBuffer(rzip.rzip_deflate());
} catch (e) {
throw new Error('Could not compress the data using rzip', e);
}
}
}
================================================
FILE: frontend/src/util/CompressionZlib.js
================================================
// Note that we can potentially remove the dependancy on pako by using the Compression Streams API:
// https://stackoverflow.com/questions/36185110/is-there-a-way-to-use-browsers-native-gzip-decompression-using-javascript
//
// But this requires making these functions async
import pako from 'pako';
export default class CompressionZlib {
static decompress(arrayBuffer) {
try {
return pako.inflate(arrayBuffer);
} catch (e) {
// pako throws a string rather than an error
throw new Error(`Could not decompress the data using zlib: ${e}`);
}
}
static compress(arrayBuffer) {
try {
return pako.deflate(arrayBuffer);
} catch (e) {
// pako throws a string rather than an error
throw new Error(`Could not compress the data using zlib: ${e}`);
}
}
}
================================================
FILE: frontend/src/util/Endian.js
================================================
function swap2ByteWord(inputDataView, outputDataView) {
for (let i = 0; i < inputDataView.byteLength / 2; i += 1) {
const n = inputDataView.getUint16(i * 2, true); // As long as the endianness values here are different, it doesn't matter which is which
outputDataView.setUint16(i * 2, n, false);
}
}
function swap4ByteWord(inputDataView, outputDataView) {
for (let i = 0; i < inputDataView.byteLength / 4; i += 1) {
const n = inputDataView.getUint32(i * 4, true); // As long as the endianness values here are different, it doesn't matter which is which
outputDataView.setUint32(i * 4, n, false);
}
}
function swap8ByteWord(inputDataView, outputDataView) {
for (let i = 0; i < inputDataView.byteLength / 8; i += 1) {
const n = inputDataView.getBigUint64(i * 8, true); // As long as the endianness values here are different, it doesn't matter which is which
outputDataView.setBigUint64(i * 8, n, false);
}
}
const SWAP_FUNCTIONS = {
2: swap2ByteWord,
4: swap4ByteWord,
8: swap8ByteWord,
};
export default class EndianUtil {
static swap(inputArrayBuffer, wordSizeInBytes) {
if ((inputArrayBuffer.byteLength % wordSizeInBytes) !== 0) {
throw new Error(`File length must be a multiple of ${wordSizeInBytes} bytes`);
}
if (!(wordSizeInBytes in SWAP_FUNCTIONS)) {
throw new Error(`Word size must be one of ${Object.keys(SWAP_FUNCTIONS).join(', ')}`);
}
const outputArrayBuffer = new ArrayBuffer(inputArrayBuffer.byteLength);
const inputDataView = new DataView(inputArrayBuffer);
const outputDataView = new DataView(outputArrayBuffer);
SWAP_FUNCTIONS[wordSizeInBytes](inputDataView, outputDataView);
return outputArrayBuffer;
}
}
================================================
FILE: frontend/src/util/Genesis.js
================================================
/* eslint-disable no-bitwise */
import PaddingUtil from './Padding';
const LITTLE_ENDIAN = false;
// Genesis raw files can come in various flavours.
//
// SRAM/FRAM saves:
//
// The general idea is that each 8 bit byte is "expanded" out to be 16 bits (because the Genesis reads over a 16 bit bus I guess).
// However, there are several ways of doing this, each of which is "correct":
//
// - Alternate 0x00 bytes: "HELLO" -> " H E L L O" (emulators and Mega Everdrive do this)
// - Alternate 0xFF bytes: "HELLO" -> " H E L L O" (Mega SD does this)
// - Repeat each byte: "HELLO" -> "HHEELLLLOO" (the Retrode cart reader does this)
//
// EEPROM saves:
//
// Here, it's just the straight data with no byte expanding.
//
// My working theory on identifying EEPROM saves vs a non-byte-expanded SRAM save is that EEPROM saves
// seem to be smaller.
const SMALLEST_SRAM_SAVE = 512; // Final Fantasy Legend on Gameboy is 512B and is SRAM, but I'm not aware of any Genesis SRAM games that small
export default class GenesisUtil {
static FILL_BYTE_REPEAT = 'repeat';
static isEepromSave(inputArrayBuffer) {
// Hacky check for EEPROM saves that shouldn't be byte expanded
// Wonder Boy in Monster World's save is 128 bytes. 512 bytes is the smallest SRAM save I've seen (Final Fantasy Legend on Gameboy)
// This hack may well not work on other Genesis EEPROM games: I have no idea how big their saves are, and looking at the list
// here https://github.com/euan-forrester/save-file-converter/blob/main/frontend/src/save-formats/Wii/ConvertFromSega.js
// there are lots of sports games that may need a bunch of space.
return (inputArrayBuffer.byteLength < SMALLEST_SRAM_SAVE);
}
// This will return true for a file that is empty: all 0x00 or all 0xFF
// Consider using the function below to check for this case
static isByteExpanded(inputArrayBuffer) {
const inputDataView = new DataView(inputArrayBuffer);
for (let i = 0; i < (inputArrayBuffer.byteLength / 2); i += 1) {
const highByte = inputDataView.getUint8(i * 2);
const lowByte = inputDataView.getUint8((i * 2) + 1);
if ((highByte !== lowByte) && (highByte !== 0x00) && (highByte !== 0xFF)) {
return false;
}
}
return true;
}
static isEmpty(inputArrayBuffer) {
const inputDataView = new DataView(inputArrayBuffer);
for (let i = 0; i < inputArrayBuffer.byteLength; i += 1) {
const byte = inputDataView.getUint8(i);
if ((byte !== 0x00) && (byte !== 0xFF)) {
return false;
}
}
return true;
}
// fillByte can either be the value you want put into upper byte of each word,
// or it can be 'repeat'
static byteExpand(inputArrayBuffer, fillByte) {
const outputArrayBuffer = new ArrayBuffer(inputArrayBuffer.byteLength * 2);
const inputDataView = new DataView(inputArrayBuffer);
const outputDataView = new DataView(outputArrayBuffer);
const repeatByte = (fillByte === GenesisUtil.FILL_BYTE_REPEAT);
for (let i = 0; i < inputDataView.byteLength; i += 1) {
const inputByte = inputDataView.getUint8(i);
const highByte = repeatByte ? inputByte : fillByte;
const outputWord = ((highByte & 0xFF) << 8) | inputByte;
outputDataView.setUint16(i * 2, outputWord, LITTLE_ENDIAN);
}
return outputArrayBuffer;
}
static byteCollapse(inputArrayBuffer) {
const outputArrayBuffer = new ArrayBuffer(inputArrayBuffer.byteLength / 2);
const inputDataView = new DataView(inputArrayBuffer);
const outputDataView = new DataView(outputArrayBuffer);
for (let i = 0; i < (inputArrayBuffer.byteLength / 2); i += 1) {
const currByte = inputDataView.getUint8((i * 2) + 1);
outputDataView.setUint8(i, currByte);
}
return outputArrayBuffer;
}
static changeFillByte(inputArrayBuffer, fillByte) {
if (GenesisUtil.isEepromSave(inputArrayBuffer)) {
return inputArrayBuffer;
}
const padding = PaddingUtil.getPadFromEndValueAndCount(inputArrayBuffer);
const needToChangePadding = (fillByte !== GenesisUtil.FILL_BYTE_REPEAT) && (padding.value !== fillByte); // Check if padding was 0xFF and we're trying to fill with 0x00, or vice versa
const unpaddedInputArrayBuffer = PaddingUtil.removePaddingFromEnd(inputArrayBuffer, padding.count);
let byteCollapsedArrayBuffer = unpaddedInputArrayBuffer;
if (GenesisUtil.isByteExpanded(unpaddedInputArrayBuffer)) {
byteCollapsedArrayBuffer = GenesisUtil.byteCollapse(unpaddedInputArrayBuffer);
}
let outputArrayBuffer = GenesisUtil.byteExpand(byteCollapsedArrayBuffer, fillByte);
if (needToChangePadding) {
outputArrayBuffer = PaddingUtil.addPaddingToEnd(outputArrayBuffer, { value: fillByte, count: padding.count });
} else {
outputArrayBuffer = PaddingUtil.addPaddingToEnd(outputArrayBuffer, { value: padding.value, count: padding.count }); // Put the padding back
}
return outputArrayBuffer;
}
}
================================================
FILE: frontend/src/util/Hash.js
================================================
import createHash from 'create-hash';
import Util from './util';
export default class HashUtil {
static getEncodedHash(arrayBuffer, hashAlgorithm, hashEncoding) {
const hash = createHash(hashAlgorithm);
hash.update(Buffer.from(arrayBuffer));
const hashOutput = hash.digest();
const hashString = Buffer.from(hashOutput).toString('hex').toLowerCase();
const textEncoder = new TextEncoder(hashEncoding);
return Util.bufferToArrayBuffer(textEncoder.encode(hashString));
}
}
================================================
FILE: frontend/src/util/Math.js
================================================
/* eslint no-bitwise: ["error", { "allow": ["&", ">>=", "<<", ">>", ">>>"] }] */
function countNumberOfBitsUsed(n) {
// Keep removing bits until we're left with 0
let count = 0;
let x = n;
while (x !== 0) {
x >>= 1;
count += 1;
}
return count;
}
export default class MathUtil {
static getNextLargestPowerOf2(n) {
// Input values shouldn't be negative
if (n <= 0) {
return 0;
}
if (MathUtil.isPowerOf2(n)) {
return n;
}
return 1 << countNumberOfBitsUsed(n);
}
static getNextSmallestPowerOf2(n) {
// Input values shouldn't be negative
if (n <= 0) {
return 0;
}
if (MathUtil.isPowerOf2(n)) {
return n;
}
return 1 << (countNumberOfBitsUsed(n) - 1);
}
static isPowerOf2(n) {
if (n <= 0) {
return false;
}
return ((n & (n - 1)) === 0);
}
static getNextMultipleOf16(n) {
// Input values shouldn't be negative
if (n <= 0) {
return 0;
}
return ((n + 0xF) >>> 4) << 4;
}
static roundUpToNearest64Bytes(num) {
if (num < 0) {
return 0;
}
return (((num + 0x3F) >>> 6) << 6);
}
}
================================================
FILE: frontend/src/util/N64.js
================================================
import EndianUtil from './Endian';
// EEPROM saves do not need to be endian swapped, but SRAM and Flash RAM saves do
// From http://micro-64.com/database/gamesave.shtml
const EEPROM_SIZES = [512, 2 * 1024];
const SRAM_SIZES = [32 * 1024];
const FLASH_RAM_SIZES = [128 * 1024];
const N64_WORD_LENGTH = 4; // 32 bits, not 64
export default class N64Util {
static needsEndianSwap(arrayBuffer) {
return !N64Util.isEepromSave(arrayBuffer);
}
static isEepromSave(arrayBuffer) {
return (EEPROM_SIZES.indexOf(arrayBuffer.byteLength) >= 0);
}
static isSramSave(arrayBuffer) {
return (SRAM_SIZES.indexOf(arrayBuffer.byteLength) >= 0);
}
static isFlashRamSave(arrayBuffer) {
return (FLASH_RAM_SIZES.indexOf(arrayBuffer.byteLength) >= 0);
}
static getFileExtension(arrayBuffer) {
if (N64Util.isEepromSave(arrayBuffer)) {
return 'eep';
}
if (N64Util.isSramSave(arrayBuffer)) {
return 'sra';
}
if (N64Util.isFlashRamSave(arrayBuffer)) {
return 'fla';
}
throw new Error(`Unrecognized N64 file size: ${arrayBuffer.byteLength} bytes`);
}
static endianSwap(inputArrayBuffer) {
if ((inputArrayBuffer.byteLength % N64_WORD_LENGTH) !== 0) {
throw new Error(`N64 file size must be a multiple of ${N64_WORD_LENGTH} bytes`);
}
return EndianUtil.swap(inputArrayBuffer, N64_WORD_LENGTH);
}
}
================================================
FILE: frontend/src/util/Padding.js
================================================
/* eslint no-bitwise: ["error", { "allow": ["&", ">>=", "<<"] }] */
import MathUtil from './Math';
function fixCountSoSaveSizeIsPowerOf2(arrayBuffer, count) {
// Most raw save files (for cartridges anyway) have sizes that are a power of 2,
// because it's stored on a chip of one type or another and that's how they're manufactured.
//
// So, if we see apparent padding that seems to take our size below a power of 2 it's probable that
// some of that "padding" was legit data so we don't want to trim it.
const apparentRemainingSize = arrayBuffer.byteLength - count;
const realRemainingSize = MathUtil.getNextLargestPowerOf2(apparentRemainingSize);
return count - (realRemainingSize - apparentRemainingSize);
}
export default class PaddingUtil {
static attemptFix(testSaveArrayBuffer, brokenSaveArrayBuffer) {
// First, temporarily remove any padding from the start of the 2 saves
const testSavePadding = PaddingUtil.getPadFromStartValueAndCount(testSaveArrayBuffer);
const brokenSavePadding = PaddingUtil.getPadFromStartValueAndCount(brokenSaveArrayBuffer);
const testSaveNoPaddingAtStart = PaddingUtil.removePaddingFromStart(testSaveArrayBuffer, testSavePadding.count);
const brokenSaveNoPaddingAtStart = PaddingUtil.removePaddingFromStart(brokenSaveArrayBuffer, brokenSavePadding.count);
// Now make the remainder of the broken save be the same length as the remainder of the good save
let brokenSaveNoPaddingAtStartCorrectLength = brokenSaveNoPaddingAtStart;
if (brokenSaveNoPaddingAtStart.byteLength < testSaveNoPaddingAtStart.byteLength) {
const endPadding = {
value: 0x00,
count: testSaveNoPaddingAtStart.byteLength - brokenSaveNoPaddingAtStart.byteLength,
};
brokenSaveNoPaddingAtStartCorrectLength = PaddingUtil.addPaddingToEnd(brokenSaveNoPaddingAtStart, endPadding);
} else if (brokenSaveNoPaddingAtStart.byteLength > testSaveNoPaddingAtStart.byteLength) {
brokenSaveNoPaddingAtStartCorrectLength = PaddingUtil.removePaddingFromEnd(brokenSaveNoPaddingAtStart, brokenSaveNoPaddingAtStart.byteLength - testSaveNoPaddingAtStart.byteLength);
}
// Now add back any padding to the start that was present in the good save
const brokenSaveFixed = PaddingUtil.addPaddingToStart(brokenSaveNoPaddingAtStartCorrectLength, testSavePadding);
return brokenSaveFixed;
}
static fileSizeAndPaddingFromStartIsSame(arrayBuffer1, arrayBuffer2) {
if (arrayBuffer1.byteLength !== arrayBuffer2.byteLength) {
return false;
}
const padding1 = PaddingUtil.getPadFromStartValueAndCount(arrayBuffer1);
const padding2 = PaddingUtil.getPadFromStartValueAndCount(arrayBuffer2);
if ((padding1.count !== padding2.count) || (padding1.value !== padding2.value)) {
return false;
}
return true;
}
static getPadFromStartValueAndCount(arrayBuffer) {
const pad00Count = PaddingUtil.countPaddingFromStart(arrayBuffer, 0x00);
const padFFCount = PaddingUtil.countPaddingFromStart(arrayBuffer, 0xFF);
let value = 0x00;
let count = pad00Count;
if (padFFCount > 0) {
value = 0xFF;
count = padFFCount;
}
count = fixCountSoSaveSizeIsPowerOf2(arrayBuffer, count);
return {
value,
count,
};
}
static getPadFromEndValueAndCount(arrayBuffer) {
const pad00Count = PaddingUtil.countPaddingFromEnd(arrayBuffer, 0x00);
const padFFCount = PaddingUtil.countPaddingFromEnd(arrayBuffer, 0xFF);
let value = 0x00;
let count = pad00Count;
if (padFFCount > 0) {
value = 0xFF;
count = padFFCount;
}
count = fixCountSoSaveSizeIsPowerOf2(arrayBuffer, count);
return {
value,
count,
};
}
static removePaddingFromStart(arrayBuffer, paddingCount) {
return arrayBuffer.slice(paddingCount);
}
static removePaddingFromEnd(arrayBuffer, paddingCount) {
return arrayBuffer.slice(0, arrayBuffer.byteLength - paddingCount);
}
static addPaddingToStart(arrayBuffer, padding) {
const newArrayBuffer = new ArrayBuffer(arrayBuffer.byteLength + padding.count);
const newUint8Array = new Uint8Array(newArrayBuffer);
const oldUint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < padding.count; i += 1) {
newUint8Array[i] = padding.value;
}
newUint8Array.set(oldUint8Array, padding.count);
return newArrayBuffer;
}
static addPaddingToEnd(arrayBuffer, padding) {
const newArrayBuffer = new ArrayBuffer(arrayBuffer.byteLength + padding.count);
const newUint8Array = new Uint8Array(newArrayBuffer);
const oldUint8Array = new Uint8Array(arrayBuffer);
newUint8Array.set(oldUint8Array, 0);
for (let i = 0; i < padding.count; i += 1) {
newUint8Array[arrayBuffer.byteLength + i] = padding.value;
}
return newArrayBuffer;
}
static padAtEndToMinimumSize(arrayBuffer, paddingValue, minimumPaddingCount) {
return PaddingUtil.addPaddingToEnd(arrayBuffer, { value: paddingValue, count: Math.max(minimumPaddingCount - arrayBuffer.byteLength, 0) });
}
static countPaddingFromStart(arrayBuffer, padValue) {
const uint8Array = new Uint8Array(arrayBuffer);
let count = 0;
for (let i = 0; i < uint8Array.length; i += 1) {
if (uint8Array[i] !== padValue) {
break;
}
count += 1;
}
// If everything looks like padding, then nothing is. This can happen, for example,
// when a user quickly creates a test save file without actually saving in-game. Not
// much we can do in that case other than to assume the whole thing is the correct size.
// Should flag the user that they need to provide a better file.
if (count === arrayBuffer.byteLength) {
count = 0;
}
return count;
}
static countPaddingFromEnd(arrayBuffer, padValue) {
const uint8Array = new Uint8Array(arrayBuffer);
let count = 0;
for (let i = uint8Array.length - 1; i >= 0; i -= 1) {
if (uint8Array[i] !== padValue) {
break;
}
count += 1;
}
// If everything looks like padding, then nothing is. This can happen, for example,
// when a user quickly creates a test save file without actually saving in-game. Not
// much we can do in that case other than to assume the whole thing is the correct size.
// Should flag the user that they need to provide a better file.
if (count === arrayBuffer.byteLength) {
count = 0;
}
return count;
}
}
================================================
FILE: frontend/src/util/PcEngine.js
================================================
/* eslint-disable no-bitwise */
import Util from './util';
const BRAM_SIZE = 2048;
const MAGIC = 'HUBM'; // Marker at the beginning that signifies correctly-formatted BRAM
const MAGIC_ENCODING = 'US-ASCII';
const MAGIC_OFFSET = 0;
export default class PcEngineUtil {
static verifyPcEngineData(arrayBuffer) {
if (arrayBuffer.byteLength !== BRAM_SIZE) {
throw new Error(`File is the incorrect size: expected ${BRAM_SIZE} bytes but found ${arrayBuffer.byteLength} bytes`);
}
return Util.checkMagic(arrayBuffer, MAGIC_OFFSET, MAGIC, MAGIC_ENCODING);
}
}
================================================
FILE: frontend/src/util/SaveFiles.js
================================================
export default class SaveFilesUtil {
static resizeRawSave(arrayBuffer, newSize, fillValue = 0) {
let newArrayBuffer = arrayBuffer;
if (newSize < arrayBuffer.byteLength) {
newArrayBuffer = arrayBuffer.slice(0, newSize);
} else if (newSize > arrayBuffer.byteLength) {
newArrayBuffer = new ArrayBuffer(newSize);
const newArray = new Uint8Array(newArrayBuffer);
const oldArray = new Uint8Array(arrayBuffer);
newArray.fill(fillValue);
newArray.set(oldArray, 0);
}
return newArrayBuffer;
}
static getEraseCartridgeSave(arrayBuffer) {
// Just a new ArrayBuffer that's the same size as the known-working save, just filled with zeros
const newArrayBuffer = new ArrayBuffer(arrayBuffer.byteLength);
const newArray = new Uint8Array(newArrayBuffer);
newArray.fill(0); // Redundant but let's be explicit
return newArrayBuffer;
}
}
================================================
FILE: frontend/src/util/SegaCd.js
================================================
/* eslint-disable no-bitwise */
import PlatformSaveSizes from '../save-formats/PlatformSaveSizes';
import Util from './util';
// Thanks to ekeeke on github for their help understanding this!
// https://github.com/ekeeke/Genesis-Plus-GX/issues/449
const LITTLE_ENDIAN = false;
// This signature appears at the end of every BRAM file
// From https://github.com/ekeeke/Genesis-Plus-GX/blob/master/sdl/sdl1/main.c#L37
// and https://github.com/superctr/buram/blob/master/buram.c#L702
const BRAM_FORMAT = [
0x5f, 0x5f, 0x5f, 0x5f, 0x5f, 0x5f, 0x5f, 0x5f, 0x5f, 0x5f, 0x5f, 0x00, 0x00, 0x00, 0x00, 0x40, // Volume name: "___________", followed by 5 unknown bytes (maybe block size?): https://github.com/superctr/buram/blob/master/buram.c#L707
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // First 8: num free blocks, second 8: num files
0x53, 0x45, 0x47, 0x41, 0x5f, 0x43, 0x44, 0x5f, 0x52, 0x4f, 0x4d, 0x00, 0x01, 0x00, 0x00, 0x00, // "SEGA_CD_ROM"
0x52, 0x41, 0x4d, 0x5f, 0x43, 0x41, 0x52, 0x54, 0x52, 0x49, 0x44, 0x47, 0x45, 0x5f, 0x5f, 0x5f, // "RAM_CARTRIDGE___"
];
const BRAM_FORMAT_FIXED_OFFSET = 0x20; // The offset within BRAM_FORMAT where the values don't change even when the file size is different
const DIRECTORY_REPEAT_COUNT = 4;
const DIRECTORY_NUM_FREE_BLOCKS_OFFSET = 0x10;
const DIRECTORY_NUM_FILES_OFFSET = 0x18;
const BLOCK_SIZE = 0x40;
const NUM_RESERVED_BLOCKS = 2; // The first block is reserved, the last block contains the BRAM_FORMAT
const NUM_INITIAL_RESERVED_BLOCKS = NUM_RESERVED_BLOCKS + 1; // and there's one more reserved when the file is created for save room for the directory entry for the eventual first file. This happens whenever the number of save files is even: https://github.com/superctr/buram/blob/master/buram.c#L713
const DIRECTORY_SIZE = 0x40; // A block
const DIRECTORY_ENTRY_SIZE = 0x20; // Half a block
// Taken from https://github.com/superctr/buram/blob/master/buram.c#L470
function readRepeatCode(arrayBuffer, offsetFromDirectory) {
const startOffset = arrayBuffer.byteLength - DIRECTORY_SIZE + offsetFromDirectory;
const dataView = new DataView(arrayBuffer);
const codesFound = new Uint16Array(DIRECTORY_REPEAT_COUNT);
let currentOffset = startOffset;
for (let i = 0; i < DIRECTORY_REPEAT_COUNT; i += 1) {
codesFound[i] = dataView.getUint16(currentOffset, LITTLE_ENDIAN);
currentOffset += 2;
}
for (let i = 0; i < (DIRECTORY_REPEAT_COUNT / 2); i += 1) {
let repeats = 0;
for (let j = i + 1; j < DIRECTORY_REPEAT_COUNT; j += 1) {
if (codesFound[i] === codesFound[j]) {
repeats += 1;
}
}
if (repeats > (DIRECTORY_REPEAT_COUNT / 2)) {
return codesFound[i];
}
}
throw new Error(`Unable to find repeat code at offset from directory 0x${offsetFromDirectory.toString(16)}`);
}
// Taken from https://github.com/superctr/buram/blob/master/buram.c#L498
function writeRepeatCode(arrayBuffer, offsetFromDirectory, value) {
const startOffset = arrayBuffer.byteLength - DIRECTORY_SIZE + offsetFromDirectory;
const dataView = new DataView(arrayBuffer);
let currentOffset = startOffset;
for (let i = 0; i < DIRECTORY_REPEAT_COUNT; i += 1) {
dataView.setUint16(currentOffset, value, LITTLE_ENDIAN);
currentOffset += 2;
}
}
export default class SegaCdUtil {
static INTERNAL_SAVE_SIZE = 8192; // Regardless of platform (mister/flash cart/emulator/etc the internal save size is always the same: it was 8kB in the original hardware. Although only one size of RAM cart was manufactured, many sizes are theoretically possible and so different platforms choose different ones)
static getActualSize(inputArrayBuffer) {
// We can have files that are padded out at the end, despite having the signature earlier in the file. Such a file
// can only store data up until its signature, making that its 'true' size.
//
// So, try to find the second half of BRAM_FORMAT (which doesn't change) where the end would be for each possible save size
const inputUint8Array = new Uint8Array(inputArrayBuffer);
const sizeIndex = PlatformSaveSizes.segacd.findIndex(
(size) => BRAM_FORMAT.slice(BRAM_FORMAT_FIXED_OFFSET).every(
(byte, index) => (byte === inputUint8Array[size - BRAM_FORMAT_FIXED_OFFSET + index]),
),
);
if (sizeIndex === -1) {
throw new Error('This does not appear to be a Sega CD save file');
}
return PlatformSaveSizes.segacd[sizeIndex];
}
static isCorrectlyFormatted(inputArrayBuffer) {
try {
SegaCdUtil.getActualSize(inputArrayBuffer);
} catch (e) {
return false;
}
return true;
}
static truncateToActualSize(inputArrayBuffer) {
return inputArrayBuffer.slice(0, SegaCdUtil.getActualSize(inputArrayBuffer));
}
static makeEmptySave(size) {
// An empty save buffer is all 0's with the BRAM_FORMAT at the end and the file size correctly encoded:
// https://github.com/ekeeke/Genesis-Plus-GX/issues/449
// https://github.com/superctr/buram/blob/master/buram.c#L702
const initialData = Util.getFilledArrayBuffer(size - BRAM_FORMAT.length, 0x00);
const bramFormat = new ArrayBuffer(BRAM_FORMAT.length);
const bramFormatUint8Array = new Uint8Array(bramFormat);
BRAM_FORMAT.forEach((byte, index) => { bramFormatUint8Array[index] = byte; });
const totalFreeBlocks = (size / BLOCK_SIZE) - NUM_INITIAL_RESERVED_BLOCKS;
SegaCdUtil.setNumFreeBlocks(bramFormat, totalFreeBlocks);
return Util.concatArrayBuffers([initialData, bramFormat]);
}
static getNumFiles(inputArrayBuffer) {
return readRepeatCode(inputArrayBuffer, DIRECTORY_NUM_FILES_OFFSET);
}
static getNumFreeBlocks(inputArrayBuffer) {
return readRepeatCode(inputArrayBuffer, DIRECTORY_NUM_FREE_BLOCKS_OFFSET);
}
static setNumFiles(inputArrayBuffer, value) {
writeRepeatCode(inputArrayBuffer, DIRECTORY_NUM_FILES_OFFSET, value);
}
static setNumFreeBlocks(inputArrayBuffer, value) {
writeRepeatCode(inputArrayBuffer, DIRECTORY_NUM_FREE_BLOCKS_OFFSET, value);
}
static getTotalAvailableBlocks(inputArrayBuffer) {
return Math.ceil(inputArrayBuffer.byteLength / BLOCK_SIZE) - NUM_RESERVED_BLOCKS;
}
static resize(inputArrayBuffer, newSize) {
if (PlatformSaveSizes.segacd.indexOf(newSize) === -1) {
throw new Error(`${newSize} bytes is not a valid size for a Sega CD save`);
}
const inputArrayBufferActualSize = SegaCdUtil.truncateToActualSize(inputArrayBuffer);
if (newSize === inputArrayBufferActualSize.byteLength) {
return inputArrayBufferActualSize;
}
// We need to change the size
// Begin by finding out how many saves are in the file. This determines the length of the footer info
// at the end of the file
const numFiles = SegaCdUtil.getNumFiles(inputArrayBufferActualSize);
const numApparentDirectoryEntries = ((numFiles % 2) === 1) ? (numFiles + 1) : numFiles; // Our directory entries get interleaved in pairs, so if there's an odd number it will still take an entire block to encode
const footerLength = numApparentDirectoryEntries * DIRECTORY_ENTRY_SIZE;
// Next divide up the file into its components
const bramFormatOffset = inputArrayBufferActualSize.byteLength - BRAM_FORMAT.length;
const footerOffset = bramFormatOffset - footerLength;
const initialData = inputArrayBufferActualSize.slice(0, footerOffset);
const footerData = inputArrayBufferActualSize.slice(footerOffset, footerOffset + footerLength);
const bramFormat = inputArrayBufferActualSize.slice(bramFormatOffset);
// Set the new number of free blocks. Note that we could calculate this by parsing the entire file and
// adding up all the save sizes within it. But we'll just trust that the previous number was correct.
const previousFreeBlocks = SegaCdUtil.getNumFreeBlocks(inputArrayBufferActualSize);
const sizeDifferenceBlocks = (newSize - inputArrayBufferActualSize.byteLength) / BLOCK_SIZE; // Positive or negative
const newFreeBlocks = previousFreeBlocks + sizeDifferenceBlocks;
if (newFreeBlocks < 0) {
const prevTotalBlocks = inputArrayBufferActualSize.byteLength / BLOCK_SIZE;
const newTotalBlocks = newSize / BLOCK_SIZE;
throw new Error(`Insufficient free blocks in file to change size from ${prevTotalBlocks} to ${newTotalBlocks} blocks. Previous free blocks: ${previousFreeBlocks}`);
}
SegaCdUtil.setNumFreeBlocks(bramFormat, newFreeBlocks);
if (newSize > inputArrayBufferActualSize.byteLength) {
// Make the file bigger by splicing in a blank block just before the footer and BRAM_FORMAT
const blankArrayBuffer = Util.getFilledArrayBuffer(newSize - inputArrayBufferActualSize.byteLength, 0x00);
return Util.concatArrayBuffers([initialData, blankArrayBuffer, footerData, bramFormat]);
}
// Make the file smaller by removing the last portion of initialData
const numBytesToRemove = inputArrayBufferActualSize.byteLength - newSize;
const initialDataSmallerLength = initialData.byteLength - numBytesToRemove;
const initialDataSmaller = initialData.slice(0, initialDataSmallerLength);
// Throw an error if the removed portion contains any data
const dataRemoved = initialData.slice(initialDataSmallerLength);
const dataRemovedUint8Array = new Uint8Array(dataRemoved); // Could use a bigger data type to require less iterations, but then need to check if the length here is a multiple of that datatype
dataRemovedUint8Array.forEach((byte) => {
if ((byte !== 0x00) && (byte !== 0xFF)) throw new Error(`Cannot resize file down to ${newSize} bytes because it would remove a portion that contains game data`);
});
// All good, so put the file back together
return Util.concatArrayBuffers([initialDataSmaller, footerData, bramFormat]);
}
}
================================================
FILE: frontend/src/util/crypto-aes.js
================================================
// Put our crypto utils in a separate file because the 'crypto' package is quite big and we don't want
// to have to include it every time we need a small function from our Util class
// Also, rather than importing the node crypto module, which is huge, we're going to use
// just a portion of it as implemented in https://github.com/crypto-browserify/browserify-aes
import browserifyAes from 'browserify-aes';
import Util from './util';
export default class CryptoAes {
static decrypt(encryptedArrayBuffer, algorithm, key, initializationVector) {
const decipher = browserifyAes.createDecipheriv(algorithm, key, initializationVector);
decipher.setAutoPadding(false); // Different platforms have different default padding: https://github.com/nodejs/node/issues/2794#issuecomment-139436581
const encryptedBuffer = Buffer.from(encryptedArrayBuffer);
const decryptedBuffer = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
return Util.bufferToArrayBuffer(decryptedBuffer);
}
static encrypt(decryptedArrayBuffer, algorithm, key, initializationVector) {
const cipher = browserifyAes.createCipheriv(algorithm, key, initializationVector);
cipher.setAutoPadding(false); // See note above. When encrypting for the Wii, for example, we don't want to add Node's PKCS padding
const decryptedBuffer = Buffer.from(decryptedArrayBuffer);
const encryptedBuffer = Buffer.concat([cipher.update(decryptedBuffer), cipher.final()]);
return Util.bufferToArrayBuffer(encryptedBuffer);
}
}
================================================
FILE: frontend/src/util/crypto-des.js
================================================
// Put our crypto utils in a separate file because the 'crypto' package is quite big and we don't want
// to have to include it every time we need a small function from our Util class
// Also, rather than importing the node crypto module, which is huge, we're going to use
// just a portion of it as implemented in https://github.com/crypto-browserify/browserify-des
import DES from 'browserify-des';
import Util from './util';
export default class CryptoDes {
static decrypt(encryptedArrayBuffer, algorithm, key, initializationVector) {
const decipher = new DES({
mode: algorithm,
key,
iv: initializationVector,
decrypt: true,
});
const encryptedBuffer = Buffer.from(encryptedArrayBuffer);
const decryptedBuffer = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
return Util.bufferToArrayBuffer(decryptedBuffer);
}
static encrypt(decryptedArrayBuffer, algorithm, key, initializationVector) {
const cipher = new DES({
mode: algorithm,
key,
iv: initializationVector,
decrypt: false,
});
const decryptedBuffer = Buffer.from(decryptedArrayBuffer);
const encryptedBuffer = Buffer.concat([cipher.update(decryptedBuffer), cipher.final()]);
return Util.bufferToArrayBuffer(encryptedBuffer);
}
}
================================================
FILE: frontend/src/util/util.js
================================================
import path from 'path';
import Encoding from 'encoding-japanese'; // Should we consider splitting this out? Almost every page depends on this file, but very few need japanese encodings
// Comment to trigger build
export default class Util {
static clampValue(value, min, max) {
return Math.min(Math.max(value, min), max);
}
static changeFilenameExtension(filename, newExtension) {
return `${path.basename(filename, path.extname(filename))}.${newExtension}`;
}
static removeFilenameExtension(filename) {
return `${path.basename(filename, path.extname(filename))}`;
}
static getFilename(filename) {
return path.basename(filename);
}
static getExtension(filename) {
return path.extname(filename);
}
static appendToFilename(filename, stringToAppend) {
return `${Util.removeFilenameExtension(filename)}${stringToAppend}${path.extname(filename)}`;
}
static convertDescriptionToFilename(description) {
// First, remove everything but A-Z, a-z, 0-9, dash, underscore, space
// Then check how many A-Z, a-z, 0-9 there are. If > 0 then done, if 0 then return 'output'
// This is to prevent a filename of just being a single space, or a dash, etc, if the
// original description is all japanese characters plus a space, for example
const descriptionStripped = description.replace(/[^0-9a-z_\- ]/gi, '');
const descriptionStrippedAlphanumeric = descriptionStripped.replace(/[^0-9a-z]/gi, '');
if (descriptionStrippedAlphanumeric.length > 0) {
return descriptionStripped;
}
return 'output';
}
static bufferToArrayBuffer(b) {
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength); // https://stackoverflow.com/questions/8609289/convert-a-binary-nodejs-buffer-to-javascript-arraybuffer/31394257#31394257
}
static concatArrayBuffers(arrayBufferList) {
const bufferList = arrayBufferList.map((ab) => Buffer.from(ab));
return Util.bufferToArrayBuffer(Buffer.concat(bufferList));
}
static setString(arrayBuffer, offset, string, stringEncoding, maxLengthWhenEncoded) {
let stringArrayBuffer = null;
if (stringEncoding === 'shift-jis') {
const unicodeArray = Encoding.stringToCode(string);
stringArrayBuffer = Util.bufferToArrayBuffer(new Uint8Array(Encoding.convert(unicodeArray, { to: 'SJIS', from: 'UNICODE' })));
} else {
// TextEncoder can actually only encode to UTF8. US-ASCII is a subset of that, so
// this happens to work when we specify US-ASCII. TextDecoder is able to decode a wide variety of formats
const textEncoder = new TextEncoder(stringEncoding);
stringArrayBuffer = Util.bufferToArrayBuffer(textEncoder.encode(string));
}
return Util.setArrayBufferPortion(arrayBuffer, stringArrayBuffer, offset, 0, Math.min(stringArrayBuffer.byteLength, maxLengthWhenEncoded));
}
static setMagic(arrayBuffer, offset, magic, magicEncoding) {
return Util.setString(arrayBuffer, offset, magic, magicEncoding, Number.MAX_SAFE_INTEGER);
}
// Check magic that's provided by a nice, human-readable string
static checkMagic(arrayBuffer, offset, magic, magicEncoding) {
const magicTextDecoder = new TextDecoder(magicEncoding);
const magicFound = magicTextDecoder.decode(arrayBuffer.slice(offset, offset + magic.length));
if (magicFound !== magic) {
throw new Error(`Save appears corrupted: found '${magicFound}' instead of '${magic}'`);
}
}
// Check magic that contains problematic bytes that aren't human-readable
static checkMagicBytes(arrayBuffer, offset, magic) {
const dataView = new DataView(arrayBuffer);
for (let i = 0; i < magic.length; i += 1) {
const magicFound = dataView.getUint8(offset + i);
if (magicFound !== magic[i]) {
throw new Error(`Save appears corrupted: found 0x${magicFound.toString(16)} instead of 0x${magic[i].toString(16)}`);
}
}
}
static setMagicBytes(arrayBuffer, offset, magic) {
const outputArrayBuffer = Util.copyArrayBuffer(arrayBuffer);
const dataView = new DataView(outputArrayBuffer);
for (let i = 0; i < magic.length; i += 1) {
dataView.setUint8(offset + i, magic[i]);
}
return outputArrayBuffer;
}
static findMagic(arrayBuffer, magic, magicEncoding, startOffset = 0) {
const magicTextDecoder = new TextDecoder(magicEncoding);
for (let offset = startOffset; offset < (arrayBuffer.byteLength - magic.length); offset += 1) {
const magicFound = magicTextDecoder.decode(arrayBuffer.slice(offset, offset + magic.length));
if (magicFound === magic) {
return offset;
}
}
throw new Error(`Save appears corrupted: could not find magic '${magic}'`);
}
static trimNull(s) {
return s.replace(/\0[\s\S]*$/g, ''); // https://stackoverflow.com/questions/22809401/removing-a-null-character-from-a-string-in-javascript
}
static getNullTerminatedArray(uint8Array, startOffset, maxLength = -1) {
let end = uint8Array.length;
if (maxLength >= 0) {
end = Math.min(end, startOffset + maxLength);
}
for (let i = startOffset; i < end; i += 1) {
if (uint8Array[i] === 0) {
return uint8Array.slice(startOffset, i);
}
}
return uint8Array.slice(startOffset, end);
}
static readString(uint8Array, startOffset, encoding, length) {
const textDecoder = new TextDecoder(encoding);
return textDecoder.decode(uint8Array.slice(startOffset, startOffset + length));
}
static readNullTerminatedString(uint8Array, startOffset, encoding, maxLength = -1) {
const textDecoder = new TextDecoder(encoding);
return textDecoder.decode(Util.getNullTerminatedArray(uint8Array, startOffset, maxLength));
}
static uint8ArrayToHex(uint8Array) {
return Buffer.from(uint8Array).toString('hex').toUpperCase();
}
static copyArrayBuffer(source) {
const destination = new ArrayBuffer(source.byteLength);
new Uint8Array(destination).set(new Uint8Array(source));
return destination;
}
static padArrayBuffer(inputArrayBuffer, desiredLength, fillValue) {
if (inputArrayBuffer.byteLength === desiredLength) {
return inputArrayBuffer;
}
if (inputArrayBuffer.byteLength > desiredLength) {
throw new Error(`Cannot pad array buffer of length ${inputArrayBuffer.byteLength} to length ${desiredLength}`);
}
const outputArrayBuffer = Util.getFilledArrayBuffer(desiredLength, fillValue);
return Util.setArrayBufferPortion(outputArrayBuffer, inputArrayBuffer, 0, 0, inputArrayBuffer.byteLength);
}
static setArrayBufferPortion(destination, source, destinationOffset, sourceOffset, length) {
const destinationArray = new Uint8Array(destination);
const sourceArray = new Uint8Array(source.slice(sourceOffset, sourceOffset + length));
const output = new ArrayBuffer(destination.byteLength);
const outputArray = new Uint8Array(output);
outputArray.set(destinationArray);
outputArray.set(sourceArray, destinationOffset);
return output;
}
static fillArrayBufferPortion(arrayBuffer, startIndex, length, fillValue) {
const outputArrayBuffer = new ArrayBuffer(arrayBuffer.byteLength);
const outputArray = new Uint8Array(outputArrayBuffer);
const inputArray = new Uint8Array(arrayBuffer);
outputArray.set(inputArray);
outputArray.fill(fillValue, startIndex, startIndex + length);
return outputArrayBuffer;
}
static getFilledArrayBuffer(length, fillValue) {
const outputArrayBuffer = new ArrayBuffer(length);
const outputArray = new Uint8Array(outputArrayBuffer);
outputArray.fill(fillValue);
return outputArrayBuffer;
}
static fillArrayBuffer(arrayBuffer, fillValue) {
return Util.getFilledArrayBuffer(arrayBuffer.byteLength, fillValue);
}
static arrayBuffersEqual(arrayBuffer1, arrayBuffer2) {
if (arrayBuffer1.byteLength !== arrayBuffer2.byteLength) {
return false;
}
const u81 = new Uint8Array(arrayBuffer1);
const u82 = new Uint8Array(arrayBuffer2);
const unequalIndex = u81.find((element, index) => u81[index] !== u82[index]);
return (unequalIndex === undefined);
}
static allBytesEqual(arrayBuffer, value) {
const u8 = new Uint8Array(arrayBuffer);
const unequalIndex = u8.find((element) => element !== value);
return (unequalIndex === undefined);
}
static copyHeaderFromArrayBuffer(sourceArrayBuffer, headerByteCount, destinationArrayBuffer) {
const headerArrayBuffer = sourceArrayBuffer.slice(0, headerByteCount);
return Util.concatArrayBuffers([headerArrayBuffer, destinationArrayBuffer]);
}
static copyFooterFromArrayBuffer(sourceArrayBuffer, footerByteCount, destinationArrayBuffer) {
const footerArrayBuffer = sourceArrayBuffer.slice(-footerByteCount);
return Util.concatArrayBuffers([destinationArrayBuffer, footerArrayBuffer]);
}
}
================================================
FILE: frontend/src/views/About.vue
================================================
GameFAQs
Lots of saves for lots of games.
The{{'\xa0'}}Tech{{'\xa0'}}Game (registration{{'\xa0'}}required)
Lots of games available. Also has save states for specific emulators/flash carts.
Brewology
Has saves for some specific systems:
PSP,{{'\xa0'}}PS3,{{'\xa0'}}WiiApollo Save Game Database
Has saves for some specific systems:
PS1,{{'\xa0'}}
PS2,{{'\xa0'}}
PS3,{{'\xa0'}}
PS4,{{'\xa0'}}
PSP,{{'\xa0'}}
PS VitaZophar's{{'\xa0'}}Domain
Mostly save states, but some save files as well.
GBAtemp
Have to dig through lots of hacks, tools, etc., but there's also saves for various games.
MemCard Pro Packs
Lots of PS1 games.
PS2 Game Saves
Lots of PS2 games.
CodeTwink
Lots of PSP and PS2 saves.
bucanero
Lots of Dreamcast games.
Fighting Street
A small selection of Dreamcast games.
GC Saves
Lots of GameCube games.
RetroPie Game Saves
Selection of VB/DC/GCN/GB/GBA/N64/NES/PS1/SNES/PSP saves
RetroPie Saves
Selection of DC/GBA/GBC/N64/PS1/SNES saves
Retromaggedon
Small selection of games for various systems.
cdx4
Small selection of games for various systems.
EmuNations
Selection of games for various systems in various emulator formats.
64scener
Selection of Nintendo 64 games.
bucanero
Selection of Sega Saturn games.
PPcenter
Selection of Sega Saturn games.
slinga-homebrew
Selection of Sega Saturn games.
360 Haven
XBox 360 games
XP Game Saves
XBox 360 games
Fantasy Anime
Small selection of 8- 16- and 32-bit RPGs.
The{{'\xa0'}}Mushroom{{'\xa0'}}Kingdom
Small selection of Mario games.
Zelda{{'\xa0'}}Legends
Selection of Zelda games.
http://www.save-editor.com/tools/
PS1/PS2/PSP/DS + various specific games
https://shunyweb.info/convert.php
Nintendo DS save formats
MemcardRex
Various PS1 formats. Requires{{'\xa0'}}Windows/Mac. Download{{'\xa0'}}here
Apollo Save Tool
Manage saves directly on PSP, PS Vita, PS3, or PS4Sega Saturn save converter
Various Saturn formats. Requires{{'\xa0'}}Windows. Download{{'\xa0'}}hereSaturn save tools
Various Saturn formats. Requires{{'\xa0'}}Windows/Mac/Linux. Download{{'\xa0'}}hereMPKEdit
N64 DexDrive and .MPK files
https://sav2vc.fm1337.com/
Pokémon Gold/Silver/Crystal save to 3DS Virtual Console
Goomba save manager
GB/GBC games on various GBA flash carts. Requires{{'\xa0'}}Windows. Download{{'\xa0'}}heregbaconv-web
Convert saves between formats used by different versions of Visual Boy Advance
RetroArch N64 Save Converter
Convert to/from Retroarch N64 saves
PS2 VMC Tool
Manage PS2 emulator memory card images. Requires{{'\xa0'}}Windows/Mac/Linux. Download{{'\xa0'}}here
================================================
FILE: frontend/tests/config.js
================================================
const defaultConfig = {
testPspIsos: true,
testPspRetailIsos: false, // We don't have these committed to the repo for obvious reasons, so we don't want the continuous deployment tests to test for them
testFlashCartRetailGames: false, // These are also not committed to the repo for obvious reasons
};
export default class Config {
static Create() {
try {
const localOverrides = require('./config.local.js'); // eslint-disable-line global-require, import/no-unresolved, import/extensions
return new Config({
...defaultConfig,
...localOverrides.default,
});
} catch {
// Fall though
}
return new Config(defaultConfig);
}
constructor(configData) {
this.configData = configData;
}
get() {
return this.configData;
}
}
================================================
FILE: frontend/tests/config.local.js.example
================================================
export default {
testPspIsos: true,
testPspRetailIsos: false,
testFlashCartRetailGames: true,
};
================================================
FILE: frontend/tests/data/rom-formats/psp/encrypted-executable-alternative-boot - EBOOT.DNR
================================================
~PSPThis is a test encrypted executable in an alternative boot location with magic0
================================================
FILE: frontend/tests/data/rom-formats/psp/encrypted-executable-other-alternative-boot - GBL
================================================
~PSPThis is a test encrypted executable in an other alternative boot location with magic0
================================================
FILE: frontend/tests/data/save-formats/flashcarts/n64/neon64emulator/Zelda II - The Adventure of Link (USA)-neon.srm
================================================
yx;JV&