mercurykit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mercurykit/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """MercuryKit public package exports."""
2
+
3
+ from mercurykit.archive import ArchiveContext, ArchiveEntry, ArchiveInfo, ArchiveMatch, UnsupportedOperation
4
+ from mercurykit.bfpk import BfpkEngine
5
+ from mercurykit.binary import BinaryReader, Endian
6
+ from mercurykit.mirror_of_fate import MirrorOfFatePackEngine
7
+
8
+ __all__ = [
9
+ "ArchiveContext",
10
+ "ArchiveEntry",
11
+ "ArchiveInfo",
12
+ "ArchiveMatch",
13
+ "BfpkEngine",
14
+ "BinaryReader",
15
+ "Endian",
16
+ "MirrorOfFatePackEngine",
17
+ "UnsupportedOperation",
18
+ ]
mercurykit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from mercurykit.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .engine import BfpkEngine
4
+
5
+ __all__ = ["BfpkEngine"]
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from mercurykit.binary import EndOfStreamError
4
+ from mercurykit.archive import UnsupportedOperation
5
+
6
+
7
+ class BfpkCodecMixin:
8
+ def _aes_256_cbc_crypt(self, data: bytes, *, decrypt: bool) -> bytes:
9
+ if len(data) % 16 != 0:
10
+ raise ValueError("BFPK AES table data must be 16-byte aligned")
11
+ try:
12
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
13
+ except ImportError as exc:
14
+ raise UnsupportedOperation("BFPK AES table support requires the cryptography package") from exc
15
+
16
+ cipher = Cipher(algorithms.AES(self.lords_of_shadow_ultimate_aes_key), modes.CBC(bytes(16)))
17
+ transform = cipher.decryptor() if decrypt else cipher.encryptor()
18
+ return transform.update(data) + transform.finalize()
19
+
20
+ def _decrypt_lords_of_shadow_ultimate_table(self, data: bytes) -> bytes:
21
+ return self._aes_256_cbc_crypt(data, decrypt=True)
22
+
23
+ def _encrypt_lords_of_shadow_ultimate_table(self, data: bytes) -> bytes:
24
+ return self._aes_256_cbc_crypt(data, decrypt=False)
25
+
26
+ def _require_lz4_block(self):
27
+ try:
28
+ import lz4.block as lz4_block
29
+ except ImportError as exc:
30
+ raise UnsupportedOperation("BFPK LZ4 repack requires the optional lz4 package") from exc
31
+ return lz4_block
32
+
33
+ def _decompress_lz4_block(self, data: bytes, expected_size: int) -> bytes:
34
+ output = bytearray()
35
+ index = 0
36
+ while index < len(data):
37
+ token = data[index]
38
+ index += 1
39
+ literal_length = token >> 4
40
+ if literal_length == 15:
41
+ while True:
42
+ if index >= len(data):
43
+ raise EndOfStreamError("BFPK LZ4 literal length extends beyond chunk")
44
+ value = data[index]
45
+ index += 1
46
+ literal_length += value
47
+ if value != 255:
48
+ break
49
+
50
+ if index + literal_length > len(data):
51
+ raise EndOfStreamError("BFPK LZ4 literal data extends beyond chunk")
52
+ output.extend(data[index:index + literal_length])
53
+ index += literal_length
54
+ if index >= len(data):
55
+ break
56
+
57
+ if index + 2 > len(data):
58
+ raise EndOfStreamError("BFPK LZ4 match offset extends beyond chunk")
59
+ offset = data[index] | (data[index + 1] << 8)
60
+ index += 2
61
+ if offset == 0 or offset > len(output):
62
+ raise ValueError("BFPK LZ4 match offset is invalid")
63
+
64
+ match_length = (token & 0x0F) + 4
65
+ if (token & 0x0F) == 15:
66
+ while True:
67
+ if index >= len(data):
68
+ raise EndOfStreamError("BFPK LZ4 match length extends beyond chunk")
69
+ value = data[index]
70
+ index += 1
71
+ match_length += value
72
+ if value != 255:
73
+ break
74
+
75
+ start = len(output) - offset
76
+ for offset_index in range(match_length):
77
+ output.append(output[start + offset_index])
78
+
79
+ if len(output) != expected_size:
80
+ raise ValueError("BFPK LZ4 chunk decompressed to unexpected size")
81
+ return bytes(output)
82
+
83
+ def _xxh32(self, data: bytes, seed: int = 0) -> int:
84
+ prime1 = 0x9E3779B1
85
+ prime2 = 0x85EBCA77
86
+ prime3 = 0xC2B2AE3D
87
+ prime4 = 0x27D4EB2F
88
+ prime5 = 0x165667B1
89
+
90
+ def rotate_left(value: int, amount: int) -> int:
91
+ return ((value << amount) | (value >> (32 - amount))) & self.max_u32
92
+
93
+ index = 0
94
+ length = len(data)
95
+ if length >= 16:
96
+ v1 = (seed + prime1 + prime2) & self.max_u32
97
+ v2 = (seed + prime2) & self.max_u32
98
+ v3 = seed & self.max_u32
99
+ v4 = (seed - prime1) & self.max_u32
100
+ while index <= length - 16:
101
+ lane = int.from_bytes(data[index:index + 4], "little")
102
+ index += 4
103
+ v1 = (rotate_left((v1 + lane * prime2) & self.max_u32, 13) * prime1) & self.max_u32
104
+ lane = int.from_bytes(data[index:index + 4], "little")
105
+ index += 4
106
+ v2 = (rotate_left((v2 + lane * prime2) & self.max_u32, 13) * prime1) & self.max_u32
107
+ lane = int.from_bytes(data[index:index + 4], "little")
108
+ index += 4
109
+ v3 = (rotate_left((v3 + lane * prime2) & self.max_u32, 13) * prime1) & self.max_u32
110
+ lane = int.from_bytes(data[index:index + 4], "little")
111
+ index += 4
112
+ v4 = (rotate_left((v4 + lane * prime2) & self.max_u32, 13) * prime1) & self.max_u32
113
+ h32 = (rotate_left(v1, 1) + rotate_left(v2, 7) + rotate_left(v3, 12) + rotate_left(v4, 18)) & self.max_u32
114
+ else:
115
+ h32 = (seed + prime5) & self.max_u32
116
+
117
+ h32 = (h32 + length) & self.max_u32
118
+ while index <= length - 4:
119
+ lane = int.from_bytes(data[index:index + 4], "little")
120
+ index += 4
121
+ h32 = (rotate_left((h32 + lane * prime3) & self.max_u32, 17) * prime4) & self.max_u32
122
+ while index < length:
123
+ h32 = (rotate_left((h32 + data[index] * prime5) & self.max_u32, 11) * prime1) & self.max_u32
124
+ index += 1
125
+
126
+ h32 ^= h32 >> 15
127
+ h32 = (h32 * prime2) & self.max_u32
128
+ h32 ^= h32 >> 13
129
+ h32 = (h32 * prime3) & self.max_u32
130
+ h32 ^= h32 >> 16
131
+ return h32 & self.max_u32
132
+
133
+ def _check_u32(self, value: int, label: str) -> None:
134
+ if not 0 <= value <= self.max_u32:
135
+ raise ValueError(f"{label} must fit in u32")
136
+
137
+ def _check_u64(self, value: int, label: str) -> None:
138
+ if not 0 <= value <= self.max_u64:
139
+ raise ValueError(f"{label} must fit in u64")
140
+
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from typing import Iterable
6
+
7
+ from mercurykit.archive import ArchiveContext, ArchiveEntry, ArchiveInfo, ArchiveMatch, UnsupportedOperation
8
+ from mercurykit.binary import BinaryReader, EndOfStreamError
9
+
10
+ from .codecs import BfpkCodecMixin
11
+ from .extraction import BfpkExtractionMixin
12
+ from .manifest import BfpkManifestMixin
13
+ from .models import BfpkState
14
+ from .repack import BfpkRepackMixin
15
+
16
+
17
+ class BfpkEngine(BfpkManifestMixin, BfpkExtractionMixin, BfpkRepackMixin, BfpkCodecMixin):
18
+ """Engine for MercurySteam BFPK game archives."""
19
+
20
+ format_name = "MercurySteam BFPK Archive"
21
+
22
+ archive_magic = b"BFPK"
23
+ legacy_layout = "legacy"
24
+ blades_of_fire_layout = "blades_of_fire"
25
+ spacelords_layout = "spacelords"
26
+ lords_of_shadow_ultimate_layout = "lords_of_shadow_ultimate"
27
+ scrapland_layout = "scrapland"
28
+ scrapland_archive_version = 0x0
29
+ scrapland_path_encoding = "cp1252"
30
+ legacy_archive_versions = frozenset({0x100, 0x101, 0x102})
31
+ blades_of_fire_archive_versions = frozenset({0x100, 0x102, 0x300})
32
+ spacelords_archive_versions = frozenset({0x500, 0x502})
33
+ lords_of_shadow_ultimate_archive_versions = frozenset({0x2, 0x3})
34
+ chunked_header_archive_versions = frozenset({0x102, 0x502})
35
+ default_file_chunk_size = 0x10000
36
+ blades_of_fire_default_file_chunk_size = 0x40000
37
+ spacelords_default_file_chunk_size = 0x40000
38
+ spacelords_default_trailing_padding = 0x10000
39
+ encrypted_picture_alignment = 0x10000
40
+ spacelords_d01_archive_version = 0xD01
41
+ blades_of_fire_pics_archive_version = 0x901
42
+ blades_of_fire_pics_default_record_flags = 0x09FF
43
+ blades_of_fire_pics_gif_record_flag_low = 0xC0
44
+ spacelords_d01_default_record_flags = 0x00F1
45
+ spacelords_d01_gif_record_flag_low = 0xDF
46
+ spacelords_d01_tga_record_flag_low = 0x44
47
+ default_trailing_padding = 0x8000
48
+ default_compression_level = -1
49
+ lords_of_shadow_ultimate_table_prefix = b"MercuryKitLoSUE!"
50
+ lords_of_shadow_ultimate_aes_key = bytes.fromhex(
51
+ "50 43 56 80 72 73 EE 6F F1 44 F3 6E EA DF 79 43 "
52
+ "6C 69 6D 61 78 53 74 75 64 69 6F 73 32 30 31 33"
53
+ )
54
+ max_u32 = 0xFFFFFFFF
55
+ max_u64 = 0xFFFFFFFFFFFFFFFF
56
+
57
+ def _spacelords_d01_xor_key(self, offset: int) -> int:
58
+ return (((offset * offset * 0x343FD) + 0x269EC3) >> 16) & 0xFF
59
+
60
+ def _crypt_spacelords_d01_table(self, data: bytes, start_offset: int) -> bytes:
61
+ return bytes(byte ^ self._spacelords_d01_xor_key(start_offset + index) for index, byte in enumerate(data))
62
+
63
+ def _has_encrypted_picture_table(self, archive_version: int) -> bool:
64
+ return archive_version in {self.spacelords_d01_archive_version, self.blades_of_fire_pics_archive_version}
65
+
66
+ def _has_chunked_header(self, archive_version: int) -> bool:
67
+ return archive_version in self.chunked_header_archive_versions
68
+
69
+ def _has_lords_of_shadow_ultimate_table(self, archive_version: int) -> bool:
70
+ return archive_version in self.lords_of_shadow_ultimate_archive_versions
71
+
72
+ def _supports_legacy_layout(self, archive_version: int) -> bool:
73
+ return archive_version in self.legacy_archive_versions
74
+
75
+ def _supports_scrapland_layout(self, archive_version: int) -> bool:
76
+ return archive_version == self.scrapland_archive_version
77
+
78
+ def _supports_lords_of_shadow_ultimate_layout(self, archive_version: int) -> bool:
79
+ return archive_version in self.lords_of_shadow_ultimate_archive_versions
80
+
81
+ def _supports_blades_of_fire_layout(self, archive_version: int) -> bool:
82
+ return archive_version in self.blades_of_fire_archive_versions or archive_version == self.blades_of_fire_pics_archive_version
83
+
84
+ def _supports_spacelords_layout(self, archive_version: int) -> bool:
85
+ return archive_version in self.spacelords_archive_versions or archive_version == self.spacelords_d01_archive_version
86
+
87
+ def _encrypted_picture_layout(self, archive_version: int) -> str:
88
+ if archive_version == self.blades_of_fire_pics_archive_version:
89
+ return self.blades_of_fire_layout
90
+ if archive_version == self.spacelords_d01_archive_version:
91
+ return self.spacelords_layout
92
+ raise ValueError(f"BFPK archive version 0x{archive_version:x} is not an encrypted picture archive")
93
+
94
+ def evaluate(self, path: Path, reader: BinaryReader) -> ArchiveMatch:
95
+ try:
96
+ header = self._read_header(reader)
97
+ file_size = self._reader_size(reader)
98
+ layout, records = self._select_table_layout(reader, header, file_size)
99
+ self._validate_sample_payloads(reader, header, layout, records, file_size)
100
+ except (EndOfStreamError, UnicodeDecodeError, ValueError, UnsupportedOperation) as exc:
101
+ return ArchiveMatch(0.0, self.format_name, reason=str(exc))
102
+
103
+ reason = f"BFPK {layout} layout matched"
104
+ return ArchiveMatch(0.99, self.format_name, reason=reason)
105
+
106
+ def read_manifest(self, path: Path, reader: BinaryReader) -> ArchiveInfo:
107
+ header = self._read_header(reader)
108
+ file_size = self._reader_size(reader)
109
+ layout, records = self._select_table_layout(reader, header, file_size)
110
+ entries = [
111
+ self._entry_for_version(reader, header.archive_version, header.file_chunk_size, layout, record)
112
+ for record in records
113
+ ]
114
+
115
+ return ArchiveInfo(
116
+ self.format_name,
117
+ len(entries),
118
+ {
119
+ "archive_version": header.archive_version,
120
+ "file_chunk_size": header.file_chunk_size,
121
+ "table_format": layout,
122
+ "encrypted_table_size": header.encrypted_table_size,
123
+ "table_prefix": header.table_prefix,
124
+ "entries": tuple(entries),
125
+ },
126
+ )
127
+
128
+ def open(self, path: Path) -> ArchiveContext:
129
+ with path.open("rb") as file:
130
+ info = self.read_manifest(path, BinaryReader(file))
131
+ return ArchiveContext(path, self, info, BfpkState(tuple(info.metadata["entries"])))
132
+
133
+ def iter_entries(self, context: ArchiveContext) -> Iterable[ArchiveEntry]:
134
+ state = self._state(context)
135
+ yield from state.entries
136
+
137
+ def _state(self, context: ArchiveContext) -> BfpkState:
138
+ if isinstance(context.state, BfpkState):
139
+ return context.state
140
+ return BfpkState(tuple(context.info.metadata["entries"]))
141
+
142
+
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import zlib
4
+ from typing import BinaryIO
5
+
6
+ from mercurykit.binary import BinaryReader, EndOfStreamError
7
+ from mercurykit.codecs import compression_registry
8
+ from mercurykit.archive import ArchiveContext, ArchiveEntry
9
+
10
+
11
+ class BfpkExtractionMixin:
12
+ def extract_entry(self, context: ArchiveContext, entry: ArchiveEntry, output_stream: BinaryIO) -> None:
13
+ if entry.offset is None:
14
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} does not define an offset")
15
+ if entry.uncompressed_size is None:
16
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} does not define an uncompressed size")
17
+
18
+ if entry.metadata.get("chunked"):
19
+ self._extract_chunked_entry(context, entry, output_stream)
20
+ return
21
+
22
+ if entry.compression is not None:
23
+ if entry.compressed_size is None:
24
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} does not define a stored size")
25
+ with context.archive_path.open("rb") as file:
26
+ file.seek(entry.offset)
27
+ payload = file.read(entry.compressed_size)
28
+ if len(payload) != entry.compressed_size:
29
+ raise EndOfStreamError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} extends beyond archive data")
30
+ payload = compression_registry.decompress(entry.compression, payload)
31
+ if len(payload) != entry.uncompressed_size:
32
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} decompressed to unexpected size")
33
+ output_stream.write(payload)
34
+ return
35
+
36
+ with context.archive_path.open("rb") as file:
37
+ file.seek(entry.offset)
38
+ payload = file.read(entry.uncompressed_size)
39
+ if len(payload) != entry.uncompressed_size:
40
+ raise EndOfStreamError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} extends beyond archive data")
41
+ expected_crc = entry.metadata.get("table_hash")
42
+ if expected_crc is not None and (zlib.crc32(payload) & self.max_u32) != expected_crc:
43
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} CRC did not match")
44
+ output_stream.write(payload)
45
+
46
+ def _extract_chunked_entry(self, context: ArchiveContext, entry: ArchiveEntry, output_stream: BinaryIO) -> None:
47
+ chunk_offsets = tuple(entry.metadata.get("chunk_offsets") or ())
48
+ chunk_compressed_sizes = tuple(entry.metadata.get("chunk_compressed_sizes") or ())
49
+ chunk_uncompressed_sizes = tuple(entry.metadata.get("chunk_uncompressed_sizes") or ())
50
+ chunk_hashes = tuple(entry.metadata.get("chunk_hashes") or ())
51
+ if not (len(chunk_offsets) == len(chunk_compressed_sizes) == len(chunk_uncompressed_sizes)):
52
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} has inconsistent chunk metadata")
53
+ if chunk_hashes and len(chunk_hashes) != len(chunk_offsets):
54
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} has inconsistent chunk hash metadata")
55
+
56
+ total_uncompressed_size = 0
57
+ crc = 0
58
+ with context.archive_path.open("rb") as file:
59
+ for index, (chunk_offset, chunk_compressed_size, chunk_uncompressed_size) in enumerate(
60
+ zip(chunk_offsets, chunk_compressed_sizes, chunk_uncompressed_sizes)
61
+ ):
62
+ file.seek(chunk_offset)
63
+ payload = file.read(chunk_compressed_size)
64
+ if len(payload) != chunk_compressed_size:
65
+ raise EndOfStreamError(
66
+ f"Entry {entry.path or entry.entry_id or '<unnamed>'} chunk extends beyond archive data"
67
+ )
68
+ if chunk_hashes and self._xxh32(payload) != chunk_hashes[index]:
69
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} chunk hash did not match")
70
+
71
+ if entry.compression == "lz4-block":
72
+ payload = self._decompress_lz4_block(payload, chunk_uncompressed_size)
73
+ else:
74
+ payload = compression_registry.decompress("zlib", payload)
75
+ if len(payload) != chunk_uncompressed_size:
76
+ raise ValueError(
77
+ f"Entry {entry.path or entry.entry_id or '<unnamed>'} chunk decompressed to unexpected size"
78
+ )
79
+ output_stream.write(payload)
80
+ crc = zlib.crc32(payload, crc)
81
+ total_uncompressed_size += len(payload)
82
+
83
+ if total_uncompressed_size != entry.uncompressed_size:
84
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} decompressed to unexpected size")
85
+ expected_crc = entry.metadata.get("table_hash")
86
+ if expected_crc is not None and (crc & self.max_u32) != expected_crc:
87
+ raise ValueError(f"Entry {entry.path or entry.entry_id or '<unnamed>'} CRC did not match")
88
+
89
+ def _crc32_lz4_chunks(self, reader: BinaryReader, entry: ArchiveEntry) -> int:
90
+ chunk_offsets = tuple(entry.metadata.get("chunk_offsets") or ())
91
+ chunk_compressed_sizes = tuple(entry.metadata.get("chunk_compressed_sizes") or ())
92
+ chunk_uncompressed_sizes = tuple(entry.metadata.get("chunk_uncompressed_sizes") or ())
93
+ chunk_hashes = tuple(entry.metadata.get("chunk_hashes") or ())
94
+ layout = entry.metadata.get("table_format")
95
+ crc = 0
96
+ for index, (chunk_offset, chunk_compressed_size, chunk_uncompressed_size) in enumerate(
97
+ zip(chunk_offsets, chunk_compressed_sizes, chunk_uncompressed_sizes)
98
+ ):
99
+ reader.seek(chunk_offset)
100
+ payload = reader.read_exact(chunk_compressed_size)
101
+ if chunk_hashes and self._xxh32(payload) != chunk_hashes[index]:
102
+ if layout == self.spacelords_layout:
103
+ raise ValueError("BFPK Spacelords chunk hash did not match")
104
+ raise ValueError("BFPK Blades of Fire chunk hash did not match")
105
+ decompressed = self._decompress_lz4_block(payload, chunk_uncompressed_size)
106
+ crc = zlib.crc32(decompressed, crc)
107
+ return crc & self.max_u32
108
+