├── .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 |
--------------------------------------------------------------------------------