├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── run_tests.sh ├── saveswap.py ├── setup.py ├── test_saveswap.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | setup.py 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .coverage 3 | .mypy_cache 4 | .tox 5 | *.egg-info 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: false 3 | cache: pip 4 | language: python 5 | python: 6 | - "2.7" 7 | - "3.3" 8 | - "3.4" 9 | - "3.5" 10 | - "pypy3" 11 | install: 12 | - pip install coveralls pytest-cov 13 | - if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then pip3 install mypy; fi 14 | script: 15 | - py.test --cov-branch --cov=. --cov-report=term-missing 16 | - if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then mypy --py2 --strict-optional --ignore-missing-imports ./*.py; fi 17 | - if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then mypy --strict-optional --ignore-missing-imports ./*.py; fi 18 | after_success: coveralls 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy of 2 | this software and associated documentation files (the "Software"), to deal in 3 | the Software without restriction, including without limitation the rights to 4 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | N64-Saveswap 3 | ============ 4 | 5 | .. image:: https://travis-ci.org/ssokolow/saveswap.svg?branch=master 6 | :target: https://travis-ci.org/ssokolow/saveswap 7 | .. image:: https://coveralls.io/repos/github/ssokolow/saveswap/badge.svg?branch=master 8 | :target: https://coveralls.io/github/ssokolow/saveswap?branch=master 9 | .. image:: https://scrutinizer-ci.com/g/ssokolow/saveswap/badges/quality-score.png?b=master 10 | :target: https://scrutinizer-ci.com/g/ssokolow/saveswap/?branch=master 11 | :alt: Scrutinizer Code Quality 12 | .. image:: https://codeclimate.com/github/ssokolow/saveswap/badges/gpa.svg 13 | :target: https://codeclimate.com/github/ssokolow/saveswap 14 | :alt: Code Climate 15 | 16 | A simple command-line utility for manipulating the endianness of (A.K.A. 17 | byte-swapping) Nintendo 64 save data so it can be moved between various 18 | cartridge-dumping tools, emulators, flash cartridges, etc. 19 | 20 | Features: 21 | 22 | * Supports multiple types of byte-swapping 23 | * Codebase is *very* well-commented because I'd originally intended to offer it 24 | as a learning aid for moving to Python before I got carried away. 25 | * Should run on any platform with a Python 2.7 or 3.x runtime 26 | * Only dependency is the Python standard library 27 | * Unit and functional test suite with 100% branch coverage 28 | 29 | Also usable for swapping other formats if the ``--force-padding`` switch is 30 | used to disable size checks. (With the caveat that it will load the entire file 31 | into memory and make copies.) 32 | 33 | Very loosely based on saturnu's 34 | `ED64-Saveswap `_ because I was 35 | feeling wary about virus-scanner false positives and wanted something I didn't 36 | have to run in Wine to use on Linux. 37 | 38 | ----- 39 | Usage 40 | ----- 41 | 42 | For basic conversion of a save memory dump file, the default settings should do 43 | perfectly well and usage is as follows: 44 | 45 | 1. Make sure you have a `Python runtime `_ 46 | installed. 47 | 2. Run ``saveswap.py`` with the path to the save dump as its argument. 48 | 49 | On Windows, that may look like this: 50 | 51 | 1. Install the Windows version of Python. 52 | 2. Copy the save dump into the same folder as ``saveswap.py``. 53 | 3. Open a terminal/command window (eg. ``cmd.exe``) in that directory. 54 | 4. Run ``saveswap.py NAME_OF_DUMP_FILE`` 55 | 56 | :: 57 | 58 | C:\Users\Me> cd Desktop 59 | C:\Users\Me\Desktop> saveswap.py MyNintendyGame.eep 60 | 61 | For more advanced functions, please consult the ``--help`` output: 62 | 63 | :: 64 | 65 | usage: saveswap.py [-h] [--version] [-v] [-q] 66 | [--swap-mode {both,bytes-only,words-only}] 67 | [--force-padding NEW_SIZE] 68 | [--no-backup] 69 | path [path ...] 70 | 71 | A simple utility to translate among the SRAM/EEPROM/Flash dump formats for 72 | Nintendo 64 cartridges as supported by various dumpers, emulators, and flash 73 | cartridges. 74 | 75 | positional arguments: 76 | path One or more Nintendo 64 save memory dumps 77 | to byte-swap 78 | 79 | optional arguments: 80 | -h, --help show this help message and exit 81 | --version show program's version number and exit 82 | -v, --verbose Increase the verbosity. Use twice for extra effect. 83 | -q, --quiet Decrease the verbosity. Use twice for extra effect. 84 | --swap-mode {both,bytes-only,words-only} 85 | Set the type of byte-swapping to be performed. 86 | --force-padding NEW_SIZE 87 | Override autodetected padding size. This also 88 | disables the associated safety checks, allowing 89 | this tool to be used on other types of files. 90 | Specify 0 to disable padding entirely. 91 | --no-backup Disable creation of an automatic backup. This is 92 | intended to be used by scripts which want 93 | more control over whether and where backups 94 | are created. 95 | 96 | The swap modes behave as follows: 97 | both: 12 34 -> 43 21 98 | bytes-only: 12 34 -> 21 43 99 | words-only: 12 34 -> 34 12 100 | 101 | The valid padding sizes for N64 save dumps are as follows: 102 | ===== EEPROM ===== 103 | 512 ( 4kbit) 104 | 2048 ( 16kbit) 105 | ====== SRAM ====== 106 | 32768 (256kbit) 107 | 131072 ( 1Mbit) 108 | ===== Flash ====== 109 | 131072 ( 1Mbit) 110 | 111 | For scripting purposes, the exit code will indicate the most 112 | serious error encountered, with the following meanings: 113 | 0 = Success 114 | 10 = Could not read file / Could not write backup 115 | 20 = File is too large to be an N64 save dump 116 | 30 = File size is not a multiple of the requested swap increment 117 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd "$(dirname "$(readlink -f "$0")")" 4 | 5 | prospector ./*.py 6 | mypy --py2 --ignore-missing-imports ./*.py 7 | mypy --ignore-missing-imports ./*.py 8 | python2 -m py.test --cov-branch --cov=. --cov-report=term-missing 9 | python3 -m py.test --cov-branch --cov=. --cov-report=term-missing 10 | -------------------------------------------------------------------------------- /saveswap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | A simple utility to translate among the SRAM/EEPROM/Flash dump formats for 5 | Nintendo 64 cartridges as supported by various dumpers, emulators, and flash 6 | cartridges. 7 | 8 | --snip-- 9 | 10 | A simple Python reimplementation of saturnu's ED64-Saveswap utility as posted 11 | at https://github.com/sanni/cartreader/tree/master/extras/saveswap 12 | 13 | The purpose of this tool is to achieve the following additional goals: 14 | 1. Be scriptable 15 | 2. Also run on non-Windows platforms 16 | 3. Don't trigger false positives for malware on VirusTotal.com 17 | 4. Be a very well-documented example for anyone who might want to switch 18 | from AutoIt to Python for this sort of tool. 19 | 20 | I have chosen the name "N64-Saveswap" in recognition that the EverDrive 64 21 | is not the only piece of hardware which may require the use of this tool 22 | and to avoid confusion with saturnu's original utility. 23 | 24 | While I did not engage in clean-room reverse-engineering, I believe this to be 25 | its own work in the eyes of the law for the following combination of reasons: 26 | 27 | 1. Not only is the code in an entirely different language and relying heavily 28 | on constructs which AutoIt's scripting language has no equivalent for, 29 | the similarities which do exist are so fundamental to the task being 30 | performed that they CANNOT be eliminated. 31 | 32 | (eg. Yes, I swap bytes and pad files, but even those basic operations are 33 | done differently, owing to flaws and ugliness in AutoIt's language which 34 | I was able to avoid in Python.) 35 | 36 | 2. My efforts to replicate the program's UI have been done entirely by working 37 | from the screenshots in this thread: 38 | http://krikzz.com/forum/index.php?topic=1396.0 39 | 40 | Copyright 2017 Stephan Sokolow 41 | 42 | Permission is hereby granted, free of charge, to any person obtaining a copy of 43 | this software and associated documentation files (the "Software"), to deal in 44 | the Software without restriction, including without limitation the rights to 45 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 46 | of the Software, and to permit persons to whom the Software is furnished to do 47 | so, subject to the following conditions: 48 | 49 | The above copyright notice and this permission notice shall be included in all 50 | copies or substantial portions of the Software. 51 | 52 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 53 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 54 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 55 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 56 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 57 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 58 | SOFTWARE. 59 | """ 60 | 61 | from __future__ import (absolute_import, division, print_function, 62 | with_statement, unicode_literals) 63 | 64 | __author__ = "Stephan Sokolow" 65 | __license__ = "MIT" 66 | __appname__ = "N64-Saveswap" 67 | __version__ = "0.1" 68 | 69 | # A list of valid dump sizes, used for safety checks and padding 70 | VALID_SIZES = [512, 2048, 32768, 131072] 71 | 72 | import logging, os, shutil, sys, textwrap 73 | log = logging.getLogger(__name__) 74 | 75 | if sys.version_info.major > 2: # pragma: nocover 76 | def reload(_): # pylint: disable=redefined-builtin 77 | """Silence a spurious 'Undefined variable' error in Landscape.io""" 78 | pass 79 | 80 | # A little safety guard against programmer error 81 | assert all(x % 4 == 0 for x in VALID_SIZES), "VALID_SIZES contains bad value" 82 | VALID_SIZES.sort() 83 | 84 | class FileTooBig(Exception): 85 | """Exception raised for files bigger than the last entry in VALID_SIZES""" 86 | 87 | class FileIncomplete(Exception): 88 | """Exception raised for files not a multiple of the swap increment.""" 89 | 90 | def rejoin_bytes(sequence): # type: (...) -> bytes 91 | """Create a bytestring from whatever you get by iterating on one. 92 | 93 | This is a workaround for the following situation: 94 | 95 | 1. In Python 2.7, iterating on a bytestring will give you a sequence of 96 | characters, which you rejoin with ``b''.join(sequence)`` and calling 97 | ``bytes([65 , 66, 67])`` gives you a string containing 98 | ``[65, 66, 67]``. 99 | 100 | 2. In Python 3.x, iterating on a bytestring will give you a sequence of 101 | integers, which you rejoin with ``bytes(sequence)`` and 102 | ``b''.join(sequence)`` will raise an error because it doesn't know 103 | how to join integers. 104 | 105 | ...just be thankful I didn't get fed up enough to use 106 | .decode('latin1') and .encode('latin1') to side-step it by pretending that 107 | the Unicode codepoints corresponding to the latin-1 encoding are raw byte 108 | values. 109 | 110 | (Not a good idea, if you can avoid it, because it wastes memory supporting 111 | values you'll never use, wastes CPU time converting both ways, and, if you 112 | pick an encoding that can't represent all 256 possible byte values or 113 | accidentally introduce Unicode values that can't be mapped back, you'll 114 | set yourself up for unpleasant surprises.) 115 | """ 116 | if sys.version_info.major < 3: 117 | # Python 2.7 118 | return b''.join(sequence) # pragma: nocover 119 | else: 120 | # Python 3.x and beyond 121 | return bytes(sequence) # pragma: nocover 122 | 123 | def calculate_padding(path): # type: (str) -> int 124 | """Calculate the size that a dump file should be padded to. 125 | 126 | :Parameters: 127 | path : `str` 128 | The path to the file to be rewritten. 129 | 130 | :rtype: `int` 131 | :returns: The target file size after padding 132 | 133 | :raises OSError: ``path`` does not exist. 134 | :raises FileTooBig: The file is already bigger than the largest valid size. 135 | """ 136 | # Get the file size in bytes or raise OSError 137 | file_size = os.path.getsize(path) 138 | 139 | # Walking from smallest to largest (see definition of VALID_SIZES), 140 | # return the first value from VALID_SIZES that matches or exceeds 141 | # the file's current size 142 | for size in VALID_SIZES: 143 | if size >= file_size: 144 | return size 145 | 146 | # If we got this far, file_size is bigger than the biggest size in the list 147 | raise FileTooBig("File already exceeds largest valid size." 148 | "({} > {})".format(file_size, VALID_SIZES[-1])) 149 | 150 | def assert_stride(data, stride_len): 151 | """Helper to deduplicate check that data length is a multiple of an int""" 152 | file_len = len(data) 153 | if not file_len % stride_len == 0: 154 | raise FileIncomplete("File length is not divisible by {}: {}" 155 | "".format(stride_len, file_len)) 156 | 157 | def byteswap(path, swap_bytes=True, swap_words=True, pad_to=0): 158 | # type: (str, bool, bool, int) -> None 159 | """Perform requested swapping operations on the given file. 160 | 161 | A backup file will be generated by appending ``.bak`` to the path. 162 | 163 | :Parameters: 164 | path : `str` 165 | The path to the file to be rewritten. 166 | swap_bytes : `bool` 167 | If ``True``, treat the file as a sequence of 16-bit words and swap 168 | their endianness. 169 | swap_words : `bool` 170 | If ``True`` treat the file as a sequence of 32-bit words made of 16-bit 171 | components and swap those. May be combined with ``swap_bytes`` for a 172 | traditional "reverse the endianness of 32-bit words made of bytes" 173 | operation. 174 | pad_to : `int` 175 | If specified, append null bytes before writing to ensure the file is 176 | at least this length. 177 | 178 | :raises TypeError: The value of ``path`` is not a string. 179 | :raises IOError: Failure when attempting to read/write a file. 180 | :raises FileIncomplete: 181 | The length of the file isn't a multiple of the requested swapping 182 | increment. 183 | """ 184 | # Given how small these are, let's just load the entire thing into memory, 185 | # manipulate it there, and then write the whole thing out again. 186 | # 187 | # It's less error-prone and it's (comparatively) slow to keep switching 188 | # into the OS kernel for a lot of little read() calls. 189 | with open(path, 'rb') as fobj_in: 190 | data = fobj_in.read() 191 | 192 | # OK, this is a little fancy, so I'll explain the parts in detail: 193 | # 194 | # [0::4] means "take every fourth character starting with the first. 195 | # [1::4] means "take every fourth character starting with the second. 196 | # "123412341234" becomes "111" and "222" and so on. 197 | # 198 | # zip() turns a list of rows into a list of columns 199 | # [["4","4","4"], ["3","3","3"], ["2","2","2"], ["1","1","1"]] 200 | # becomes 201 | # [["4","3","2","1"], ["4","3","2","1"], ["4","3","2","1"]] 202 | # 203 | # rejoin_bytes() turns a list of bytes into a bytestring. 204 | # 205 | # b''.join() turns a list of bytestrings into a single bytestring, 206 | # using "nothing" as the separator. ('' and "" are interchangeable) 207 | # ["4", "3", "2", "1"] -> "4321" 208 | # 209 | # Things of the form ``output = [something for x in data]`` are 210 | # "list comprehensions" and what you see is shorthand for: 211 | # output2 = [] 212 | # for x in output: 213 | # output2.append(rejoin_bytes(x)) 214 | # output = b''.join(output2) 215 | # del output2 216 | # 217 | # TODO: Are these files ALWAYS supposed to be multiples of 4 bytes when 218 | # dumped? If so, I should enforce that unconditionally to catch 219 | # corruption as broadly as possible. 220 | file_len = len(data) 221 | if swap_bytes: 222 | assert_stride(data, 2) 223 | data = zip(data[1::2], data[0::2]) # type: ignore 224 | data = b''.join([rejoin_bytes(x) for x in data]) 225 | 226 | if swap_words: 227 | assert_stride(data, 4) 228 | data = zip(data[2::4], data[3::4], # type: ignore 229 | data[0::4], data[1::4]) 230 | data = b''.join([rejoin_bytes(x) for x in data]) 231 | 232 | # Now, apply padding if requested 233 | # 234 | # In Python, multiplying a string by an int repeats the string. 235 | # 'Foo' * 3 -> 'FooFooFoo' 236 | if pad_to > file_len: 237 | data = data + (b'\x00' * (pad_to - file_len)) 238 | 239 | # Now, overwrite the old data with the new data 240 | # 241 | # The file will automatically be wiped clean when calling open() with 242 | # 'w' in the mode and this, while not infallible, is hard to screw up 243 | # because we only open the file after all the tricky bits are done. 244 | # 245 | with open(path, 'wb') as fobj_out: 246 | fobj_out.write(data) 247 | 248 | def process_path(path, swap_bytes=True, swap_words=True, pad_to=None, 249 | make_backup=True): 250 | """Do all necessary swapping and padding for a single file. 251 | 252 | This is separated out from `main` because it's good convention to 253 | keep your "handle one file" code in a function of its own so 254 | main() is all about processing the command-line input. 255 | 256 | See `byteswap` for additional argument documentation and exceptions raised. 257 | 258 | :Parameters: 259 | make_backup : `bool` 260 | If `True`, generate a backup file by appending `.bak` to the path. 261 | This will happen after detecting oversize files but before performing 262 | any actual work. 263 | 264 | :raises TypeError: The value of ``path`` is not a string. 265 | :raises IOError: Failure when attempting to read/write a file. 266 | :raises OSError: ``path`` does not exist (with ``pad_to=None``) 267 | """ 268 | if pad_to is None: # "None" means "Nothing specified. Guess." 269 | pad_to = calculate_padding(path) 270 | elif not pad_to: # Anything else False-y (eg. 0) means "No padding." 271 | pad_to = 0 272 | 273 | if make_backup: 274 | bak_path = path + '.bak' 275 | 276 | # Don't get fancy. Just let a well-tested routine make our backup 277 | # (copy2 also preserves metadata like modification date) 278 | shutil.copy2(path, bak_path) 279 | 280 | byteswap(path, swap_bytes, swap_words, pad_to) 281 | 282 | def main(): # type: () -> None 283 | """The main entry point, compatible with setuptools entry points.""" 284 | # If we're running on Python 2, take responsibility for preventing 285 | # output from causing UnicodeEncodeErrors. (Done here so it should only 286 | # happen when not being imported by some other program.) 287 | if sys.version_info.major < 3: # pragma: nocover 288 | # pylint: disable=no-member 289 | reload(sys) # type: ignore 290 | sys.setdefaultencoding('utf-8') # type: ignore 291 | 292 | # Define a command-line argument parser which handles things like --help 293 | # and enforcing requirements for what arguments must be provided 294 | from argparse import ArgumentParser, RawDescriptionHelpFormatter 295 | parser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter, 296 | description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0], 297 | epilog=textwrap.dedent(""" 298 | The swap modes behave as follows: 299 | both: 12 34 -> 43 21 300 | bytes-only: 12 34 -> 21 43 301 | words-only: 12 34 -> 34 12 302 | 303 | The valid padding sizes for N64 save dumps are as follows: 304 | ===== EEPROM ===== 305 | 512 ( 4kbit) 306 | 2048 ( 16kbit) 307 | ====== SRAM ====== 308 | 32768 (256kbit) 309 | 131072 ( 1Mbit) 310 | ===== Flash ====== 311 | 131072 ( 1Mbit) 312 | 313 | For scripting purposes, the exit code will indicate the most 314 | serious error encountered, with the following meanings: 315 | 0 = Success 316 | 10 = Could not read file / Could not write backup 317 | 20 = File is too large to be an N64 save dump 318 | 30 = File size is not a multiple of the requested swap increment 319 | """)) 320 | 321 | parser.add_argument('--version', action='version', 322 | version="%%(prog)s v%s" % __version__) 323 | parser.add_argument('-v', '--verbose', action="count", 324 | default=2, help="Increase the verbosity. Use twice for extra effect.") 325 | parser.add_argument('-q', '--quiet', action="count", 326 | default=0, help="Decrease the verbosity. Use twice for extra effect.") 327 | 328 | parser.add_argument('--swap-mode', action="store", default='both', 329 | choices=('both', 'bytes-only', 'words-only'), 330 | help="Set the type of byte-swapping to be performed.") 331 | parser.add_argument('--force-padding', action="store", dest="pad_to", 332 | metavar="NEW_SIZE", default=None, type=int, 333 | help="Override autodetected padding size. This also disables the " 334 | " associated safety checks, allowing this tool to be used on " 335 | " other types of files. Specify 0 to disable padding entirely.") 336 | parser.add_argument('--no-backup', action="store_false", dest="backup", 337 | default=True, 338 | help="Disable creation of an automatic backup. This is intended to " 339 | "be used by scripts which want more control over whether and " 340 | "where backups are created.") 341 | parser.add_argument('path', nargs='+', 342 | help="One or more Nintendo 64 save memory dumps to byte-swap") 343 | 344 | # Parse the command-line 345 | args = parser.parse_args() 346 | 347 | # Set up clean logging to stderr which listens to -v and -q 348 | log_levels = [logging.CRITICAL, logging.ERROR, logging.WARNING, 349 | logging.INFO, logging.DEBUG] 350 | args.verbose = min(args.verbose - args.quiet, len(log_levels) - 1) 351 | args.verbose = max(args.verbose, 0) 352 | logging.basicConfig(level=log_levels[args.verbose], # type: ignore 353 | format='%(levelname)s: %(message)s') 354 | 355 | # Adapt the external interface to the internal one and process each file 356 | retcode = 0 357 | for path in args.path: 358 | log.info("Processing %s...", path) 359 | try: 360 | process_path(path=path, 361 | swap_bytes=args.swap_mode in ('both', 'bytes-only'), 362 | swap_words=args.swap_mode in ('both', 'words-only'), 363 | pad_to=args.pad_to, 364 | make_backup=args.backup) 365 | 366 | # Return the most serious error code we encountered 367 | except (IOError, OSError) as err: 368 | retcode = max(retcode, 10) 369 | log.error("Error while trying to read file: %s\n\t%s", path, err) 370 | except FileTooBig as err: 371 | retcode = max(retcode, 20) 372 | log.error("File is too big to be an N64 save dump: %s", path) 373 | except FileIncomplete as err: 374 | retcode = max(retcode, 30) 375 | log.error("File is incompatible with requested swap: %s", path) 376 | 377 | if retcode != 0: 378 | sys.exit(retcode) 379 | 380 | # If we're being run directly rather than `import`-ed, run the code in `main()` 381 | if __name__ == '__main__': 382 | main() # pragma: nocover 383 | 384 | # vim: set sw=4 sts=4 expandtab : 385 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """setup.py for N64-Saveswap""" 2 | 3 | import io, os, re 4 | from setuptools import setup 5 | 6 | __author__ = "Stephan Sokolow" 7 | __license__ = "MIT" 8 | 9 | # Get the version from the program rather than duplicating it here 10 | # Source: https://packaging.python.org/en/latest/single_source_version.html 11 | def read(*names, **kwargs): 12 | """Convenience wrapper for read()ing a file""" 13 | with io.open(os.path.join(os.path.dirname(__file__), *names), 14 | encoding=kwargs.get("encoding", "utf8")) as fobj: 15 | return fobj.read() 16 | 17 | def find_version(*file_paths): 18 | """Extract the value of __version__ from the given file""" 19 | version_file = read(*file_paths) 20 | version_match = re.search(r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", 21 | version_file, re.M) 22 | if version_match: 23 | return version_match.group(1) 24 | raise RuntimeError("Unable to find version string.") 25 | 26 | setup( 27 | name='n64_saveswap', 28 | version=find_version("saveswap.py"), 29 | author='Stephan Sokolow', 30 | author_email='http://ssokolow.com/ContactMe', 31 | description='Utility for byte-swapping Nintendo 64 save data', 32 | long_description=read("README.rst"), 33 | url="http://github.com/ssokolow/saveswap/", 34 | 35 | classifiers=[ 36 | 'Development Status :: 4 - Beta', 37 | 'Environment :: Console', 38 | 'Intended Audience :: End Users/Desktop', 39 | 'Intended Audience :: Developers', 40 | 'License :: OSI Approved :: MIT License', 41 | 'Natural Language :: English', 42 | 'Operating System :: OS Independent', 43 | 'Programming Language :: Python :: 2.7', 44 | 'Programming Language :: Python :: 3', 45 | 'Topic :: Utilities', 46 | ], 47 | keywords=('byteswap byteswapping endian endianness dump n64 sram ' 48 | 'eeprom flash'), 49 | license="MIT", 50 | py_modules=['saveswap'], 51 | entry_points={ 52 | 'console_scripts': [ 53 | 'saveswap=saveswap:main', 54 | ], 55 | }, 56 | ) 57 | 58 | # vim: set sw=4 sts=4 expandtab : 59 | -------------------------------------------------------------------------------- /test_saveswap.py: -------------------------------------------------------------------------------- 1 | """Test suite for saveswap.py 2 | 3 | As this relies on helpers from py.test, it must be run with ``py.test``. 4 | """ 5 | 6 | from __future__ import (absolute_import, division, print_function, 7 | with_statement, unicode_literals) 8 | 9 | __author__ = "Stephan Sokolow" 10 | __license__ = "MIT" 11 | __appname__ = "N64-Saveswap" 12 | __version__ = "0.0pre0" 13 | 14 | import os, sys 15 | from contextlib import contextmanager 16 | 17 | from saveswap import (calculate_padding, byteswap, main, process_path, 18 | FileIncomplete, FileTooBig) 19 | 20 | @contextmanager 21 | def set_argv(args): 22 | """Context manager to temporarily modify sys.argv""" 23 | old_argv = sys.argv 24 | try: 25 | sys.argv = [sys.argv[0]] + [str(x) for x in args] 26 | yield 27 | finally: 28 | sys.argv = old_argv 29 | 30 | def test_calculate_padding(tmpdir): 31 | """Test that calculate_padding works as expected""" 32 | import pytest 33 | test_file = tmpdir.join("fake_dump") 34 | 35 | for start, expected in ( 36 | (100, 512), (500, 2048), (1000, 32768), (10000, 131072)): 37 | test_file.write("1234" * start) 38 | assert calculate_padding(str(test_file)) == expected 39 | 40 | test_file.write("1234" * 100000) 41 | with pytest.raises(FileTooBig): 42 | calculate_padding(str(test_file)) 43 | 44 | def test_byteswap(tmpdir): 45 | """Test that byteswap produces the expected output""" 46 | test_file = tmpdir.join("fake_dump") 47 | 48 | # Test the various modes 49 | for options, expected in ( 50 | ({}, "4321"), 51 | ({'swap_bytes': False}, "3412"), 52 | ({'swap_words': False}, "2143"), 53 | ({'swap_bytes': False, 'swap_words': False}, "1234")): 54 | test_file.write("1234" * 10) 55 | byteswap(str(test_file), **options) 56 | assert test_file.read() == expected * 10 57 | 58 | def test_byteswap_padding(tmpdir): 59 | """Test that byteswap pads as intended""" 60 | test_file = tmpdir.join("fake_dump") 61 | test_file.write("1234" * 100000) 62 | byteswap(str(test_file), pad_to=500000) 63 | assert test_file.read() == ("4321" * 100000) + ("\x00" * 100000) 64 | 65 | def test_byteswap_with_incomplete(tmpdir): 66 | """Test that byteswap reacts properly to file sizes with remainders 67 | 68 | (ie. file sizes that are not evenly divisible by 2 or 4) 69 | """ 70 | import pytest 71 | test_file = tmpdir.join("fake_dump") 72 | 73 | # Define a function which will be called for each combination of inputs 74 | def test_callback(_bytes, _words, pad_to): 75 | """Function called many times by _vary_check_swap_inputs""" 76 | # Test that both types of swapping error out on odd-numbered lengths 77 | test_file.write("12345") 78 | if _bytes or _words: 79 | with pytest.raises(FileIncomplete): 80 | byteswap(str(test_file), _bytes, _words, pad_to) 81 | 82 | test_file.write("123456") 83 | if _words: 84 | with pytest.raises(FileIncomplete): 85 | byteswap(str(test_file), _bytes, _words, pad_to) 86 | else: 87 | byteswap(str(test_file), False, _words, pad_to) 88 | 89 | # Let _vary_check_swap_inputs call test_callback once for each combination 90 | _vary_check_swap_inputs(test_callback) 91 | 92 | def test_process_path_autopad_error(tmpdir): 93 | """Test that process_path reacts to pad_to=None properly on error""" 94 | import pytest 95 | test_file = tmpdir.join("fake_dump") 96 | backup_path = str(test_file) + '.bak' 97 | 98 | test_file.write("1234" * 100000) 99 | assert not os.path.exists(backup_path) 100 | with pytest.raises(FileTooBig): 101 | process_path(str(test_file), pad_to=None) 102 | assert test_file.read() == "1234" * 100000 # Unchanged on error 103 | assert not os.path.exists(backup_path) # No backup on oversize 104 | 105 | def test_process_path_padding(tmpdir): 106 | """Test that process_path pads properly""" 107 | test_file = tmpdir.join("fake_dump") 108 | backup_path = str(test_file) + '.bak' 109 | 110 | test_file.write("1234" * 100000) 111 | process_path(str(test_file), pad_to=500000) 112 | assert test_file.read() == ("4321" * 100000) + ("\x00" * 100000) 113 | assert os.path.exists(backup_path) 114 | 115 | def test_process_path_nopad(tmpdir): 116 | """Test that process_path reacts to pad_to=0 properly""" 117 | test_file = tmpdir.join("fake_dump") 118 | backup_path = str(test_file) + '.bak' 119 | 120 | test_file.write("1234" * 100000) 121 | process_path(str(test_file), pad_to=0) 122 | assert test_file.read() == "4321" * 100000 123 | assert os.path.exists(backup_path) 124 | 125 | def check_main_retcode(args, code): 126 | """Helper for testing return codes from main()""" 127 | try: 128 | with set_argv(args): 129 | main() 130 | except SystemExit as err: 131 | assert err.code == code 132 | 133 | def test_main_works(tmpdir): 134 | """Functional test for basic main() use""" 135 | test_file = tmpdir.join("fake_dump") 136 | backup_path = str(test_file) + '.bak' 137 | 138 | # Test successful runs 139 | for pat_reps, options, expect_pat, expect_len, backup in ( 140 | (500, [], '4321', 2048, False), 141 | (100, ['--swap-mode=words-only'], '3412', 512, True), 142 | (1000, ['--swap-mode=bytes-only'], '2143', 32768, False), 143 | (1000, ['--force-padding=0', 144 | '--swap-mode=bytes-only'], '2143', 4000, False), 145 | (100000, ['--force-padding=500000'], '4321', 500000, True)): 146 | 147 | bkopt = [] if backup else ['--no-backup'] 148 | test_file.write("1234" * pat_reps) 149 | with set_argv(options + bkopt + [test_file]): 150 | main() 151 | assert test_file.read() == (expect_pat * pat_reps) + ( 152 | "\x00" * (expect_len - (4 * pat_reps))) 153 | if backup: 154 | assert os.path.exists(backup_path) 155 | os.remove(backup_path) 156 | 157 | def test_main_missing_file(tmpdir): 158 | """Functional test for main() with nonexistant path""" 159 | missing_path = str(tmpdir.join("missing_file")) 160 | check_main_retcode([missing_path], 10) 161 | assert not os.path.exists(missing_path + '.bak') 162 | 163 | def test_main_error_returns(tmpdir): 164 | """Functional test for main() with erroring input""" 165 | test_file = tmpdir.join("fake_dump") 166 | backup_path = str(test_file) + '.bak' 167 | 168 | assert not os.path.exists(backup_path) 169 | test_file.write("1234" * 100000) # Too big 170 | check_main_retcode([test_file], 20) 171 | assert not os.path.exists(backup_path) 172 | 173 | test_file.write("12345") # Not evenly disible by 2 174 | check_main_retcode([test_file], 30) 175 | # TODO: Fix the code so the backup is removed on this failure 176 | 177 | def _vary_check_swap_inputs(callback): 178 | """Helper to avoid duplicating stuff within test_byteswap_with_incomplete 179 | 180 | You want to be careful about this, because the number of tests run goes up 181 | exponentially, but with small numbers of combinations, it's very useful. 182 | """ 183 | for _bytes in (True, False): 184 | for _words in (True, False): 185 | for _padding in (0, 1000, 2048): 186 | callback(_bytes, _words, _padding) 187 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py31 3 | 4 | [testenv] 5 | deps= 6 | pytest 7 | commands= 8 | py.test 9 | --------------------------------------------------------------------------------