From a312c9270a78df1613d594d08ca178af00d27e72 Mon Sep 17 00:00:00 2001 From: MagicBot Date: Sun, 18 Oct 2026 11:22:52 -0400 Subject: [PATCH] Add Config.wpak unpack, pack and test A .wpak is a zip whose entries are each Blowfish CFB-64 encrypted with a zero IV and the client's hardcoded key (sb.exe 0x54DEC0). Pure Python Blowfish, no new dependencies. Co-Authored-By: Claude Opus 5.5 --- mbEditorPro2.0/arcane/util/ArcWpak.py | 178 +++++++++++++++++++++++++ mbEditorPro2.0/arcane/util/__init__.py | 1 + mbEditorPro2.0/mbEditorPro.py | 40 ++++++ 3 files changed, 219 insertions(+) create mode 100644 mbEditorPro2.0/arcane/util/ArcWpak.py diff --git a/mbEditorPro2.0/arcane/util/ArcWpak.py b/mbEditorPro2.0/arcane/util/ArcWpak.py new file mode 100644 index 0000000..5b06b64 --- /dev/null +++ b/mbEditorPro2.0/arcane/util/ArcWpak.py @@ -0,0 +1,178 @@ +# • ▌ ▄ ·. ▄▄▄▄· ▄▄▄ .·▄▄▄▄ ▪ ▄▄▄▄▄ ▄▄▄ ▄▄▄·▄▄▄ +# ·██ ▐███▪▐█ ▀█▪ ▀▄.▀·██▪ ██ ██ •██ ▪ ▀▄ █· ▐█ ▄█▀▄ █·▪ +# ▐█ ▌▐▌▐█·▐█▀▀█▄ ▐▀▀▪▄▐█· ▐█▌▐█· ▐█.▪ ▄█▀▄ ▐▀▀▄ ██▀·▐▀▀▄ ▄█▀▄ +# ██ ██▌▐█▌██▄▪▐█ ▐█▄▄▌██. ██ ▐█▌ ▐█▌·▐█▌.▐▌▐█•█▌ ▐█▪·•▐█•█▌▐█▌.▐▌ +# ▀▀ █▪▀▀▀·▀▀▀▀ ▀▀▀ ▀▀▀▀▀• ▀▀▀ ▀▀▀ ▀█▄▀▪.▀ ▀ .▀ .▀ ▀ ▀█▄▀▪ +# Magicbane Emulator Project © 2013 - 2022 +# www.magicbane.com + +import os +import time +import zipfile + +from .ArcFileCache import next_available_path + +# A .wpak is a zip (client uses minizip, ArcWPakFile 0x54D3C0) whose entries are +# each Blowfish CFB-64 encrypted with a zero IV (BF_cfb64_encrypt 0xCD9000). +WPAK_KEY = bytes([ + 0x85, 0x71, 0x40, 0x3C, 0x14, 0x50, 0x0B, 0x52, + 0x73, 0x2D, 0x10, 0x08, 0x63, 0x59, 0x5B, 0xAA, +]) # hardcoded at 0x54DEC0 + +WPAK_COMPRESS_LEVEL = 9 # zipOpenNewFileInZip level in ArcWPakFile writer + +MASK32 = 0xFFFFFFFF + + +def pi_fraction_words(count): + # Blowfish's initial P-array and S-boxes are the hex digits of pi's fraction. + guard_bits = 64 + bits = count * 32 + guard_bits + one = 1 << bits + + def arctan_inverse(x): + x_squared = x * x + term = one // x + total = term + sign = -1 + n = 1 + while term: + term //= x_squared + total += sign * (term // (2 * n + 1)) + sign = -sign + n += 1 + return total + + pi = 4 * (4 * arctan_inverse(5) - arctan_inverse(239)) + fraction = (pi - (3 << bits)) >> guard_bits + + return [(fraction >> (32 * (count - 1 - i))) & MASK32 for i in range(count)] + + +class ArcBlowfish: + p_array = None + s_boxes = None + + def __init__(self, key): + words = pi_fraction_words(18 + 4 * 256) + self.p_array = words[:18] + self.s_boxes = [words[18 + 256 * i: 18 + 256 * (i + 1)] for i in range(4)] + self.set_key(key) + + def set_key(self, key): + left = 0 + right = 0 + + for i in range(18): + key_word = 0 + for j in range(4): + key_word = (key_word << 8) | key[(i * 4 + j) % len(key)] + self.p_array[i] ^= key_word + + for i in range(0, 18, 2): + left, right = self.encrypt_block(left, right) + self.p_array[i] = left + self.p_array[i + 1] = right + + for s_box in self.s_boxes: + for i in range(0, 256, 2): + left, right = self.encrypt_block(left, right) + s_box[i] = left + s_box[i + 1] = right + + def encrypt_block(self, left, right): + p_array = self.p_array + s0, s1, s2, s3 = self.s_boxes + + for i in range(16): + left ^= p_array[i] + feistel = ((s0[left >> 24] + s1[(left >> 16) & 0xFF]) & MASK32) ^ s2[(left >> 8) & 0xFF] + right ^= (feistel + s3[left & 0xFF]) & MASK32 + left, right = right, left + + return right ^ p_array[17], left ^ p_array[16] + + def keystream_block(self, iv): + left, right = self.encrypt_block( + int.from_bytes(iv[:4], 'big'), + int.from_bytes(iv[4:], 'big') + ) + return left.to_bytes(4, 'big') + right.to_bytes(4, 'big') + + def cfb64_decrypt(self, data): + output = bytearray(len(data)) + iv = bytes(8) + + for offset in range(0, len(data), 8): + keystream = self.keystream_block(iv) + cipher_block = data[offset:offset + 8] + for i, cipher_byte in enumerate(cipher_block): + output[offset + i] = cipher_byte ^ keystream[i] + iv = cipher_block + + return bytes(output) + + def cfb64_encrypt(self, data): + output = bytearray(len(data)) + iv = bytes(8) + + for offset in range(0, len(data), 8): + keystream = self.keystream_block(iv) + plain_block = data[offset:offset + 8] + for i, plain_byte in enumerate(plain_block): + output[offset + i] = plain_byte ^ keystream[i] + iv = bytes(output[offset:offset + 8]) + + return bytes(output) + + +WPAK_CIPHER = ArcBlowfish(WPAK_KEY) + + +def load_wpak_file(filename): + entries = [] + + with zipfile.ZipFile(filename) as wpak_file: + for info in wpak_file.infolist(): + if info.is_dir(): + continue + entries.append([ + info.filename, + WPAK_CIPHER.cfb64_decrypt(wpak_file.read(info)) + ]) + + return entries + + +def load_wpak_directory(directory): + entries = [] + + # Original archives list entries in NTFS directory order (uppercase-folded sort). + for filename in sorted(os.listdir(directory), key=str.upper): + filepath = os.path.join(directory, filename) + if not os.path.isfile(filepath): + continue + with open(filepath, 'rb') as fp: + entries.append([filename, fp.read()]) + + return entries + + +def build_wpak_bytes(entries, wpak_file): + with zipfile.ZipFile(wpak_file, 'w') as archive: + for filename, data in entries: + info = zipfile.ZipInfo(filename, date_time=time.localtime()[:6]) + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr( + info, + WPAK_CIPHER.cfb64_encrypt(data), + compresslevel=WPAK_COMPRESS_LEVEL + ) + + +def save_wpak_file(filename, entries): + filename = next_available_path(filename) + print('Packing to ' + filename) + + with open(filename, 'wb') as wpak_file: + build_wpak_bytes(entries, wpak_file) diff --git a/mbEditorPro2.0/arcane/util/__init__.py b/mbEditorPro2.0/arcane/util/__init__.py index 6b98bd3..f235472 100644 --- a/mbEditorPro2.0/arcane/util/__init__.py +++ b/mbEditorPro2.0/arcane/util/__init__.py @@ -7,4 +7,5 @@ # www.magicbane.com from .ArcFileCache import load_cache_file, save_cache_file, next_available_path +from .ArcWpak import load_wpak_file, load_wpak_directory, save_wpak_file, build_wpak_bytes from .ResStream import ResStream diff --git a/mbEditorPro2.0/mbEditorPro.py b/mbEditorPro2.0/mbEditorPro.py index 3a14150..dcc0aee 100644 --- a/mbEditorPro2.0/mbEditorPro.py +++ b/mbEditorPro2.0/mbEditorPro.py @@ -45,6 +45,7 @@ MOTION_DIRECTORY = os.path.join(TARGET_DIRECTORY, 'MOTION') TILE_DIRECTORY = os.path.join(TARGET_DIRECTORY, 'TILE') SKELETON_DIRECTORY = os.path.join(TARGET_DIRECTORY, 'SKELETON') RENDER_DIRECTORY = os.path.join(TARGET_DIRECTORY, 'RENDER') +CONFIG_DIRECTORY = os.path.join(TARGET_DIRECTORY, 'Config') COBJECTS_MAGIC = 0x434c4e54 COBJECTS_MAP = { @@ -85,6 +86,7 @@ EDITOR_TARGETS = [ 'skeleton', 'terrain', 'render', + 'config', 'all' ] @@ -153,6 +155,11 @@ def init_render(): os.makedirs(directory_path, exist_ok=True) +def init_config(): + directory_path = CONFIG_DIRECTORY + os.makedirs(directory_path, exist_ok=True) + + def unpack_cobjects(): resources = load_cache_file('CObjects.cache') directory = create_unpack_directory(COBJECTS_DIRECTORY) @@ -841,6 +848,39 @@ def test_render(): print(res_id, in_stream.buffer.tell(), out_stream.buffer.tell()) +def unpack_config(): + entries = load_wpak_file('Config.wpak') + directory = create_unpack_directory(CONFIG_DIRECTORY) + + with tqdm(total=len(entries)) as pBar: + for filename, data in entries: + filepath = os.path.join(directory, filename) + with open(filepath, 'wb') as fp: + fp.write(data) + pBar.update(1) + + +def pack_config(): + init_config() + + entries = load_wpak_directory(CONFIG_DIRECTORY) + save_wpak_file('Config.wpak', entries) + + +def test_config(): + entries = load_wpak_file('Config.wpak') + out_stream = io.BytesIO() + + build_wpak_bytes(entries, out_stream) + repacked = dict(load_wpak_file(out_stream)) + + for filename, data in entries: + try: + assert repacked[filename] == data + except: + print(filename, len(data), len(repacked.get(filename, b''))) + + def unpack_all(): for cache_type in EDITOR_TARGETS[:-1]: method = 'unpack_' + cache_type + '()'