|
| 1 | +#! /usr/bin/env python3 |
| 2 | + |
| 3 | +# Sources: |
| 4 | +# https://archive.softwareheritage.org/browse/content/sha1_git:0bc8340ae58e9d88a23e52b0723b9a92e14f4e62/?origin_url=https://bitbucket.org/MnemonicWME/wme1&path=src/engine_core/wme_base/dcpackage.h |
| 5 | +# https://archive.softwareheritage.org/browse/content/sha1_git:4fd95c6076f4f2ce5dbbd1eead41b357bd232c66/?origin_url=https://bitbucket.org/MnemonicWME/wme1&path=src/engine_core/wme_base/BFileManager.cpp |
| 6 | + |
| 7 | +import collections |
| 8 | +from datetime import datetime |
| 9 | +import functools |
| 10 | +import pathlib |
| 11 | +import struct |
| 12 | +import zlib |
| 13 | + |
| 14 | +Header = collections.namedtuple('Header', ['magic1', 'magic2', 'pkg_version', 'game_version', 'priority', 'cd', 'master_index', 'creation_time', 'desc', 'num_dirs']) |
| 15 | +DirEntry = collections.namedtuple('DirEntry', ['name', 'cd', 'num_entries', 'files']) |
| 16 | +FileEntry = collections.namedtuple('FileEntry', ['name', 'offset', 'length', 'comp_length', 'flags', 'timedate1', 'timedate2'], defaults=(0, 0)) |
| 17 | + |
| 18 | +def read_struct(f, fmt, constructor): |
| 19 | + if type(fmt) is str: |
| 20 | + fmt = struct.Struct(fmt) |
| 21 | + |
| 22 | + buf = f.read(fmt.size) |
| 23 | + if len(buf) != fmt.size: |
| 24 | + raise Exception("File too small") |
| 25 | + |
| 26 | + return constructor(*fmt.unpack(buf)) |
| 27 | + |
| 28 | +def read_str(f): |
| 29 | + sz = f.read(1) |
| 30 | + if len(sz) != 1: |
| 31 | + raise Exception("File too small") |
| 32 | + sz, = struct.unpack('<B', sz) |
| 33 | + s = f.read(sz) |
| 34 | + if len(s) != sz: |
| 35 | + raise Exception("File too small") |
| 36 | + return s |
| 37 | + |
| 38 | +def read_headers(f, abs_offset = 0): |
| 39 | + f.seek(abs_offset) |
| 40 | + |
| 41 | + header = read_struct(f, '<L4sLLBBBxL100sL', Header) |
| 42 | + if header.magic1 != 0xdec0adde: |
| 43 | + raise Exception("Invalid magic") |
| 44 | + if header.magic2 != b'JUNK': |
| 45 | + raise Exception("Invalid magic") |
| 46 | + if header.pkg_version > 0x200: |
| 47 | + raise Exception("Invalid version") |
| 48 | + |
| 49 | + if header.pkg_version == 0x200: |
| 50 | + dir_offset, = struct.unpack('<L', f.read(4)) |
| 51 | + dir_offset += abs_offset |
| 52 | + f.seek(dir_offset) |
| 53 | + |
| 54 | + dirs = [] |
| 55 | + for pkg in range(header.num_dirs): |
| 56 | + files = [] |
| 57 | + dir_name = read_str(f) |
| 58 | + dir_name = dir_name.rstrip(b'\x00') |
| 59 | + dirent = read_struct(f, '<BL', functools.partial(DirEntry, dir_name, files=files)) |
| 60 | + dirs.append(dirent) |
| 61 | + |
| 62 | + for i in range(dirent.num_entries): |
| 63 | + fname = read_str(f) |
| 64 | + fname = bytes(b ^ 0x44 for b in fname) |
| 65 | + fname = fname.rstrip(b'\x00') |
| 66 | + |
| 67 | + if header.pkg_version == 0x200: |
| 68 | + fmt = '<LLLLLL' |
| 69 | + else: |
| 70 | + fmt = '<LLLL' |
| 71 | + fileent = read_struct(f, fmt, functools.partial(FileEntry, fname)) |
| 72 | + fileent = fileent._replace(offset=fileent.offset + abs_offset) |
| 73 | + files.append(fileent) |
| 74 | + |
| 75 | + return header, dirs |
| 76 | + |
| 77 | +def read_file(f, fileent): |
| 78 | + f.seek(fileent.offset) |
| 79 | + if fileent.comp_length: |
| 80 | + buf = f.read(fileent.comp_length) |
| 81 | + buf = zlib.decompress(buf) |
| 82 | + else: |
| 83 | + buf = f.read(fileent.length) |
| 84 | + if len(buf) != fileent.length: |
| 85 | + raise Exception("Invalid file size") |
| 86 | + return buf |
| 87 | + |
| 88 | +def lookup_sig(f): |
| 89 | + import mmap |
| 90 | + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm: |
| 91 | + offset = mm.find(b'\xde\xad\xc0\xdeJUNK') |
| 92 | + if offset == -1: |
| 93 | + raise Exception("Signature not found") |
| 94 | + return offset |
| 95 | + |
| 96 | +def dcp_list(options, offset=0): |
| 97 | + header, dirs = read_headers(options.input, offset) |
| 98 | + for dirent in dirs: |
| 99 | + print("Directory {0} from CD {1} with {2} entries".format(dirent.name.decode('utf-8'), dirent.cd, dirent.num_entries)) |
| 100 | + print("{0:<8}\t@{1:<8}\t{2:<8}\t{3:<19}\t{4}".format('sz', 'offset ', 'compsz', 'date', 'name')) |
| 101 | + for fl in dirent.files: |
| 102 | + print("{0:<8}\t@{1:<8}\t{2:<8}\t{3}\t{4}".format( |
| 103 | + fl.length, fl.offset, fl.length if fl.comp_length == 0 else fl.comp_length, |
| 104 | + datetime.fromtimestamp(fl.timedate1 | (fl.timedate2 << 64)).isoformat(), fl.name.decode('utf-8'))) |
| 105 | + |
| 106 | +def dcp_extract(options, offset=0): |
| 107 | + header, dirs = read_headers(options.input, offset) |
| 108 | + |
| 109 | + output_dir = options.output_dir |
| 110 | + for dirent in dirs: |
| 111 | + print("Directory {0} from CD {1} with {2} entries".format(dirent.name.decode('utf-8'), dirent.cd, dirent.num_entries)) |
| 112 | + print("{0:<8}\t@{1:<8}\t{2:<8}\t{3:<19}\t{4}".format('sz', 'offset ', 'compsz', 'date', 'name')) |
| 113 | + output_int = output_dir / dirent.name.decode('utf-8') |
| 114 | + for fl in dirent.files: |
| 115 | + print("{0:<8}\t@{1:<8}\t{2:<8}\t{3}\t{4}".format( |
| 116 | + fl.length, fl.offset, fl.length if fl.comp_length == 0 else fl.comp_length, |
| 117 | + datetime.fromtimestamp(fl.timedate1 | (fl.timedate2 << 64)).isoformat(), fl.name.decode('utf-8'))) |
| 118 | + output_file = output_int / pathlib.Path(fl.name.decode('utf-8').replace('\\', '/')) |
| 119 | + output_file.parent.mkdir(parents=True, exist_ok=True) |
| 120 | + |
| 121 | + with output_file.open('wb') as output_f: |
| 122 | + buf = read_file(options.input, fl) |
| 123 | + output_f.write(buf) |
| 124 | + |
| 125 | +def main(): |
| 126 | + import argparse |
| 127 | + |
| 128 | + parser = argparse.ArgumentParser( |
| 129 | + prog='dcp_extractor.py', |
| 130 | + description='Wintermute DCP archive extractor') |
| 131 | + |
| 132 | + parser.add_argument('--sfx', |
| 133 | + action='store_true') |
| 134 | + |
| 135 | + action_parsers = parser.add_subparsers(required=True) |
| 136 | + list_parser = action_parsers.add_parser('list', help='list archive contents') |
| 137 | + list_parser.add_argument('input', type=argparse.FileType('rb'), metavar='dcp file') |
| 138 | + list_parser.set_defaults(action=dcp_list) |
| 139 | + extract_parser = action_parsers.add_parser('extract', help='extract archive contents') |
| 140 | + extract_parser.add_argument('input', type=argparse.FileType('rb'), metavar='dcp file') |
| 141 | + extract_parser.add_argument('output_dir', type=pathlib.Path, metavar='output directory') |
| 142 | + extract_parser.set_defaults(action=dcp_extract) |
| 143 | + |
| 144 | + options = parser.parse_args() |
| 145 | + |
| 146 | + offset = 0 |
| 147 | + if options.sfx: |
| 148 | + offset = lookup_sig(options.input) |
| 149 | + print("Found signature at {}".format(offset)) |
| 150 | + |
| 151 | + options.action(options, offset=offset) |
| 152 | + |
| 153 | +if __name__ == "__main__": |
| 154 | + main() |
0 commit comments