├── .editorconfig ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── mpq ├── __init__.py ├── python_wrapper.hpp ├── stormmodule.cc └── stormmodule.h ├── setup.cfg ├── setup.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.py] 10 | indent_style = tab 11 | indent_size = 4 12 | quote_type = double 13 | spaces_around_brackets = none 14 | spaces_around_operators = true 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # python generated files 3 | *.pyc 4 | 5 | # setup.py generated files 6 | build/ 7 | dist/ 8 | *.egg-info/ 9 | .eggs/ 10 | .cache/ 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Coding style 2 | 3 | Please adhere to the coding style throughout the project. 4 | 5 | 1. Always use tabs. [Here](https://leclan.ch/tabs) is a short explanation why tabs are preferred. 6 | 2. Always use double quotes for strings, unless single quotes avoid unnecessary escapes. 7 | 3. When in doubt, [PEP8](https://www.python.org/dev/peps/pep-0008/). Follow its naming conventions. 8 | 4. Know when to make exceptions. 9 | 10 | Also see: [How to name things in programming](http://www.slideshare.net/pirhilton/how-to-name-things-the-hardest-problem-in-programming) 11 | 12 | 13 | ### Commits and Pull Requests 14 | 15 | Keep the commit log as healthy as the code. It is one of the first places new contributors will look at the project. 16 | 17 | 1. No more than one change per commit. There should be no changes in a commit which are unrelated to its message. 18 | 2. Every commit should pass all tests on its own. 19 | 3. Follow [these conventions](http://chris.beams.io/posts/git-commit/) when writing the commit message 20 | 21 | When filing a Pull Request, make sure it is rebased on top of most recent master. 22 | If you need to modify it or amend it in some way, you should always appropriately [fixup](https://help.github.com/articles/about-git-rebase/) the issues in git and force-push your changes to your fork. 23 | 24 | Also see: [Github Help: Using Pull Requests](https://help.github.com/articles/using-pull-requests/) 25 | 26 | 27 | ### Need help? 28 | 29 | You can always ask for help in our IRC channel, `#Hearthsim` on [Freenode](https://freenode.net/). 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Jerome Leclanche 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include mpq/stormmodule.h 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-mpq 2 | 3 | Python bindings for Ladislav Zezula's [StormLib](http://zezula.net/en/mpq/stormlib.html). 4 | 5 | 6 | ## Usage 7 | 8 | ### Reading MPQs 9 | 10 | ```py 11 | import mpq 12 | f = mpq.MPQFile("base-Win.MPQ") 13 | 14 | if "example.txt" in mpq: 15 | print(mpq.open("example.txt").read()) 16 | ``` 17 | 18 | ### Patching MPQs 19 | 20 | Modern MPQs support archive patching. The filename usually contains the 21 | `from` and `to` build numbers. 22 | 23 | ```py 24 | f.patch("hs-6024-6141-Win-final.MPQ") 25 | ``` 26 | 27 | ### Writing MPQs 28 | 29 | Writing MPQs is not supported. 30 | 31 | 32 | ## License 33 | 34 | This project is licensed under the terms of the MIT license. 35 | The full license text is available in the LICENSE file. 36 | -------------------------------------------------------------------------------- /mpq/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python wrapper around Storm C API bindings 3 | """ 4 | import os 5 | 6 | import pkg_resources 7 | 8 | from . import storm 9 | 10 | 11 | __version__ = pkg_resources.require("mpq")[0].version 12 | 13 | 14 | class MPQFile(object): 15 | """ 16 | An MPQ archive 17 | """ 18 | ATTRIBUTES = "(attributes)" 19 | LISTFILE = "(listfile)" 20 | 21 | def __init__(self, name=None, flags=0): 22 | self.paths = [] 23 | self._archives = [] 24 | self._archive_names = {} 25 | if name is not None: 26 | self.add_archive(name, flags) 27 | 28 | def __contains__(self, name): 29 | for mpq in self._archives: 30 | if storm.SFileHasFile(mpq, name): 31 | return True 32 | return False 33 | 34 | def __repr__(self): 35 | return "<%s paths=%r>" % (self.__class__.__name__, self.paths) 36 | 37 | def _archive_contains(self, name): 38 | for mpq in self._archives: 39 | if storm.SFileHasFile(mpq, name): 40 | return mpq 41 | 42 | def _regenerate_listfile(self): 43 | self._listfile = [] 44 | for mpq in self._archives: 45 | handle, file = storm.SFileFindFirstFile(mpq, "", "*") 46 | while True: 47 | self._listfile.append(file.replace("\\", "/")) 48 | try: 49 | file = storm.SFileFindNextFile(handle) 50 | except storm.NoMoreFilesError: 51 | break 52 | 53 | def add_archive(self, name, flags=0): 54 | """ 55 | Adds an archive to the MPQFile 56 | """ 57 | priority = 0 # Unused by StormLib 58 | mpq = storm.SFileOpenArchive(name, priority, flags) 59 | self._archives.append(mpq) 60 | self._archive_names[mpq] = name 61 | self.paths.append(name) 62 | self._listfile = [] 63 | 64 | def close(self): 65 | """ 66 | Flushes all archives in the MPQFile 67 | """ 68 | for mpq in self._archives: 69 | storm.SFileCloseArchive(mpq) 70 | 71 | def flush(self): 72 | """ 73 | Flushes all archives in the MPQFile 74 | """ 75 | for mpq in self._archives: 76 | storm.SFileFlushArchive(mpq) 77 | 78 | def getinfo(self, f): 79 | """ 80 | Returns a MPQInfo object for either a path or a MPQExtFile object. 81 | """ 82 | if isinstance(f, str): 83 | f = self.open(f.replace("/", "\\")) 84 | return MPQInfo(f) 85 | 86 | def infolist(self): 87 | """ 88 | Returns a list of class MPQInfo instances for files in all the archives 89 | in the MPQFile. 90 | """ 91 | return [self.getinfo(x) for x in self.namelist()] 92 | 93 | def is_patched(self): 94 | """ 95 | Returns whether at least one of the archives in the MPQFile has been patched. 96 | """ 97 | for mpq in self._archives: 98 | if storm.SFileIsPatchedArchive(mpq): 99 | return True 100 | return False 101 | 102 | def namelist(self): 103 | """ 104 | Returns a list of file names in all the archives in the MPQFile. 105 | """ 106 | if not self._listfile: 107 | self._regenerate_listfile() 108 | return self._listfile 109 | 110 | def open(self, name, mode="r", patched=False): 111 | """ 112 | Return file-like object for \a name in mode \a mode. 113 | If \a name is an int, it is treated as an index within the MPQFile. 114 | If \a patched is True, the file will be opened fully patched, 115 | otherwise unpatched. 116 | Raises a KeyError if no file matches \a name. 117 | """ 118 | if isinstance(name, int): 119 | name = "File%08x.xxx" % (int) 120 | 121 | scope = int(bool(patched)) 122 | 123 | mpq = self._archive_contains(name) 124 | if not mpq: 125 | raise KeyError("There is no item named %r in the archive" % (name)) 126 | 127 | return MPQExtFile(storm.SFileOpenFileEx(mpq, name, scope), name) 128 | 129 | def patch(self, name, prefix=None, flags=0): 130 | """ 131 | Patches all archives in the MPQFile with \a name under prefix \a prefix. 132 | """ 133 | for mpq in self._archives: 134 | storm.SFileOpenPatchArchive(mpq, name, prefix, flags) 135 | 136 | # invalidate the listfile 137 | self._listfile = [] 138 | 139 | def extract(self, name, path=".", patched=False): 140 | """ 141 | Extracts \a name to \a path. 142 | If \a patched is True, the file will be extracted fully patched, 143 | otherwise unpatched. 144 | """ 145 | scope = int(bool(patched)) 146 | mpq = self._archive_contains(name) 147 | if not mpq: 148 | raise KeyError("There is no item named %r in the archive" % (name)) 149 | storm.SFileExtractFile(mpq, name, path, scope) 150 | 151 | def printdir(self): 152 | """ 153 | Print a table of contents for the MPQFile 154 | """ 155 | infolist = sorted(self.infolist(), key=lambda item: item.filename.lower()) 156 | longest_filename = max(infolist, key=lambda item: len(item.filename)) 157 | longest_filename = len(longest_filename.filename) 158 | format_string = "%%-%is %%12s %%12s" % (longest_filename) 159 | 160 | print(format_string % ("File Name", "Size", "Packed Size")) 161 | for x in infolist: 162 | print(format_string % (x.filename, x.file_size, x.compress_size)) 163 | 164 | def read(self, name): 165 | """ 166 | Return file bytes (as a string) for \a name. 167 | """ 168 | if isinstance(name, MPQInfo): 169 | name = name.name 170 | f = self.open(name) 171 | return f.read() 172 | 173 | def testmpq(self): 174 | pass 175 | 176 | 177 | class MPQExtFile(object): 178 | def __init__(self, file, name): 179 | self._file = file 180 | self.name = name 181 | 182 | def __repr__(self): 183 | return "%s(%r)" % (self.__class__.__name__, self.name) 184 | 185 | def _info(self, type): 186 | return storm.SFileGetFileInfo(self._file, type) 187 | 188 | def close(self): 189 | storm.SFileCloseFile(self._file) 190 | 191 | def read(self, size=None): 192 | if size is None: 193 | size = self.size() - self.tell() 194 | return storm.SFileReadFile(self._file, size) 195 | 196 | def seek(self, offset, whence=os.SEEK_SET): 197 | storm.SFileSetFilePointer(self._file, offset, whence) 198 | 199 | def size(self): 200 | return storm.SFileGetFileSize(self._file) 201 | 202 | def tell(self): 203 | return storm.SFileSetFilePointer(self._file, 0, os.SEEK_CUR) 204 | 205 | 206 | class MPQInfo(object): 207 | def __init__(self, file): 208 | self._file = file 209 | 210 | @property 211 | def basename(self): 212 | return os.path.basename(self.filename) 213 | 214 | @property 215 | def filename(self): 216 | return self._file.name.replace("\\", "/") 217 | 218 | @property 219 | def date_time(self): 220 | return self._file._info(storm.SFILE_INFO_FILETIME) 221 | 222 | @property 223 | def compress_type(self): 224 | raise NotImplementedError 225 | 226 | @property 227 | def CRC(self): 228 | raise NotImplementedError 229 | 230 | @property 231 | def compress_size(self): 232 | return self._file._info(storm.SFileInfoCompressedSize) 233 | 234 | @property 235 | def file_size(self): 236 | return self._file._info(storm.SFileInfoFileSize) 237 | -------------------------------------------------------------------------------- /mpq/python_wrapper.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | 7 | namespace python 8 | { 9 | namespace detail 10 | { 11 | namespace 12 | { 13 | //! \note If compilation fails here, check Python documentation for the 14 | //! correct character to be used, and add that to the list. 15 | //! \see https://docs.python.org/3/c-api/arg.html 16 | template struct tuple_char_for_type; 17 | #define mk_tuple_char_for_type(c_, t_) \ 18 | template<> struct tuple_char_for_type { static char const v = c_; } 19 | mk_tuple_char_for_type ('i', int); 20 | mk_tuple_char_for_type ('I', unsigned int); 21 | mk_tuple_char_for_type ('k', unsigned long); 22 | mk_tuple_char_for_type ('K', unsigned long long); 23 | mk_tuple_char_for_type ('s', char*); 24 | mk_tuple_char_for_type ('s', char const*); 25 | 26 | //! \note fixed length char arrays are usually used as c-style strings 27 | template struct tuple_char_for_type 28 | : tuple_char_for_type {}; 29 | template struct tuple_char_for_type 30 | : tuple_char_for_type {}; 31 | //! \note enums are int in c++03 32 | mk_tuple_char_for_type (tuple_char_for_type::v, SFileInfoClass); 33 | //! \note HANDLE is supposed to be opaque, and we shouldn't serialize 34 | //! \what's behind the pointer, so we just serialize the pointer value. 35 | mk_tuple_char_for_type (tuple_char_for_type::v, HANDLE); 36 | } 37 | } 38 | 39 | namespace 40 | { 41 | template 42 | bool parse_tuple (PyObject* args, char const* fun, T1* v1) { 43 | std::string const format 44 | ( std::string (1, detail::tuple_char_for_type::v) 45 | + ':' + fun 46 | ); 47 | return PyArg_ParseTuple (args, format.c_str(), v1); 48 | } 49 | template 50 | bool parse_tuple (PyObject* args, char const* fun, T1* v1, T2* v2) { 51 | std::string const format 52 | ( std::string (1, detail::tuple_char_for_type::v) 53 | + std::string (1, detail::tuple_char_for_type::v) 54 | + ':' + fun 55 | ); 56 | return PyArg_ParseTuple (args, format.c_str(), v1, v2); 57 | } 58 | template 59 | bool parse_tuple (PyObject* args, char const* fun, T1* v1, T2* v2, T3* v3) { 60 | std::string const format 61 | ( std::string (1, detail::tuple_char_for_type::v) 62 | + std::string (1, detail::tuple_char_for_type::v) 63 | + std::string (1, detail::tuple_char_for_type::v) 64 | + ':' + fun 65 | ); 66 | return PyArg_ParseTuple (args, format.c_str(), v1, v2, v3); 67 | } 68 | template 69 | bool parse_tuple (PyObject* args, char const* fun, T1* v1, T2* v2, T3* v3, T4* v4) { 70 | std::string const format 71 | ( std::string (1, detail::tuple_char_for_type::v) 72 | + std::string (1, detail::tuple_char_for_type::v) 73 | + std::string (1, detail::tuple_char_for_type::v) 74 | + std::string (1, detail::tuple_char_for_type::v) 75 | + ':' + fun 76 | ); 77 | return PyArg_ParseTuple (args, format.c_str(), v1, v2, v3, v4); 78 | } 79 | 80 | template 81 | PyObject* build_value (T1 const& v1) 82 | { 83 | std::string const format 84 | ( std::string (1, detail::tuple_char_for_type::v) 85 | ); 86 | return Py_BuildValue (format.c_str(), v1); 87 | } 88 | template<> 89 | PyObject* build_value (std::pair const& v1) 90 | { 91 | std::string const format 92 | ( "y#" 93 | ); 94 | return Py_BuildValue (format.c_str(), v1.first, v1.second); 95 | } 96 | template 97 | PyObject* build_value (T1 const& v1, T2 const& v2) 98 | { 99 | std::string const format 100 | ( std::string (1, detail::tuple_char_for_type::v) 101 | + std::string (1, detail::tuple_char_for_type::v) 102 | ); 103 | return Py_BuildValue (format.c_str(), v1, v2); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /mpq/stormmodule.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #define STORM_MODULE 3 | #include "stormmodule.h" 4 | 5 | #include "python_wrapper.hpp" 6 | 7 | #include 8 | 9 | #ifdef __cplusplus 10 | extern "C" { 11 | #endif 12 | 13 | static PyObject *StormError; 14 | static PyObject *NoMoreFilesError; 15 | 16 | /* 17 | * Manipulating MPQ archives 18 | */ 19 | 20 | static PyObject * Storm_SFileOpenArchive(PyObject *self, PyObject *args) { 21 | HANDLE mpq = NULL; 22 | TCHAR *name; 23 | DWORD priority; 24 | int flags; 25 | 26 | if (!python::parse_tuple(args, "SFileOpenArchive", &name, &priority, &flags)) { 27 | return NULL; 28 | } 29 | bool result = SFileOpenArchive(name, priority, MPQ_OPEN_READ_ONLY, &mpq); 30 | 31 | if (!result) { 32 | DWORD error = GetLastError(); 33 | switch (error) { 34 | case ERROR_FILE_NOT_FOUND: 35 | PyErr_Format(PyExc_IOError, "Could not open archive: No such file or directory: %s", name); 36 | break; 37 | 38 | default: 39 | PyErr_Format(StormError, "Error opening archive %s: %i", name, error); 40 | break; 41 | } 42 | return NULL; 43 | } 44 | 45 | return python::build_value(mpq); 46 | } 47 | 48 | static PyObject * Storm_SFileAddListFile(PyObject *self, PyObject *args) { 49 | HANDLE mpq = NULL; 50 | TCHAR *name; 51 | 52 | if (!python::parse_tuple(args, "SFileAddListFile", &mpq, &name)) { 53 | return NULL; 54 | } 55 | int result = SFileAddListFile(mpq, name); 56 | 57 | if (result != ERROR_SUCCESS) { 58 | PyErr_SetString(StormError, "Error adding listfile"); 59 | return NULL; 60 | } 61 | 62 | Py_RETURN_NONE; 63 | } 64 | 65 | static PyObject * Storm_SFileFlushArchive(PyObject *self, PyObject *args) { 66 | HANDLE mpq = NULL; 67 | 68 | if (!python::parse_tuple(args, "SFileFlushArchive", &mpq)) { 69 | return NULL; 70 | } 71 | bool result = SFileFlushArchive(mpq); 72 | 73 | if (!result) { 74 | PyErr_SetString(StormError, "Error flushing archive, archive may be corrupted!"); 75 | return NULL; 76 | } 77 | 78 | Py_RETURN_NONE; 79 | } 80 | 81 | static PyObject * Storm_SFileCloseArchive(PyObject *self, PyObject *args) { 82 | HANDLE mpq = NULL; 83 | 84 | if (!python::parse_tuple (args, "SFileCloseArchive", &mpq)) { 85 | return NULL; 86 | } 87 | bool result = SFileCloseArchive(mpq); 88 | 89 | if (!result) { 90 | PyErr_SetString(StormError, "Error closing archive"); 91 | return NULL; 92 | } 93 | 94 | Py_RETURN_NONE; 95 | } 96 | 97 | static PyObject * Storm_SFileCompactArchive(PyObject *self, PyObject *args) { 98 | HANDLE mpq = NULL; 99 | TCHAR *listfile; 100 | bool reserved = 0; /* Unused */ 101 | 102 | if (!python::parse_tuple(args, "SFileCompactArchive", &mpq, &listfile)) { 103 | return NULL; 104 | } 105 | bool result = SFileCompactArchive(mpq, listfile, reserved); 106 | 107 | if (!result) { 108 | PyErr_SetString(StormError, "Error compacting archive"); 109 | return NULL; 110 | } 111 | 112 | Py_RETURN_NONE; 113 | } 114 | 115 | /* 116 | * Using Patched archives 117 | */ 118 | 119 | static PyObject * Storm_SFileOpenPatchArchive(PyObject *self, PyObject *args) { 120 | HANDLE mpq = NULL; 121 | TCHAR *name; 122 | char *prefix; 123 | DWORD flags; 124 | 125 | if (!python::parse_tuple(args, "SFileOpenPatchArchive", &mpq, &name, &prefix, &flags)) { 126 | return NULL; 127 | } 128 | bool result = SFileOpenPatchArchive(mpq, name, prefix, flags); 129 | 130 | if (!result) { 131 | DWORD error = GetLastError(); 132 | switch (error) { 133 | case ERROR_INVALID_HANDLE: 134 | PyErr_SetString(PyExc_TypeError, "Could not patch archive: Invalid handle"); 135 | break; 136 | 137 | case ERROR_INVALID_PARAMETER: 138 | PyErr_SetString(PyExc_TypeError, "Could not patch archive: Invalid file name or patch prefix"); 139 | break; 140 | 141 | case ERROR_FILE_NOT_FOUND: 142 | PyErr_Format(PyExc_IOError, "Could not patch archive: No such file or directory: %s", name); 143 | break; 144 | 145 | case ERROR_ACCESS_DENIED: 146 | PyErr_SetString(PyExc_IOError, "Could not patch archive: Access denied"); 147 | break; 148 | 149 | default: 150 | PyErr_Format(StormError, "Could not patch archive: %i", error); 151 | break; 152 | } 153 | return NULL; 154 | } 155 | 156 | Py_RETURN_TRUE; 157 | } 158 | 159 | static PyObject * Storm_SFileIsPatchedArchive(PyObject *self, PyObject *args) { 160 | HANDLE mpq = NULL; 161 | 162 | if (!python::parse_tuple(args, "SFileIsPatchedArchive", &mpq)) { 163 | return NULL; 164 | } 165 | bool result = SFileIsPatchedArchive(mpq); 166 | 167 | if (!result) { 168 | Py_RETURN_FALSE; 169 | } 170 | 171 | Py_RETURN_TRUE; 172 | } 173 | 174 | /* 175 | * Reading Files 176 | */ 177 | 178 | static PyObject * Storm_SFileOpenFileEx(PyObject *self, PyObject *args) { 179 | HANDLE mpq = NULL; 180 | char *name; 181 | DWORD scope; 182 | HANDLE file = NULL; 183 | 184 | if (!python::parse_tuple(args, "SFileOpenFileEx", &mpq, &name, &scope)) { 185 | return NULL; 186 | } 187 | bool result = SFileOpenFileEx(mpq, name, scope, &file); 188 | 189 | if (!result) { 190 | PyErr_SetString(StormError, "Error opening file"); 191 | return NULL; 192 | } 193 | 194 | return python::build_value(file); 195 | } 196 | 197 | static PyObject * Storm_SFileGetFileSize(PyObject *self, PyObject *args) { 198 | HANDLE file = NULL; 199 | 200 | if (!python::parse_tuple(args, "SFileGetFileSize", &file)) { 201 | return NULL; 202 | } 203 | DWORD sizeHigh; 204 | DWORD sizeLow = SFileGetFileSize(file, &sizeHigh); 205 | 206 | if (sizeLow == SFILE_INVALID_SIZE) { 207 | PyErr_SetString(StormError, "Error getting file size"); 208 | return NULL; 209 | } 210 | 211 | return python::build_value ( (((uint64_t)sizeLow << 0) & 0x00000000FFFFFFFF) 212 | | (((uint64_t)sizeHigh << 32) & 0xFFFFFFFF00000000) 213 | ); 214 | } 215 | 216 | static PyObject * Storm_SFileSetFilePointer(PyObject *self, PyObject *args) { 217 | HANDLE file = NULL; 218 | uint64_t offset = 0; 219 | DWORD whence; 220 | 221 | if (!python::parse_tuple(args, "SFileSetFilePointer", &file, &offset, &whence)) { 222 | return NULL; 223 | } 224 | 225 | //! \note The StormLib API is broken here: LONG is signed, while it surely 226 | //! should be unsigned. 227 | LONG posLow = (offset & 0x00000000FFFFFFFF) >> 0; 228 | LONG posHigh = (offset & 0xFFFFFFFF00000000) >> 32; 229 | DWORD result = SFileSetFilePointer(file, posLow, &posHigh, whence); 230 | 231 | if (result == SFILE_INVALID_SIZE) { 232 | DWORD error = GetLastError(); 233 | switch (error) { 234 | case ERROR_INVALID_HANDLE: 235 | PyErr_SetString(PyExc_TypeError, "Could not seek within file: Invalid handle"); 236 | break; 237 | case ERROR_INVALID_PARAMETER: 238 | if (whence != FILE_BEGIN && whence != FILE_CURRENT && whence != FILE_END) { 239 | PyErr_Format(PyExc_TypeError, "Could not seek within file: %i is not a valid whence", whence); 240 | } else { 241 | PyErr_Format(PyExc_TypeError, "Could not seek within file: offset %lu is too large", offset); 242 | } 243 | break; 244 | default: 245 | PyErr_Format(StormError, "Error seeking in file: %i", GetLastError()); 246 | break; 247 | } 248 | return NULL; 249 | } 250 | 251 | return python::build_value ( (((uint64_t)result << 0) & 0x00000000FFFFFFFF) 252 | | (((uint64_t)posHigh << 32) & 0xFFFFFFFF00000000) 253 | ); 254 | } 255 | 256 | static PyObject * Storm_SFileReadFile(PyObject *self, PyObject *args) { 257 | HANDLE file = NULL; 258 | DWORD size; 259 | DWORD bytesRead; 260 | 261 | if (!python::parse_tuple(args, "SFileReadFile", &file, &size)) { 262 | return NULL; 263 | } 264 | 265 | std::vector buffer (size); 266 | 267 | bool result = SFileReadFile(file, buffer.data(), size, &bytesRead, NULL); 268 | 269 | if (!result) { 270 | DWORD error = GetLastError(); 271 | if (error != ERROR_HANDLE_EOF) { 272 | switch (error) { 273 | case ERROR_INVALID_HANDLE: 274 | PyErr_SetString(PyExc_TypeError, "Could not read file: Invalid handle"); 275 | break; 276 | case ERROR_FILE_CORRUPT: 277 | PyErr_SetString(PyExc_IOError, "Could not read file: File is corrupt"); 278 | break; 279 | default: 280 | PyErr_Format(StormError, "Could not read file: %i", error); 281 | break; 282 | } 283 | /* Emulate python's read() behaviour => we don't care if we go past EOF */ 284 | return NULL; 285 | } 286 | } 287 | 288 | assert (bytesRead <= (DWORD)std::numeric_limits::max()); 289 | return python::build_value(std::pair (buffer.data(), bytesRead)); 290 | } 291 | 292 | static PyObject * Storm_SFileCloseFile(PyObject *self, PyObject *args) { 293 | HANDLE file = NULL; 294 | 295 | if (!python::parse_tuple(args, "SFileCloseFile", &file)) { 296 | return NULL; 297 | } 298 | bool result = SFileCloseFile(file); 299 | 300 | if (!result) { 301 | PyErr_SetString(StormError, "Error closing file"); 302 | return NULL; 303 | } 304 | 305 | Py_RETURN_NONE; 306 | } 307 | 308 | static PyObject * Storm_SFileHasFile(PyObject *self, PyObject *args) { 309 | HANDLE mpq = NULL; 310 | char *name; 311 | 312 | if (!python::parse_tuple(args, "SFileHasFile", &mpq, &name)) { 313 | return NULL; 314 | } 315 | bool result = SFileHasFile(mpq, name); 316 | 317 | if (!result) { 318 | if (GetLastError() == ERROR_FILE_NOT_FOUND) { 319 | Py_RETURN_FALSE; 320 | } else { 321 | PyErr_SetString(StormError, "Error searching for file"); 322 | return NULL; 323 | } 324 | } 325 | 326 | Py_RETURN_TRUE; 327 | } 328 | 329 | static PyObject * Storm_SFileGetFileName(PyObject *self, PyObject *args) { 330 | HANDLE file = NULL; 331 | char name[MAX_PATH]; 332 | 333 | if (!python::parse_tuple(args, "SFileGetFileName", &file)) { 334 | return NULL; 335 | } 336 | bool result = SFileGetFileName(file, name); 337 | 338 | if (!result) { 339 | PyErr_SetString(StormError, "Error getting file name"); 340 | return NULL; 341 | } 342 | 343 | return python::build_value(name); 344 | } 345 | 346 | static PyObject * Storm_SFileGetFileInfo(PyObject *self, PyObject *args) { 347 | HANDLE file = NULL; 348 | SFileInfoClass infoClass; 349 | 350 | if (!python::parse_tuple(args, "SFileGetFileInfo", &file, &infoClass)) { 351 | return NULL; 352 | } 353 | 354 | int value = 0; 355 | DWORD size = sizeof(value); 356 | bool result = SFileGetFileInfo(file, infoClass, &value, size, 0); 357 | 358 | if (!result) { 359 | if (GetLastError() == ERROR_INVALID_PARAMETER) { 360 | PyErr_SetString(PyExc_TypeError, "Invalid INFO_TYPE queried"); 361 | return NULL; 362 | } else { 363 | PyErr_SetString(StormError, "Error getting info"); 364 | return NULL; 365 | } 366 | } 367 | 368 | return python::build_value(value); 369 | } 370 | 371 | static PyObject * Storm_SFileExtractFile(PyObject *self, PyObject *args) { 372 | HANDLE mpq = NULL; 373 | char *name; 374 | TCHAR *localName; 375 | DWORD scope; 376 | 377 | if (!python::parse_tuple(args, "SFileExtractFile", &mpq, &name, &localName, &scope)) { 378 | return NULL; 379 | } 380 | bool result = SFileExtractFile(mpq, name, localName, scope); 381 | 382 | if (!result) { 383 | if (GetLastError() == ERROR_UNKNOWN_FILE_KEY) { 384 | PyErr_Format(StormError, "Error extracting file: File Key `%s' unknown", name); 385 | return NULL; 386 | } else { 387 | PyErr_Format(StormError, "Error extracting file: %i", GetLastError()); 388 | return NULL; 389 | } 390 | } 391 | 392 | Py_RETURN_NONE; 393 | } 394 | 395 | static PyObject * Storm_SFileFindFirstFile(PyObject *self, PyObject *args) { 396 | HANDLE mpq = NULL; 397 | char *mask; 398 | SFILE_FIND_DATA findFileData; 399 | TCHAR *listFile; // XXX Unused for now 400 | 401 | if (!python::parse_tuple(args, "SFileFindFirstFile", &mpq, &listFile, &mask)) { 402 | return NULL; 403 | } 404 | HANDLE result = SFileFindFirstFile(mpq, mask, &findFileData, NULL); 405 | 406 | if (!result) { 407 | PyErr_SetString(StormError, "Error searching archive"); 408 | return NULL; 409 | } 410 | 411 | return python::build_value(result, findFileData.cFileName); 412 | } 413 | 414 | static PyObject * Storm_SFileFindNextFile(PyObject *self, PyObject *args) { 415 | HANDLE find = NULL; 416 | SFILE_FIND_DATA findFileData; 417 | 418 | if (!python::parse_tuple(args, "SFileFindFirstFile", &find)) { 419 | return NULL; 420 | } 421 | bool result = SFileFindNextFile(find, &findFileData); 422 | 423 | if (!result) { 424 | if (GetLastError() == ERROR_NO_MORE_FILES) { 425 | PyErr_SetString(NoMoreFilesError, ""); 426 | return NULL; 427 | } else { 428 | PyErr_SetString(StormError, "Error searching for next result in archive"); 429 | return NULL; 430 | } 431 | } 432 | 433 | return python::build_value(findFileData.cFileName); 434 | } 435 | 436 | static PyObject * Storm_SFileFindClose(PyObject *self, PyObject *args) { 437 | HANDLE find = NULL; 438 | 439 | if (!python::parse_tuple(args, "SFileFindFirstFile", &find)) { 440 | return NULL; 441 | } 442 | bool result = SFileFindClose(find); 443 | 444 | if (!result) { 445 | PyErr_SetString(StormError, "Error closing archive search"); 446 | return NULL; 447 | } 448 | 449 | Py_RETURN_NONE; 450 | } 451 | 452 | static PyObject * Storm_SListFileFindFirstFile(PyObject *self, PyObject *args) { 453 | HANDLE mpq = NULL; 454 | char *mask; 455 | char *listFile; // XXX Unused for now 456 | SFILE_FIND_DATA findFileData; 457 | 458 | if (!python::parse_tuple(args, "SListFileFindFirstFile", &mpq, &listFile, &mask)) { 459 | return NULL; 460 | } 461 | HANDLE result = SListFileFindFirstFile(mpq, NULL, mask, &findFileData); 462 | 463 | if (!result) { 464 | PyErr_SetString(StormError, "Error searching listfile"); 465 | return NULL; 466 | } 467 | 468 | return python::build_value(result, findFileData.cFileName); 469 | } 470 | 471 | static PyObject * Storm_SListFileFindNextFile(PyObject *self, PyObject *args) { 472 | HANDLE find = NULL; 473 | SFILE_FIND_DATA findFileData; 474 | 475 | if (!python::parse_tuple(args, "SListFileFindFirstFile", &find)) { 476 | return NULL; 477 | } 478 | bool result = SListFileFindNextFile(find, &findFileData); 479 | 480 | if (!result) { 481 | if (GetLastError() == ERROR_NO_MORE_FILES) { 482 | PyErr_SetString(NoMoreFilesError, ""); 483 | return NULL; 484 | } else { 485 | PyErr_SetString(StormError, "Error searching for next result in listfile"); 486 | return NULL; 487 | } 488 | } 489 | 490 | return python::build_value(findFileData.cFileName); 491 | } 492 | 493 | static PyObject * Storm_SListFileFindClose(PyObject *self, PyObject *args) { 494 | HANDLE find = NULL; 495 | 496 | if (!python::parse_tuple(args, "SListFileFindFirstFile", &find)) { 497 | return NULL; 498 | } 499 | bool result = SListFileFindClose(find); 500 | 501 | if (!result) { 502 | PyErr_SetString(StormError, "Error closing listfile search"); 503 | return NULL; 504 | } 505 | 506 | Py_RETURN_NONE; 507 | } 508 | 509 | 510 | static PyMethodDef StormMethods[] = { 511 | {"SFileOpenArchive", Storm_SFileOpenArchive, METH_VARARGS, "Open an MPQ archive."}, 512 | /* SFileCreateArchive */ 513 | {"SFileAddListFile", Storm_SFileAddListFile, METH_VARARGS, "Adds an in-memory listfile to an open MPQ archive"}, 514 | /* SFileSetLocale (unimplemented) */ 515 | /* SFileGetLocale (unimplemented) */ 516 | {"SFileFlushArchive", Storm_SFileFlushArchive, METH_VARARGS, "Flushes all unsaved data in an MPQ archive to the disk"}, 517 | {"SFileCloseArchive", Storm_SFileCloseArchive, METH_VARARGS, "Close an MPQ archive."}, 518 | {"SFileCompactArchive", Storm_SFileCompactArchive, METH_VARARGS, "Compacts (rebuilds) the MPQ archive, freeing all gaps that were created by write operations"}, 519 | /* SFileSetMaxFileCount */ 520 | /* SFileSetCompactCallback (unimplemented) */ 521 | 522 | {"SFileIsPatchedArchive", Storm_SFileIsPatchedArchive, METH_VARARGS, "Determines if an MPQ archive has been patched"}, 523 | {"SFileOpenPatchArchive", Storm_SFileOpenPatchArchive, METH_VARARGS, "Adds a patch archive to an MPQ archive"}, 524 | 525 | {"SFileOpenFileEx", Storm_SFileOpenFileEx, METH_VARARGS, "Open a file from an MPQ archive"}, 526 | {"SFileGetFileSize", Storm_SFileGetFileSize, METH_VARARGS, "Retrieve the size of a file within an MPQ archive"}, 527 | {"SFileSetFilePointer", Storm_SFileSetFilePointer, METH_VARARGS, "Seeks to a position within archive file"}, 528 | {"SFileReadFile", Storm_SFileReadFile, METH_VARARGS, "Reads bytes in an open file"}, 529 | {"SFileCloseFile", Storm_SFileCloseFile, METH_VARARGS, "Close an open file"}, 530 | {"SFileHasFile", Storm_SFileHasFile, METH_VARARGS, "Check if a file exists within an MPQ archive"}, 531 | {"SFileGetFileName", Storm_SFileGetFileName, METH_VARARGS, "Retrieve the name of an open file"}, 532 | {"SFileGetFileInfo", Storm_SFileGetFileInfo, METH_VARARGS, "Retrieve information about an open file or MPQ archive"}, 533 | /* SFileVerifyFile (unimplemented) */ 534 | /* SFileVerifyArchive (unimplemented) */ 535 | {"SFileExtractFile", Storm_SFileExtractFile, METH_VARARGS, "Extracts a file from an MPQ archive to the local drive"}, 536 | 537 | /* File searching */ 538 | {"SFileFindFirstFile", Storm_SFileFindFirstFile, METH_VARARGS, "Finds the first file matching the specification in the archive"}, 539 | {"SFileFindNextFile", Storm_SFileFindNextFile, METH_VARARGS, "Finds the next file matching the specification in the archive"}, 540 | {"SFileFindClose", Storm_SFileFindClose, METH_VARARGS, "Stops searching files in the archive"}, 541 | {"SListFileFindFirstFile", Storm_SListFileFindFirstFile, METH_VARARGS, "Finds the first file matching the specification in the listfile"}, 542 | {"SListFileFindNextFile", Storm_SListFileFindNextFile, METH_VARARGS, "Finds the next file matching the specification in the listfile"}, 543 | {"SListFileFindClose", Storm_SListFileFindClose, METH_VARARGS, "Stops searching files in the listfile"}, 544 | {NULL, NULL, 0, NULL} /* Sentinel */ 545 | }; 546 | 547 | 548 | #define storm_doc "Python bindings for StormLib" 549 | #define DECLARE(x) PyObject_SetAttrString(m, #x, PyLong_FromLong((long) x)); 550 | 551 | static struct PyModuleDef moduledef = { 552 | PyModuleDef_HEAD_INIT, 553 | "storm", /* m_name */ 554 | storm_doc, /* m_doc */ 555 | -1, /* m_size */ 556 | StormMethods, /* m_methods */ 557 | NULL, /* m_reload */ 558 | NULL, /* m_traverse */ 559 | NULL, /* m_clear */ 560 | NULL, /* m_free */ 561 | }; 562 | #define MOD_INIT(name) PyMODINIT_FUNC PyInit_##name(void) 563 | 564 | MOD_INIT(storm) { 565 | PyObject *m; 566 | 567 | m = PyModule_Create(&moduledef); 568 | if (m == NULL) return NULL; 569 | 570 | StormError = PyErr_NewException((char *)"storm.error", NULL, NULL); 571 | Py_INCREF(StormError); 572 | PyModule_AddObject(m, "error", StormError); 573 | 574 | NoMoreFilesError = PyErr_NewException((char *)"storm.NoMoreFilesError", NULL, NULL); 575 | Py_INCREF(NoMoreFilesError); 576 | PyModule_AddObject(m, "NoMoreFilesError", NoMoreFilesError); 577 | 578 | /* SFileOpenArchive */ 579 | DECLARE(MPQ_OPEN_NO_LISTFILE); 580 | DECLARE(MPQ_OPEN_NO_ATTRIBUTES); 581 | DECLARE(MPQ_OPEN_FORCE_MPQ_V1); 582 | DECLARE(MPQ_OPEN_CHECK_SECTOR_CRC); 583 | DECLARE(MPQ_OPEN_READ_ONLY); 584 | 585 | /* SFileGetFileInfo */ 586 | DECLARE(SFileInfoPatchChain); 587 | DECLARE(SFileInfoFileEntry); 588 | DECLARE(SFileInfoHashEntry); 589 | DECLARE(SFileInfoHashIndex); 590 | DECLARE(SFileInfoNameHash1); 591 | DECLARE(SFileInfoNameHash2); 592 | DECLARE(SFileInfoNameHash3); 593 | DECLARE(SFileInfoLocale); 594 | DECLARE(SFileInfoFileIndex); 595 | DECLARE(SFileInfoByteOffset); 596 | DECLARE(SFileInfoFileTime); 597 | DECLARE(SFileInfoFlags); 598 | DECLARE(SFileInfoFileSize); 599 | DECLARE(SFileInfoCompressedSize); 600 | DECLARE(SFileInfoEncryptionKey); 601 | DECLARE(SFileInfoEncryptionKeyRaw); 602 | 603 | /* SFileOpenFileEx, SFileExtractFile */ 604 | DECLARE(SFILE_OPEN_FROM_MPQ); 605 | 606 | return m; 607 | } 608 | 609 | #ifdef __cplusplus 610 | } 611 | #endif 612 | -------------------------------------------------------------------------------- /mpq/stormmodule.h: -------------------------------------------------------------------------------- 1 | #ifndef Py_STORMMODULE_H 2 | #define Py_STORMMODULE_H 3 | #include 4 | 5 | #ifdef __cplusplus 6 | extern "C" { 7 | #endif 8 | 9 | #ifdef STORM_MODULE 10 | /* This section is used when compiling stormmodule.c */ 11 | 12 | #else 13 | /* This section is used in modules that use stormmodule's API */ 14 | 15 | static void **PyStorm_API; 16 | 17 | #endif 18 | 19 | #ifdef __cplusplus 20 | } 21 | #endif 22 | 23 | #endif /* !defined(Py_STORMMODULE_H) */ 24 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = mpq 3 | version = 1.1.1 4 | description = Python bindings for StormLib 5 | author = Jerome Leclanche 6 | author_email = jerome@leclan.ch 7 | url = https://github.com/HearthSim/python-mpq/ 8 | download_url = https://github.com/HearthSim/python-mpq/tarball/master 9 | classifiers = 10 | Development Status :: 5 - Production/Stable 11 | Intended Audience :: Developers 12 | License :: OSI Approved :: MIT License 13 | Programming Language :: Python 14 | Programming Language :: Python :: 3 15 | Programming Language :: Python :: 3.4 16 | Programming Language :: Python :: 3.5 17 | Programming Language :: Python :: 3.6 18 | Topic :: Games/Entertainment 19 | Topic :: System :: Archiving 20 | 21 | [options] 22 | packages = 23 | mpq 24 | include_package_data = True 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import platform 3 | from setuptools import Extension, setup 4 | 5 | 6 | extra_link_args = [] 7 | extra_compile_args = [] 8 | # XCode for macOS Mojave issue 9 | if platform.mac_ver()[0] == "10.14": 10 | for flags in extra_link_args, extra_compile_args: 11 | flags += ["-stdlib=libc++", "-mmacosx-version-min=10.9"] 12 | 13 | 14 | module = Extension( 15 | "mpq.storm", 16 | sources=["mpq/stormmodule.cc"], 17 | language="c++", 18 | libraries=["storm"], 19 | extra_compile_args=extra_compile_args, 20 | extra_link_args=extra_link_args, 21 | ) 22 | 23 | setup(ext_modules=[module]) 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, flake8 3 | 4 | 5 | [testenv] 6 | commands = 7 | {envpython} setup.py build_ext --inplace 8 | {envpython} -c "import mpq; print(mpq.__version__)" 9 | 10 | 11 | [testenv:flake8] 12 | commands = flake8 13 | deps = 14 | flake8 15 | flake8-import-order 16 | flake8-quotes 17 | 18 | 19 | [flake8] 20 | ignore = W191 21 | import-order-style = smarkets 22 | inline-quotes = " 23 | --------------------------------------------------------------------------------