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 +18 -0
- mercurykit/__main__.py +4 -0
- mercurykit/_bfpk/__init__.py +5 -0
- mercurykit/_bfpk/codecs.py +140 -0
- mercurykit/_bfpk/engine.py +142 -0
- mercurykit/_bfpk/extraction.py +108 -0
- mercurykit/_bfpk/manifest.py +1061 -0
- mercurykit/_bfpk/models.py +50 -0
- mercurykit/_bfpk/repack.py +1001 -0
- mercurykit/archive.py +55 -0
- mercurykit/bfpk.py +5 -0
- mercurykit/binary.py +178 -0
- mercurykit/bits.py +52 -0
- mercurykit/cli.py +312 -0
- mercurykit/codecs.py +104 -0
- mercurykit/mirror_of_fate.py +461 -0
- mercurykit/mofh.py +5 -0
- mercurykit/progress.py +162 -0
- mercurykit/scanner.py +88 -0
- mercurykit/security.py +29 -0
- mercurykit-0.1.0.dist-info/METADATA +319 -0
- mercurykit-0.1.0.dist-info/RECORD +26 -0
- mercurykit-0.1.0.dist-info/WHEEL +5 -0
- mercurykit-0.1.0.dist-info/entry_points.txt +2 -0
- mercurykit-0.1.0.dist-info/licenses/LICENSE +21 -0
- mercurykit-0.1.0.dist-info/top_level.txt +1 -0
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,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
|
+
|