├── .gitignore ├── BaseCommand.py ├── CommandCompile.py ├── CommandDecompile.py ├── PrefixMatcher.py ├── README.md ├── NamedStruct.py ├── wadcode ├── FriendlyArgumentParser.py ├── palette.json ├── MultiCommand.py ├── WADFile.py └── EncoderImage.py /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | __pycache__ 3 | *.wad 4 | -------------------------------------------------------------------------------- /BaseCommand.py: -------------------------------------------------------------------------------- 1 | # wadcode - WAD compiler/decompiler for WAD resource files 2 | # Copyright (C) 2019-2019 Johannes Bauer 3 | # 4 | # This file is part of wadcode. 5 | # 6 | # wadcode is free software; you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation; this program is ONLY licensed under 9 | # version 3 of the License, later versions are explicitly excluded. 10 | # 11 | # wadcode is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # Johannes Bauer 20 | 21 | class BaseCommand(): 22 | def __init__(self, cmd, args): 23 | self._cmd = cmd 24 | self._args = args 25 | -------------------------------------------------------------------------------- /CommandCompile.py: -------------------------------------------------------------------------------- 1 | # wadcode - WAD compiler/decompiler for WAD resource files 2 | # Copyright (C) 2019-2019 Johannes Bauer 3 | # 4 | # This file is part of wadcode. 5 | # 6 | # wadcode is free software; you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation; this program is ONLY licensed under 9 | # version 3 of the License, later versions are explicitly excluded. 10 | # 11 | # wadcode is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # Johannes Bauer 20 | 21 | from BaseCommand import BaseCommand 22 | from WADFile import WADFile 23 | 24 | class CommandCompile(BaseCommand): 25 | def __init__(self, cmd, args): 26 | BaseCommand.__init__(self, cmd, args) 27 | 28 | wadfile = WADFile.create_from_directory(args.indir) 29 | wadfile.write(args.outfile) 30 | 31 | -------------------------------------------------------------------------------- /CommandDecompile.py: -------------------------------------------------------------------------------- 1 | # wadcode - WAD compiler/decompiler for WAD resource files 2 | # Copyright (C) 2019-2019 Johannes Bauer 3 | # 4 | # This file is part of wadcode. 5 | # 6 | # wadcode is free software; you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation; this program is ONLY licensed under 9 | # version 3 of the License, later versions are explicitly excluded. 10 | # 11 | # wadcode is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # Johannes Bauer 20 | 21 | from BaseCommand import BaseCommand 22 | from WADFile import WADFile 23 | 24 | class CommandDecompile(BaseCommand): 25 | def __init__(self, cmd, args): 26 | BaseCommand.__init__(self, cmd, args) 27 | 28 | wadfile = WADFile.create_from_file(args.infile) 29 | wadfile.write_to_directory(args.outdir, decode = not self._args.no_unpack) 30 | -------------------------------------------------------------------------------- /PrefixMatcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # 3 | # PrefixMatcher - Match the shortest unambiguous prefix 4 | # Copyright (C) 2011-2012 Johannes Bauer 5 | # 6 | # This file is part of pycommon. 7 | # 8 | # pycommon is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; this program is ONLY licensed under 11 | # version 3 of the License, later versions are explicitly excluded. 12 | # 13 | # pycommon is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with pycommon; if not, write to the Free Software 20 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 21 | # 22 | # Johannes Bauer 23 | # 24 | # File UUID f75ad3fb-12d0-4368-b14b-9353df125800 25 | 26 | class PrefixMatcher(object): 27 | def __init__(self, options): 28 | self._opts = options 29 | 30 | def matchunique(self, value): 31 | result = self.match(value) 32 | if len(result) != 1: 33 | if len(result) == 0: 34 | raise Exception("'%s' did not match any options." % (value)) 35 | else: 36 | raise Exception("'%s' is ambiguous. Please clarify further. Available: %s" % (value, ", ".join(sorted(list(result))))) 37 | return result[0] 38 | 39 | def match(self, value): 40 | return [ option for option in self._opts if option.startswith(value) ] 41 | 42 | if __name__ == "__main__": 43 | pm = PrefixMatcher([ "import", "install", "foo" ]) 44 | 45 | print(pm.match("i")) 46 | print(pm.matchunique("i")) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wadcode 2 | wadcode is a WAD Compiler/Decompiler. It is supposed to disassemble WAD files 3 | that provide all the resources for games like Doom into individual files that 4 | can then be edited. Ideally it should be able to recompile those files back 5 | into a WAD to use. 6 | 7 | ## Usage 8 | To decompile a WAD file into the internal resources: 9 | 10 | ``` 11 | usage: ./wadcode decompile [--no-unpack] [--verbose] [--help] 12 | wadfile directory 13 | 14 | Decompile a WAD file into its resources 15 | 16 | positional arguments: 17 | wadfile Input WAD file that is decompiled. 18 | directory Input directory in which WAD resources are written. 19 | 20 | optional arguments: 21 | --no-unpack Do not unpack any inner blobs (e.g., convert images to PNG 22 | files), extract everything as stored internally. 23 | --verbose Increase verbosity. Can be specified multiple times. 24 | --help Show this help page. 25 | ``` 26 | 27 | For example: 28 | 29 | ``` 30 | $ ./wadcode decompile DOOM.WAD /tmp/my-doom-wad 31 | ``` 32 | 33 | Then you can easily edit all files in that directory (/tmp/my-doom-wad), and 34 | recompile them afterwards: 35 | 36 | ``` 37 | usage: ./wadcode compile [--verbose] [--help] directory wadfile 38 | 39 | Compile a WAD file from resources 40 | 41 | positional arguments: 42 | directory Input directory that should be compiled to a WAD. 43 | wadfile Output WAD file that is created after compilation. 44 | 45 | optional arguments: 46 | --verbose Increase verbosity. Can be specified multiple times. 47 | --help Show this help page. 48 | ``` 49 | 50 | For example: 51 | 52 | ``` 53 | $ ./wadcode compile /tmp/my-doom-wad MODIFIED_DOOM.WAD 54 | ``` 55 | 56 | ## Dependencies 57 | wadcode requires the pypng package to be installed. 58 | 59 | ## License 60 | GNU-GPL 3. 61 | -------------------------------------------------------------------------------- /NamedStruct.py: -------------------------------------------------------------------------------- 1 | # retools - Reverse engineering toolkit 2 | # Copyright (C) 2019-2019 Johannes Bauer 3 | # 4 | # This file is part of retools. 5 | # 6 | # retools is free software; you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation; this program is ONLY licensed under 9 | # version 3 of the License, later versions are explicitly excluded. 10 | # 11 | # retools is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with retools; if not, write to the Free Software 18 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 19 | # 20 | # Johannes Bauer 21 | 22 | import collections 23 | import struct 24 | 25 | class NamedStruct(object): 26 | def __init__(self, fields, struct_extra = "<"): 27 | struct_format = struct_extra + ("".join(fieldtype for (fieldtype, fieldname) in fields)) 28 | self._struct = struct.Struct(struct_format) 29 | self._collection = collections.namedtuple("Fields", [ fieldname for (fieldtype, fieldname) in fields ]) 30 | 31 | @property 32 | def size(self): 33 | return self._struct.size 34 | 35 | def pack(self, data): 36 | fields = self._collection(**data) 37 | return self._struct.pack(*fields) 38 | 39 | def unpack(self, data): 40 | values = self._struct.unpack(data) 41 | fields = self._collection(*values) 42 | return fields 43 | 44 | def unpack_head(self, data): 45 | return self.unpack(data[:self._struct.size]) 46 | 47 | def unpack_from_file(self, f, at_offset = None): 48 | if at_offset is not None: 49 | f.seek(at_offset) 50 | data = f.read(self._struct.size) 51 | return self.unpack(data) 52 | -------------------------------------------------------------------------------- /wadcode: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # wadcode - WAD compiler/decompiler for WAD resource files 3 | # Copyright (C) 2019-2019 Johannes Bauer 4 | # 5 | # This file is part of wadcode. 6 | # 7 | # wadcode is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; this program is ONLY licensed under 10 | # version 3 of the License, later versions are explicitly excluded. 11 | # 12 | # wadcode is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | # 20 | # Johannes Bauer 21 | 22 | import sys 23 | from CommandCompile import CommandCompile 24 | from CommandDecompile import CommandDecompile 25 | from MultiCommand import MultiCommand 26 | 27 | mc = MultiCommand() 28 | 29 | def genparser(parser): 30 | parser.add_argument("--verbose", action = "count", default = 0, help = "Increase verbosity. Can be specified multiple times.") 31 | parser.add_argument("indir", metavar = "directory", type = str, help = "Input directory that should be compiled to a WAD.") 32 | parser.add_argument("outfile", metavar = "wadfile", type = str, help = "Output WAD file that is created after compilation.") 33 | mc.register("compile", "Compile a WAD file from resources", genparser, action = CommandCompile) 34 | 35 | def genparser(parser): 36 | parser.add_argument("--no-unpack", action = "store_true", help = "Do not unpack any inner blobs (e.g., convert images to PNG files), extract everything as stored internally.") 37 | parser.add_argument("--verbose", action = "count", default = 0, help = "Increase verbosity. Can be specified multiple times.") 38 | parser.add_argument("infile", metavar = "wadfile", type = str, help = "Input WAD file that is decompiled.") 39 | parser.add_argument("outdir", metavar = "directory", type = str, help = "Input directory in which WAD resources are written.") 40 | mc.register("decompile", "Decompile a WAD file into its resources", genparser, action = CommandDecompile) 41 | 42 | mc.run(sys.argv[1:]) 43 | -------------------------------------------------------------------------------- /FriendlyArgumentParser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # 3 | # FriendlyArgumentParser - Argument parser with default help pages 4 | # Copyright (C) 2011-2012 Johannes Bauer 5 | # 6 | # This file is part of pycommon. 7 | # 8 | # pycommon is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; this program is ONLY licensed under 11 | # version 3 of the License, later versions are explicitly excluded. 12 | # 13 | # pycommon is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with pycommon; if not, write to the Free Software 20 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 21 | # 22 | # Johannes Bauer 23 | # 24 | # File UUID c55a0ea0-6dc8-4ceb-a9ff-e54ea8a2ea62 25 | 26 | import sys 27 | import argparse 28 | import textwrap 29 | 30 | class FriendlyArgumentParser(argparse.ArgumentParser): 31 | def __init__(self, *args, **kwargs): 32 | argparse.ArgumentParser.__init__(self, *args, **kwargs) 33 | self.__silent_error = False 34 | 35 | def setsilenterror(self, silenterror): 36 | self.__silent_error = silenterror 37 | 38 | def error(self, msg): 39 | if self.__silent_error: 40 | raise Exception(msg) 41 | else: 42 | for line in textwrap.wrap("Error: %s" % (msg), subsequent_indent = " "): 43 | print(line, file = sys.stderr) 44 | print(file = sys.stderr) 45 | self.print_help(file = sys.stderr) 46 | sys.exit(1) 47 | 48 | def baseint(value, default_base = 10): 49 | if value.lower().startswith("0x"): 50 | return int(value, 16) 51 | elif value.lower().startswith("0b"): 52 | return int(value, 2) 53 | elif value.lower().startswith("0o"): 54 | return int(value, 8) 55 | elif value.lower().startswith("0b"): 56 | return int(value, 2) 57 | else: 58 | return int(value, default_base) 59 | 60 | if __name__ == "__main__": 61 | parser = FriendlyArgumentParser(description = "Simple example application.") 62 | parser.add_argument("-d", "--dbfile", metavar = "filename", type = str, default = "mydb.sqlite", help = "Specifies database file to use. Defaults to %(default)s.") 63 | parser.add_argument("-f", "--force", action = "store_true", help = "Do not ask for confirmation") 64 | parser.add_argument("-x", metavar = "hexint", type = baseint, default = "0x100", help = "Defaults to %(default)s.") 65 | parser.add_argument("-v", "--verbose", action = "count", default = 0, help = "Increases verbosity. Can be specified multiple times to increase.") 66 | parser.add_argument("qids", metavar = "qid", type = int, nargs = "+", help = "Question ID(s) of the question(s) to be edited") 67 | args = parser.parse_args(sys.argv[1:]) 68 | print(args) 69 | 70 | 71 | -------------------------------------------------------------------------------- /palette.json: -------------------------------------------------------------------------------- 1 | [ 2 | "000000", 3 | "1f170b", 4 | "170f07", 5 | "4b4b4b", 6 | "ffffff", 7 | "1b1b1b", 8 | "131313", 9 | "0b0b0b", 10 | "070707", 11 | "2f371f", 12 | "232b0f", 13 | "171f07", 14 | "0f1700", 15 | "4f3b2b", 16 | "473323", 17 | "3f2b1b", 18 | "ffb7b7", 19 | "f7abab", 20 | "f3a3a3", 21 | "eb9797", 22 | "e78f8f", 23 | "df8787", 24 | "db7b7b", 25 | "d37373", 26 | "cb6b6b", 27 | "c76363", 28 | "bf5b5b", 29 | "bb5757", 30 | "b34f4f", 31 | "af4747", 32 | "a73f3f", 33 | "a33b3b", 34 | "9b3333", 35 | "972f2f", 36 | "8f2b2b", 37 | "8b2323", 38 | "831f1f", 39 | "7f1b1b", 40 | "771717", 41 | "731313", 42 | "6b0f0f", 43 | "670b0b", 44 | "5f0707", 45 | "5b0707", 46 | "530707", 47 | "4f0000", 48 | "470000", 49 | "430000", 50 | "ffebdf", 51 | "ffe3d3", 52 | "ffdbc7", 53 | "ffd3bb", 54 | "ffcfb3", 55 | "ffc7a7", 56 | "ffbf9b", 57 | "ffbb93", 58 | "ffb383", 59 | "f7ab7b", 60 | "efa373", 61 | "e79b6b", 62 | "df9363", 63 | "d78b5b", 64 | "cf8353", 65 | "cb7f4f", 66 | "bf7b4b", 67 | "b37347", 68 | "ab6f43", 69 | "a36b3f", 70 | "9b633b", 71 | "8f5f37", 72 | "875733", 73 | "7f532f", 74 | "774f2b", 75 | "6b4727", 76 | "5f4323", 77 | "533f1f", 78 | "4b371b", 79 | "3f2f17", 80 | "332b13", 81 | "2b230f", 82 | "efefef", 83 | "e7e7e7", 84 | "dfdfdf", 85 | "dbdbdb", 86 | "d3d3d3", 87 | "cbcbcb", 88 | "c7c7c7", 89 | "bfbfbf", 90 | "b7b7b7", 91 | "b3b3b3", 92 | "ababab", 93 | "a7a7a7", 94 | "9f9f9f", 95 | "979797", 96 | "939393", 97 | "8b8b8b", 98 | "838383", 99 | "7f7f7f", 100 | "777777", 101 | "6f6f6f", 102 | "6b6b6b", 103 | "636363", 104 | "5b5b5b", 105 | "575757", 106 | "4f4f4f", 107 | "474747", 108 | "434343", 109 | "3b3b3b", 110 | "373737", 111 | "2f2f2f", 112 | "272727", 113 | "232323", 114 | "77ff6f", 115 | "6fef67", 116 | "67df5f", 117 | "5fcf57", 118 | "5bbf4f", 119 | "53af47", 120 | "4b9f3f", 121 | "439337", 122 | "3f832f", 123 | "37732b", 124 | "2f6323", 125 | "27531b", 126 | "1f4317", 127 | "17330f", 128 | "13230b", 129 | "0b1707", 130 | "bfa78f", 131 | "b79f87", 132 | "af977f", 133 | "a78f77", 134 | "9f876f", 135 | "9b7f6b", 136 | "937b63", 137 | "8b735b", 138 | "836b57", 139 | "7b634f", 140 | "775f4b", 141 | "6f5743", 142 | "67533f", 143 | "5f4b37", 144 | "574333", 145 | "533f2f", 146 | "9f8363", 147 | "8f7753", 148 | "836b4b", 149 | "775f3f", 150 | "675333", 151 | "5b472b", 152 | "4f3b23", 153 | "43331b", 154 | "7b7f63", 155 | "6f7357", 156 | "676b4f", 157 | "5b6347", 158 | "53573b", 159 | "474f33", 160 | "3f472b", 161 | "373f27", 162 | "ffff73", 163 | "ebdb57", 164 | "d7bb43", 165 | "c39b2f", 166 | "af7b1f", 167 | "9b5b13", 168 | "874307", 169 | "732b00", 170 | "ffffff", 171 | "ffdbdb", 172 | "ffbbbb", 173 | "ff9b9b", 174 | "ff7b7b", 175 | "ff5f5f", 176 | "ff3f3f", 177 | "ff1f1f", 178 | "ff0000", 179 | "ef0000", 180 | "e30000", 181 | "d70000", 182 | "cb0000", 183 | "bf0000", 184 | "b30000", 185 | "a70000", 186 | "9b0000", 187 | "8b0000", 188 | "7f0000", 189 | "730000", 190 | "670000", 191 | "5b0000", 192 | "4f0000", 193 | "430000", 194 | "e7e7ff", 195 | "c7c7ff", 196 | "ababff", 197 | "8f8fff", 198 | "7373ff", 199 | "5353ff", 200 | "3737ff", 201 | "1b1bff", 202 | "0000ff", 203 | "0000e3", 204 | "0000cb", 205 | "0000b3", 206 | "00009b", 207 | "000083", 208 | "00006b", 209 | "000053", 210 | "ffffff", 211 | "ffebdb", 212 | "ffd7bb", 213 | "ffc79b", 214 | "ffb37b", 215 | "ffa35b", 216 | "ff8f3b", 217 | "ff7f1b", 218 | "f37317", 219 | "eb6f0f", 220 | "df670f", 221 | "d75f0b", 222 | "cb5707", 223 | "c34f00", 224 | "b74700", 225 | "af4300", 226 | "ffffff", 227 | "ffffd7", 228 | "ffffb3", 229 | "ffff8f", 230 | "ffff6b", 231 | "ffff47", 232 | "ffff23", 233 | "ffff00", 234 | "a73f00", 235 | "9f3700", 236 | "932f00", 237 | "872300", 238 | "4f3b27", 239 | "432f1b", 240 | "372313", 241 | "2f1b0b", 242 | "000053", 243 | "000047", 244 | "00003b", 245 | "00002f", 246 | "000023", 247 | "000017", 248 | "00000b", 249 | "000000", 250 | "ff9f43", 251 | "ffe74b", 252 | "ff7bff", 253 | "ff00ff", 254 | "cf00cf", 255 | "9f009b", 256 | "6f006b", 257 | "a76b6b" 258 | ] -------------------------------------------------------------------------------- /MultiCommand.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # 3 | # MultiCommand - Provide an openssl-style multi-command abstraction 4 | # Copyright (C) 2011-2018 Johannes Bauer 5 | # 6 | # This file is part of pycommon. 7 | # 8 | # pycommon is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; this program is ONLY licensed under 11 | # version 3 of the License, later versions are explicitly excluded. 12 | # 13 | # pycommon is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with pycommon; if not, write to the Free Software 20 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 21 | # 22 | # Johannes Bauer 23 | # 24 | # File UUID 4c6b89d0-ec0c-4b19-80d1-4daba7d80967 25 | 26 | import sys 27 | import collections 28 | import textwrap 29 | 30 | from FriendlyArgumentParser import FriendlyArgumentParser 31 | from PrefixMatcher import PrefixMatcher 32 | 33 | class MultiCommand(object): 34 | RegisteredCommand = collections.namedtuple("RegisteredCommand", [ "name", "description", "parsergenerator", "action", "aliases", "visible" ]) 35 | ParseResult = collections.namedtuple("ParseResults", [ "cmd", "args" ]) 36 | 37 | def __init__(self): 38 | self._commands = { } 39 | self._aliases = { } 40 | self._cmdorder = [ ] 41 | 42 | def register(self, commandname, description, parsergenerator, **kwargs): 43 | supported_kwargs = set(("aliases", "action", "visible")) 44 | if len(set(kwargs.keys()) - supported_kwargs) > 0: 45 | raise Exception("Unsupported kwarg found. Supported: %s" % (", ".join(sorted(list(supported_kwargs))))) 46 | 47 | if (commandname in self._commands) or (commandname in self._aliases): 48 | raise Exception("Command '%s' already registered." % (commandname)) 49 | 50 | aliases = kwargs.get("aliases", [ ]) 51 | action = kwargs.get("action") 52 | for alias in aliases: 53 | if (alias in self._commands) or (alias in self._aliases): 54 | raise Exception("Alias '%s' already registered." % (alias)) 55 | self._aliases[alias] = commandname 56 | 57 | cmd = self.RegisteredCommand(commandname, description, parsergenerator, action, aliases, visible = kwargs.get("visible", True)) 58 | self._commands[commandname] = cmd 59 | self._cmdorder.append(commandname) 60 | 61 | def _show_syntax(self, msg = None): 62 | if msg is not None: 63 | print("Error: %s" % (msg), file = sys.stderr) 64 | print("Syntax: %s [command] [options]" % (sys.argv[0]), file = sys.stderr) 65 | print(file = sys.stderr) 66 | print("Available commands:", file = sys.stderr) 67 | for commandname in self._cmdorder: 68 | command = self._commands[commandname] 69 | if not command.visible: 70 | continue 71 | commandname_line = command.name 72 | for description_line in textwrap.wrap(command.description, width = 56): 73 | print(" %-15s %s" % (commandname_line, description_line)) 74 | commandname_line = "" 75 | print(file = sys.stderr) 76 | print("Options vary from command to command. To receive further info, type", file = sys.stderr) 77 | print(" %s [command] --help" % (sys.argv[0]), file = sys.stderr) 78 | 79 | def _raise_error(self, msg, silent = False): 80 | if silent: 81 | raise Exception(msg) 82 | else: 83 | self._show_syntax(msg) 84 | sys.exit(1) 85 | 86 | def _getcmdnames(self): 87 | return set(self._commands.keys()) | set(self._aliases.keys()) 88 | 89 | def parse(self, cmdline, silent = False): 90 | if len(cmdline) < 1: 91 | self._raise_error("No command supplied.") 92 | 93 | # Check if we can match the command portion 94 | pm = PrefixMatcher(self._getcmdnames()) 95 | try: 96 | supplied_cmd = pm.matchunique(cmdline[0]) 97 | except Exception as e: 98 | self._raise_error("Invalid command supplied: %s" % (str(e))) 99 | 100 | if supplied_cmd in self._aliases: 101 | supplied_cmd = self._aliases[supplied_cmd] 102 | 103 | command = self._commands[supplied_cmd] 104 | parser = FriendlyArgumentParser(prog = sys.argv[0] + " " + command.name, description = command.description, add_help = False) 105 | command.parsergenerator(parser) 106 | parser.add_argument("--help", action = "help", help = "Show this help page.") 107 | parser.setsilenterror(silent) 108 | args = parser.parse_args(cmdline[1:]) 109 | return self.ParseResult(command, args) 110 | 111 | def run(self, cmdline, silent = False): 112 | parseresult = self.parse(cmdline, silent) 113 | if parseresult.cmd.action is None: 114 | raise Exception("Should run command '%s', but no action was registered." % (parseresult.cmd.name)) 115 | parseresult.cmd.action(parseresult.cmd.name, parseresult.args) 116 | 117 | if __name__ == "__main__": 118 | mc = MultiCommand() 119 | 120 | def importaction(cmd, args): 121 | print("Import:", cmd, args) 122 | 123 | class ExportAction(object): 124 | def __init__(self, cmd, args): 125 | print("Export:", cmd, args) 126 | 127 | def genparser(parser): 128 | parser.add_argument("-i", "--infile", metavar = "filename", type = str, required = True, help = "Specifies the input text file that is to be imported. Mandatory argument.") 129 | parser.add_argument("--verbose", action = "store_true", help = "Increase verbosity during the importing process.") 130 | parser.add_argument("-n", "--world", metavar = "name", type = str, choices = [ "world", "foo", "bar" ], default = "overworld", help = "Specifies the world name. Possible options are %(choices)s. Default is %(default)s.") 131 | mc.register("import", "Import some file from somewhere", genparser, action = importaction, aliases = [ "ymport" ]) 132 | 133 | 134 | def genparser(parser): 135 | parser.add_argument("-o", "--outfile", metavar = "filename", type = str, required = True, help = "Specifies the input text file that is to be imported. Mandatory argument.") 136 | parser.add_argument("--verbose", action = "store_true", help = "Increase verbosity during the importing process.") 137 | mc.register("export", "Export some file to somewhere", genparser, action = ExportAction) 138 | 139 | mc.run(sys.argv[1:]) 140 | 141 | -------------------------------------------------------------------------------- /WADFile.py: -------------------------------------------------------------------------------- 1 | # wadcode - WAD compiler/decompiler for WAD resource files 2 | # Copyright (C) 2019-2019 Johannes Bauer 3 | # 4 | # This file is part of wadcode. 5 | # 6 | # wadcode is free software; you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation; this program is ONLY licensed under 9 | # version 3 of the License, later versions are explicitly excluded. 10 | # 11 | # wadcode is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # Johannes Bauer 20 | 21 | import os 22 | import re 23 | import json 24 | import collections 25 | import contextlib 26 | from NamedStruct import NamedStruct 27 | from EncoderImage import EncoderImage 28 | 29 | class Filenames(): 30 | def __init__(self): 31 | self._names = set() 32 | 33 | def generate(self, template, extension = ""): 34 | for i in range(1000): 35 | if i == 0: 36 | name = "%s%s" % (template, extension) 37 | else: 38 | name = "%s_%03d%s" % (template, i, extension) 39 | if name not in self._names: 40 | self._names.add(name) 41 | return name 42 | 43 | class WADFile(): 44 | _WAD_HEADER = NamedStruct(( 45 | ("4s", "magic"), 46 | ("l", "number_of_files"), 47 | ("l", "directory_offset"), 48 | )) 49 | 50 | _FILE_ENTRY = NamedStruct(( 51 | ("l", "offset"), 52 | ("l", "size"), 53 | ("8s", "name"), 54 | )) 55 | _WADResource = collections.namedtuple("WADResource", [ "name", "data" ]) 56 | _Encoders = { 57 | encoder.name: encoder for encoder in [ 58 | EncoderImage, 59 | ] 60 | } 61 | 62 | def __init__(self): 63 | self._resources = [ ] 64 | self._resources_by_name = collections.defaultdict(list) 65 | 66 | def add_resource(self, resource): 67 | self._resources.append(resource) 68 | self._resources_by_name[resource.name].append(resource) 69 | 70 | @classmethod 71 | def create_from_file(cls, filename): 72 | wadfile = cls() 73 | with open(filename, "rb") as f: 74 | header = cls._WAD_HEADER.unpack_from_file(f) 75 | assert(header.magic == b"IWAD") 76 | 77 | f.seek(header.directory_offset) 78 | for fileno in range(header.number_of_files): 79 | fileinfo = cls._FILE_ENTRY.unpack_from_file(f) 80 | name = fileinfo.name.rstrip(b"\x00").decode("latin1") 81 | cur_pos = f.tell() 82 | f.seek(fileinfo.offset) 83 | data = f.read(fileinfo.size) 84 | f.seek(cur_pos) 85 | resource = cls._WADResource(name = name, data = data) 86 | wadfile.add_resource(resource) 87 | return wadfile 88 | 89 | @classmethod 90 | def create_from_directory(cls, dirname): 91 | wadfile = cls() 92 | content_json = dirname + "/content.json" 93 | with open(content_json) as f: 94 | content = json.load(f) 95 | 96 | for resource_info in content: 97 | if resource_info.get("virtual") is True: 98 | data = b"" 99 | else: 100 | with open(dirname + "/files/" + resource_info["filename"], "rb") as f: 101 | data = f.read() 102 | 103 | if (len(data) > 0) and (resource_info.get("encoder") is not None): 104 | encoder_name = resource_info["encoder"] 105 | encoder_class = cls._Encoders[encoder_name] 106 | data = encoder_class.encode(data, metadata = resource_info.get("meta")) 107 | 108 | resource = cls._WADResource(name = resource_info["name"], data = data) 109 | wadfile.add_resource(resource) 110 | return wadfile 111 | 112 | def write_to_directory(self, outdir, decode = False): 113 | with contextlib.suppress(FileExistsError): 114 | os.makedirs(outdir) 115 | output_json_filename = outdir + "/content.json" 116 | output_json = [ ] 117 | 118 | lvl_regex = re.compile("E\dM\d") 119 | fns = Filenames() 120 | section = None 121 | for resource in self._resources: 122 | resource_item = { 123 | "name": resource.name, 124 | } 125 | if len(resource.data) == 0: 126 | resource_item["virtual"] = True 127 | section = resource.name 128 | else: 129 | extension = "" 130 | encoder = None 131 | template = resource.name.lower() 132 | if template.startswith("stcfn"): 133 | template = "font_small/%s" % (template) 134 | elif (template in [ "things", "linedefs", "sidedefs", "vertexes", "segs", "ssectors", "nodes", "sectors", "reject", "blockmap" ]) and (lvl_regex.fullmatch(section or "")): 135 | template = "level/%s/%s" % (section, template) 136 | elif decode and (any(template.startswith(x) for x in [ "stfdead", "stfkill", "stfouch", "stfst", "stftl", "stftr", "stfevl" ])): 137 | template = "face/%s" % (template) 138 | extension = ".png" 139 | encoder = EncoderImage 140 | elif decode and (any(template.startswith(x) for x in [ "titlepic", "m_" ])): 141 | template = "gfx/%s" % (template) 142 | extension = ".png" 143 | encoder = EncoderImage 144 | elif section is None: 145 | template = "nosection/%s" % (template) 146 | else: 147 | template = "other/%s" % (template) 148 | filename = fns.generate(template, extension) 149 | resource_item["filename"] = filename 150 | 151 | if encoder is not None: 152 | resource_item["encoder"] = encoder.name 153 | (write_data, metadata) = encoder.decode(resource.data) 154 | resource_item["meta"] = metadata 155 | else: 156 | write_data = resource.data 157 | 158 | full_outname = "%s/files/%s" % (outdir, filename) 159 | with contextlib.suppress(FileExistsError): 160 | os.makedirs(os.path.dirname(full_outname)) 161 | with open(full_outname, "wb") as outfile: 162 | outfile.write(write_data) 163 | 164 | output_json.append(resource_item) 165 | 166 | with open(output_json_filename, "w") as f: 167 | json.dump(output_json, fp = f, indent = 4, sort_keys = True) 168 | 169 | def write(self, wad_filename): 170 | with open(wad_filename, "wb") as f: 171 | directory_offset = self._WAD_HEADER.size 172 | header = self._WAD_HEADER.pack({ 173 | "magic": b"IWAD", 174 | "number_of_files": len(self._resources), 175 | "directory_offset": self._WAD_HEADER.size, 176 | }) 177 | f.write(header) 178 | 179 | data_offset = directory_offset + (len(self._resources) * self._FILE_ENTRY.size) 180 | for resource in self._resources: 181 | file_entry = self._FILE_ENTRY.pack({ 182 | "offset": data_offset, 183 | "size": len(resource.data), 184 | "name": resource.name.encode("latin1"), 185 | }) 186 | f.write(file_entry) 187 | data_offset += len(resource.data) 188 | for resource in self._resources: 189 | f.write(resource.data) 190 | -------------------------------------------------------------------------------- /EncoderImage.py: -------------------------------------------------------------------------------- 1 | # wadcode - WAD compiler/decompiler for WAD resource files 2 | # Copyright (C) 2019-2019 Johannes Bauer 3 | # 4 | # This file is part of wadcode. 5 | # 6 | # wadcode is free software; you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation; this program is ONLY licensed under 9 | # version 3 of the License, later versions are explicitly excluded. 10 | # 11 | # wadcode is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # Johannes Bauer 20 | 21 | import io 22 | import png 23 | import json 24 | import math 25 | from NamedStruct import NamedStruct 26 | 27 | class Palette(): 28 | def __init__(self, colors): 29 | self._colors = colors 30 | self._index_by_color = { rgb: index for (index, rgb) in enumerate(self._colors) } 31 | 32 | @staticmethod 33 | def rgb_diff(rgb1, rgb2): 34 | return math.sqrt((rgb1[0] - rgb2[0]) ** 2 + (rgb1[1] - rgb2[1]) ** 2 + (rgb1[2] - rgb2[2]) ** 2) 35 | 36 | def _lookup_closest(self, rgb): 37 | best_index = 0 38 | best_error = self.rgb_diff(rgb, self._colors[best_index]) 39 | for (index, pixel) in enumerate(self._colors[1:], 1): 40 | error = self.rgb_diff(rgb, pixel) 41 | if error < best_error: 42 | best_error = error 43 | best_index = index 44 | return best_index 45 | 46 | def lookup(self, rgb): 47 | if rgb in self._index_by_color: 48 | return self._index_by_color[rgb] 49 | else: 50 | return self._lookup_closest(rgb) 51 | 52 | def write_gimp_palette(self, filename): 53 | with open(filename, "w") as f: 54 | print("GIMP Palette", file = f) 55 | print("Name: Doom", file = f) 56 | print("Columns: 8", file = f) 57 | print("#", file = f) 58 | for color in self._colors: 59 | print("%3d %3d %3d Unknown" % (color[0], color[1], color[2]), file = f) 60 | 61 | @classmethod 62 | def load_from_json(cls, filename): 63 | with open(filename) as f: 64 | colors = json.load(f) 65 | colors = [ (int(color[0 : 2], 16), int(color[2 : 4], 16), int(color[4 : 6], 16)) for color in colors ] 66 | return cls(colors) 67 | 68 | def __getitem__(self, index): 69 | return self._colors[index] 70 | 71 | class EncoderImage(): 72 | name = "image" 73 | _HEADER = NamedStruct(( 74 | ("h", "width"), 75 | ("h", "height"), 76 | ("h", "offsetx"), 77 | ("h", "offsety"), 78 | )) 79 | _POINTER = NamedStruct(( 80 | ("l", "offset"), 81 | )) 82 | _SPANHDR = NamedStruct(( 83 | ("B", "yoffset"), 84 | ("B", "pixel_cnt"), 85 | ("B", "dummy"), 86 | )) 87 | _Palette = None 88 | 89 | @classmethod 90 | def _generate_palette(cls): 91 | if cls._Palette is not None: 92 | return 93 | cls._Palette = Palette.load_from_json("palette.json") 94 | 95 | @classmethod 96 | def decode(cls, encoded_data): 97 | cls._generate_palette() 98 | decoded_data = encoded_data 99 | header = cls._HEADER.unpack_head(encoded_data) 100 | metadata = { 101 | "offsetx": header.offsetx, 102 | "offsety": header.offsety, 103 | } 104 | 105 | start_ptrs = [ ] 106 | offset = cls._HEADER.size 107 | for x in range(header.width): 108 | start_ptr = cls._POINTER.unpack_head(encoded_data[offset : ]) 109 | start_ptrs.append(start_ptr) 110 | offset += cls._POINTER.size 111 | 112 | (img_width, img_height) = (header.width, header.height) 113 | raw_pixel_data = bytearray(4 * img_width * img_height) 114 | for (x, start_ptr) in enumerate(start_ptrs): 115 | offset = start_ptr.offset 116 | ybase = 0 117 | while True: 118 | if encoded_data[offset] == 255: 119 | break 120 | span_hdr = cls._SPANHDR.unpack_head(encoded_data[offset : ]) 121 | offset += cls._SPANHDR.size 122 | for y in range(span_hdr.pixel_cnt): 123 | pixel_index = encoded_data[offset + y] 124 | pixel_offset = 4 * (x + ((y + span_hdr.yoffset) * header.width)) 125 | pixel_value = cls._Palette[pixel_index] 126 | for i in range(3): 127 | raw_pixel_data[pixel_offset + i] = pixel_value[i] 128 | raw_pixel_data[pixel_offset + 3] = 0xff 129 | offset += span_hdr.pixel_cnt + 1 130 | 131 | row_data = [ raw_pixel_data[x : x + (4 * img_width)] for x in range(0, len(raw_pixel_data), 4 * img_width) ] 132 | iobuf = io.BytesIO() 133 | png_image = png.from_array(row_data, mode = "RGBA", info = { "width": header.width, "height": header.height }) 134 | png_image.write(iobuf) 135 | decoded_data = iobuf.getvalue() 136 | 137 | return (decoded_data, metadata) 138 | 139 | 140 | @classmethod 141 | def encode(cls, decoded_data, metadata = None): 142 | if metadata is None: 143 | metadata = { } 144 | def encode_column(column): 145 | def emit(start_offset, values): 146 | return cls._SPANHDR.pack({ 147 | "yoffset": start_offset, 148 | "pixel_cnt": len(values), 149 | "dummy": 0, 150 | }) + bytes(values) + b"\x00" 151 | 152 | encoded_column = bytearray() 153 | start_offset = None 154 | chunk_data = [ ] 155 | for (index, value) in enumerate(column): 156 | if value == -1: 157 | if start_offset is not None: 158 | # No more pixels, but we've seen some before 159 | encoded_column += emit(start_offset, chunk_data) 160 | start_offset = None 161 | chunk_data = [ ] 162 | else: 163 | # There's a pixel there 164 | if start_offset is None: 165 | # First pixel 166 | start_offset = index 167 | chunk_data = [ value ] 168 | else: 169 | # Subsequent pixel 170 | chunk_data.append(value) 171 | if len(chunk_data) >= 128: 172 | encoded_column += emit(start_offset, chunk_data) 173 | start_offset = None 174 | chunk_data = [ ] 175 | if start_offset is not None: 176 | encoded_column += emit(start_offset, chunk_data) 177 | return encoded_column + b"\xff" 178 | 179 | cls._generate_palette() 180 | iobuf = io.BytesIO(decoded_data) 181 | (width, height, pixels, info) = png.Reader(file = iobuf).read_flat() 182 | 183 | encoded_data = bytearray() 184 | encoded_data += cls._HEADER.pack({ 185 | "width": width, 186 | "height": height, 187 | "offsetx": metadata.get("offsetx", 0), 188 | "offsety": metadata.get("offsety", 0), 189 | }) 190 | 191 | column_offset = len(encoded_data) + (width * cls._POINTER.size) 192 | column_data = bytearray() 193 | pixels = bytes(pixels) 194 | if (info["planes"] == 3) and (info["alpha"] == False) and (len(pixels) == 3 * width * height): 195 | def rgb_to_rgba(data): 196 | for idx in range(0, len(data), 3): 197 | yield data[idx + 0] 198 | yield data[idx + 1] 199 | yield data[idx + 2] 200 | yield 0xff 201 | pixels = bytes(rgb_to_rgba(pixels)) 202 | assert(len(pixels) == 4 * width * height) 203 | 204 | for x in range(width): 205 | # First get the color indices of each column 206 | col_data = [ ] 207 | for y in range(height): 208 | pixel_offset = 4 * (x + (y * width)) 209 | (rgb, a) = (tuple(pixels[pixel_offset + 0 : pixel_offset + 3]), pixels[pixel_offset + 3]) 210 | if a == 255: 211 | pixel_index = cls._Palette.lookup(rgb) 212 | else: 213 | pixel_index = -1 214 | col_data.append(pixel_index) 215 | 216 | # Then encode them 217 | encoded_col_data = encode_column(col_data) 218 | column_data += encoded_col_data 219 | encoded_data += cls._POINTER.pack({ 220 | "offset": column_offset, 221 | }) 222 | column_offset += len(encoded_col_data) 223 | 224 | encoded_data += column_data 225 | return encoded_data 226 | --------------------------------------------------------------------------------