osz2 1.0.0__tar.gz

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.
osz2-1.0.0/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+
2
+ Copyright (c) 2025 Levi <contact@lekuru.xyz>, ascenttree
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+
6
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7
+
8
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
osz2-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: osz2
3
+ Version: 1.0.0
4
+ Summary: A python library for reading osz2 files
5
+ Author: Lekuru
6
+ Author-email: contact@lekuru.xyz
7
+ Keywords: osu,osz2,python,bancho
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: author
11
+ Dynamic: author-email
12
+ Dynamic: description
13
+ Dynamic: description-content-type
14
+ Dynamic: keywords
15
+ Dynamic: license-file
16
+ Dynamic: summary
17
+
18
+ # osz2.py
19
+
20
+ [![Python Version](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/)
21
+ [![GitHub License](https://img.shields.io/github/license/Lekuruu/osz2.py)](https://github.com/Lekuruu/osz2.py/blob/main/LICENSE)
22
+ [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Lekuruu/osz2.py/.github%2Fworkflows%2Fbuild.yml)](https://github.com/Lekuruu/osz2.py/actions/workflows/build.yml)
23
+
24
+ osz2.py is a Python library for reading osz2 files. It's a direct port of the existing [Osz2Decryptor](https://github.com/xxCherry/Osz2Decryptor) project by [xxCherry](https://github.com/xxCherry) and [osz2-go](https://github.com/Lekuruu/osz2-go) by me. The Python port itself was done by [@ascenttree](https://github.com/ascenttree); all credit goes to them.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install git+https://github.com/Lekuruu/osz2.py@main
30
+ ```
31
+
32
+ Or install from source:
33
+
34
+ ```bash
35
+ git clone https://github.com/Lekuruu/osz2.py
36
+ cd osz2.py
37
+ pip install -e .
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ This repository provides a command-line interface for easy testing:
43
+
44
+ ```bash
45
+ python cli.py <input.osz2> <output_directory>
46
+ ```
47
+
48
+ But that's not all!
49
+ Here is an example of how to use osz2.py as a library:
50
+
51
+ ```python
52
+ from osz2 import Osz2Package, MetadataType
53
+
54
+ # Parse package from file
55
+ package = Osz2Package.from_file("beatmap.osz2")
56
+
57
+ # Access metadata
58
+ print("Title:", package.metadata.get(MetadataType.Title))
59
+ print("Artist:", package.metadata.get(MetadataType.Artist))
60
+ print("Creator:", package.metadata.get(MetadataType.Creator))
61
+ print("Difficulty:", package.metadata.get(MetadataType.Difficulty))
62
+
63
+ # Access files
64
+ for file in package.files:
65
+ print(f"File: {file.filename}, Size: {len(file.content)} bytes")
66
+
67
+ # Extract specific files
68
+ for file in package.files:
69
+ if not file.filename.endswith(".osu"):
70
+ continue
71
+
72
+ with open(file.filename, "wb") as f:
73
+ f.write(file.content)
74
+ ```
75
+
76
+ ### Metadata-only Mode
77
+
78
+ If you only need to read metadata without extracting files, you can use the `metadata_only` parameter:
79
+
80
+ ```python
81
+ # Only parse metadata
82
+ package = Osz2Package.from_file("beatmap.osz2", metadata_only=True)
83
+
84
+ # Access metadata
85
+ print("Title:", package.metadata.get(MetadataType.Title))
86
+ print("BeatmapSet ID:", package.metadata.get(MetadataType.BeatmapSetID))
87
+ ```
88
+
89
+ ### Alternative Constructors
90
+
91
+ ```python
92
+ # From file path
93
+ package = Osz2Package.from_file("beatmap.osz2")
94
+
95
+ # From bytes
96
+ with open("beatmap.osz2", "rb") as f:
97
+ data = f.read()
98
+ package = Osz2Package.from_bytes(data)
99
+
100
+ # From an io.BufferedReader-like object, e.g. a file stream
101
+ with open("beatmap.osz2", "rb") as f:
102
+ package = Osz2Package(f)
103
+ ```
osz2-1.0.0/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # osz2.py
2
+
3
+ [![Python Version](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/)
4
+ [![GitHub License](https://img.shields.io/github/license/Lekuruu/osz2.py)](https://github.com/Lekuruu/osz2.py/blob/main/LICENSE)
5
+ [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Lekuruu/osz2.py/.github%2Fworkflows%2Fbuild.yml)](https://github.com/Lekuruu/osz2.py/actions/workflows/build.yml)
6
+
7
+ osz2.py is a Python library for reading osz2 files. It's a direct port of the existing [Osz2Decryptor](https://github.com/xxCherry/Osz2Decryptor) project by [xxCherry](https://github.com/xxCherry) and [osz2-go](https://github.com/Lekuruu/osz2-go) by me. The Python port itself was done by [@ascenttree](https://github.com/ascenttree); all credit goes to them.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install git+https://github.com/Lekuruu/osz2.py@main
13
+ ```
14
+
15
+ Or install from source:
16
+
17
+ ```bash
18
+ git clone https://github.com/Lekuruu/osz2.py
19
+ cd osz2.py
20
+ pip install -e .
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ This repository provides a command-line interface for easy testing:
26
+
27
+ ```bash
28
+ python cli.py <input.osz2> <output_directory>
29
+ ```
30
+
31
+ But that's not all!
32
+ Here is an example of how to use osz2.py as a library:
33
+
34
+ ```python
35
+ from osz2 import Osz2Package, MetadataType
36
+
37
+ # Parse package from file
38
+ package = Osz2Package.from_file("beatmap.osz2")
39
+
40
+ # Access metadata
41
+ print("Title:", package.metadata.get(MetadataType.Title))
42
+ print("Artist:", package.metadata.get(MetadataType.Artist))
43
+ print("Creator:", package.metadata.get(MetadataType.Creator))
44
+ print("Difficulty:", package.metadata.get(MetadataType.Difficulty))
45
+
46
+ # Access files
47
+ for file in package.files:
48
+ print(f"File: {file.filename}, Size: {len(file.content)} bytes")
49
+
50
+ # Extract specific files
51
+ for file in package.files:
52
+ if not file.filename.endswith(".osu"):
53
+ continue
54
+
55
+ with open(file.filename, "wb") as f:
56
+ f.write(file.content)
57
+ ```
58
+
59
+ ### Metadata-only Mode
60
+
61
+ If you only need to read metadata without extracting files, you can use the `metadata_only` parameter:
62
+
63
+ ```python
64
+ # Only parse metadata
65
+ package = Osz2Package.from_file("beatmap.osz2", metadata_only=True)
66
+
67
+ # Access metadata
68
+ print("Title:", package.metadata.get(MetadataType.Title))
69
+ print("BeatmapSet ID:", package.metadata.get(MetadataType.BeatmapSetID))
70
+ ```
71
+
72
+ ### Alternative Constructors
73
+
74
+ ```python
75
+ # From file path
76
+ package = Osz2Package.from_file("beatmap.osz2")
77
+
78
+ # From bytes
79
+ with open("beatmap.osz2", "rb") as f:
80
+ data = f.read()
81
+ package = Osz2Package.from_bytes(data)
82
+
83
+ # From an io.BufferedReader-like object, e.g. a file stream
84
+ with open("beatmap.osz2", "rb") as f:
85
+ package = Osz2Package(f)
86
+ ```
@@ -0,0 +1,13 @@
1
+
2
+ __author__ = "Lekuru"
3
+ __email__ = "contact@lekuru.xyz"
4
+ __version__ = "1.0.0"
5
+ __license__ = "MIT"
6
+
7
+ from .package import Osz2Package
8
+ from .types import MetadataType
9
+ from .file import File
10
+
11
+ from .simple_cryptor import SimpleCryptor
12
+ from .xxtea_reader import XXTEAReader
13
+ from .xxtea import XXTEA
@@ -0,0 +1,13 @@
1
+
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+
5
+ @dataclass
6
+ class File:
7
+ filename: str
8
+ offset: int
9
+ size: int
10
+ hash: bytes
11
+ date_created: datetime
12
+ date_modified: datetime
13
+ content: bytes
@@ -0,0 +1,157 @@
1
+
2
+ from osz2.xxtea_reader import XXTEAReader
3
+ from osz2.xxtea import XXTEA
4
+ from typing import Dict, List
5
+
6
+ from .types import MetadataType
7
+ from .file import File
8
+ from .utils import *
9
+
10
+ import datetime
11
+ import hashlib
12
+ import struct
13
+ import io
14
+
15
+ class Osz2Package:
16
+ def __init__(self, reader: io.BufferedReader, metadata_only: bool = False) -> None:
17
+ self.metadata: Dict[MetadataType, str] = {}
18
+ self.filenames: Dict[str, int] = {}
19
+ self.files: List[File] = []
20
+ self.key: bytes = b""
21
+
22
+ self.metadata_hash: bytes = b""
23
+ self.file_info_hash: bytes = b""
24
+ self.full_body_hash: bytes = b""
25
+
26
+ # Always read the header when initializing
27
+ self.read_header(reader)
28
+
29
+ if not metadata_only:
30
+ # Read the files if requested
31
+ self.read_files(reader)
32
+
33
+ @classmethod
34
+ def from_file(cls, path: str, metadata_only: bool = False) -> "Osz2Package":
35
+ with open(path, "rb") as f:
36
+ return cls(f, metadata_only)
37
+
38
+ @classmethod
39
+ def from_bytes(cls, data: bytes, metadata_only: bool = False) -> "Osz2Package":
40
+ with io.BytesIO(data) as f:
41
+ return cls(f, metadata_only)
42
+
43
+ def read_header(self, reader: io.BufferedReader) -> None:
44
+ magic = reader.read(3)
45
+ assert magic == b"\xECHO", "Not a valid osz2 package" # nice one echo
46
+
47
+ # Seek 17 bytes from the current position
48
+ # to skip unused version byte & IV data
49
+ reader.seek(17, 1)
50
+
51
+ self.metadata_hash = reader.read(16)
52
+ self.file_info_hash = reader.read(16)
53
+ self.full_body_hash = reader.read(16)
54
+
55
+ self.read_metadata(reader)
56
+ self.read_file_names(reader)
57
+
58
+ assert MetadataType.Creator in self.metadata, "Metadata is missing creator"
59
+ assert MetadataType.BeatmapSetID in self.metadata, "Metadata is missing beatmapset ID"
60
+
61
+ creator = self.metadata[MetadataType.Creator]
62
+ beatmapset_id = self.metadata[MetadataType.BeatmapSetID]
63
+
64
+ seed = f"{creator}yhxyfjo5{beatmapset_id}"
65
+ self.key = hashlib.md5(seed.encode()).digest()
66
+
67
+ def read_metadata(self, reader: io.BufferedReader) -> None:
68
+ buffer = reader.read(4)
69
+ count = struct.unpack("<I", buffer)[0]
70
+
71
+ for _ in range(count):
72
+ buf = reader.read(2)
73
+ meta_type = struct.unpack("<H", buf)[0]
74
+ meta_value = read_string(reader)
75
+
76
+ self.metadata[MetadataType(meta_type)] = meta_value
77
+
78
+ buffer += buf
79
+ buffer += write_string(meta_value)
80
+
81
+ hash = compute_osz_hash(buffer, count*3, 0xA7)
82
+ assert hash == self.metadata_hash, f"Hash mismatch, expected: {hash}, got: {self.metadata_hash}"
83
+
84
+ def read_file_names(self, reader: io.BufferedReader) -> None:
85
+ buffer = reader.read(4)
86
+ count = struct.unpack("<I", buffer)[0]
87
+
88
+ for _ in range(count):
89
+ filename = read_string(reader)
90
+ beatmap_id = struct.unpack("<I", reader.read(4))[0]
91
+ self.filenames[filename] = beatmap_id
92
+
93
+ for name, id in self.filenames.items():
94
+ print(f"{name}: {id}")
95
+
96
+ def read_files(self, reader: io.BufferedReader) -> None:
97
+ reader.seek(64, 1)
98
+
99
+ # Read encrypted i32 length
100
+ length = struct.unpack("<I", reader.read(4))[0]
101
+
102
+ # Decode length by encrypted length
103
+ for i in range(0, 16, 2):
104
+ length -= self.file_info_hash[i] | (self.file_info_hash[i+1] << 17)
105
+
106
+ file_info = reader.read()
107
+ file_offset = reader.seek(0, 1)
108
+ total_size = reader.seek(0, 2)
109
+ reader.seek(file_offset, 0)
110
+
111
+ key = bytes_to_uint32_array(self.key)
112
+ xxtea_reader = XXTEAReader(io.BytesIO(file_info), key)
113
+
114
+ # Parse file info using xxtea stream and proceed to read file contents
115
+ self.parse_files(xxtea_reader, file_offset, total_size)
116
+
117
+ def parse_files(self, reader: XXTEAReader, file_offset: int, total_size: int) -> None:
118
+ count = struct.unpack("<I", reader.read(4))[0]
119
+ curr_offset = struct.unpack("<I", reader.read(4))[0]
120
+
121
+ for i in range(count):
122
+ filename = read_string(reader)
123
+ file_hash = reader.read(16)
124
+
125
+ date_created_binary = struct.unpack("<Q", reader.read(8))[0]
126
+ date_modified_binary = struct.unpack("<Q", reader.read(8))[0]
127
+
128
+ # Convert from .NET DateTime.ToBinary() format
129
+ date_created = datetime_from_binary(date_created_binary)
130
+ date_modified = datetime_from_binary(date_modified_binary)
131
+
132
+ next_offset = 0
133
+ if count > i + 1:
134
+ next_offset = struct.unpack("<I", reader.read(4))[0]
135
+ else:
136
+ # This is the last file, so we calculate size differently
137
+ # using total file size minus file offset
138
+ next_offset = total_size - file_offset
139
+
140
+ file_length = next_offset - curr_offset
141
+
142
+ file = File(
143
+ filename,
144
+ curr_offset,
145
+ file_length,
146
+ file_hash,
147
+ date_created,
148
+ date_modified,
149
+ content=bytes(),
150
+ )
151
+ self.files.append(file)
152
+ curr_offset = next_offset
153
+
154
+ # After reading the file info, read the actual file contents
155
+ for i in range(len(self.files)):
156
+ length = struct.unpack("<I", reader.read(4))[0]
157
+ self.files[i].content = reader.read(length)
@@ -0,0 +1,51 @@
1
+
2
+ from typing import List
3
+
4
+ class SimpleCryptor:
5
+ def __init__(self, key: bytes) -> None:
6
+ self.key = key
7
+
8
+ def encrypt_bytes(self, buf: bytearray) -> None:
9
+ byte_key = self.uint32_slice_to_byte_slice(self.key)
10
+ prev_encrypted = 0
11
+
12
+ for i in range(len(buf)):
13
+ sum_val = buf[i] + (byte_key[i % 16] >> 2)
14
+ buf[i] = ((sum_val % 256) + 256) % 256
15
+ buf[i] ^= self.rotate_left(byte_key[15 - i % 16], (prev_encrypted + len(buf) - i) % 7)
16
+ buf[i] = self.rotate_right(buf[i], (~prev_encrypted & 0xFFFFFFFF) % 7)
17
+ prev_encrypted = buf[i]
18
+
19
+ def decrypt_bytes(self, buf: bytearray) -> None:
20
+ byte_key = self.uint32_slice_to_byte_slice(self.key)
21
+ prev_encrypted = 0
22
+
23
+ for i in range(len(buf)):
24
+ tmp_e = buf[i]
25
+ buf[i] = self.rotate_left(buf[i], (~prev_encrypted & 0xFFFFFFFF) % 7)
26
+ buf[i] ^= self.rotate_left(byte_key[15 - i % 16], (prev_encrypted + len(buf) - i) % 7)
27
+ diff = buf[i] - (byte_key[i % 16] >> 2)
28
+ buf[i] = ((diff % 256) + 256) % 256
29
+ prev_encrypted = tmp_e
30
+
31
+ @staticmethod
32
+ def rotate_left(val: int, n: int) -> int:
33
+ val &= 0xFF
34
+ n &= 0x07
35
+ return ((val << n) | (val >> (8 - n))) & 0xFF
36
+
37
+ @staticmethod
38
+ def rotate_right(val: int, n: int) -> int:
39
+ val &= 0xFF
40
+ n &= 0x07
41
+ return ((val >> n) | (val << (8 - n))) & 0xFF
42
+
43
+ @staticmethod
44
+ def uint32_slice_to_byte_slice(u32s: List[int]) -> List[int]:
45
+ bytes_list = []
46
+ for u32 in u32s:
47
+ bytes_list.append(u32 & 0xFF)
48
+ bytes_list.append((u32 >> 8) & 0xFF)
49
+ bytes_list.append((u32 >> 16) & 0xFF)
50
+ bytes_list.append((u32 >> 24) & 0xFF)
51
+ return bytes_list
@@ -0,0 +1,27 @@
1
+
2
+ from enum import IntEnum
3
+
4
+ class MetadataType(IntEnum):
5
+ Title = 0
6
+ Artist = 1
7
+ Creator = 2
8
+ Version = 3
9
+ Source = 4
10
+ Tags = 5
11
+ VideoDataOffset = 6
12
+ VideoDataLength = 7
13
+ VideoHash = 8
14
+ BeatmapSetID = 9
15
+ Genre = 10
16
+ Language = 11
17
+ TitleUnicode = 12
18
+ ArtistUnicode = 13
19
+ Difficulty = 14
20
+ PreviewTime = 15
21
+ ArtistFullName = 16
22
+ ArtistTwitter = 17
23
+ SourceUnicode = 18
24
+ ArtistURL = 19
25
+ Revision = 20
26
+ PackID = 21
27
+ Unknown = 9999
@@ -0,0 +1,57 @@
1
+
2
+ import datetime
3
+ import hashlib
4
+ import struct
5
+ import io
6
+
7
+ def bytes_to_uint32_array(data: bytes) -> list[int]:
8
+ return [x[0] for x in struct.iter_unpack("<I", data)]
9
+
10
+ def read_string(reader: io.BufferedReader) -> str:
11
+ length = read_uleb128(reader)
12
+ return reader.read(length).decode('utf-8')
13
+
14
+ def write_string(string: str) -> bytes:
15
+ buf = write_uleb128(len(string))
16
+ return buf + string.encode('utf-8')
17
+
18
+ def read_uleb128(reader: io.BufferedReader) -> int:
19
+ result = 0
20
+ shift = 0
21
+
22
+ while True:
23
+ b = reader.read(1)
24
+ result |= b[0]&0x7F << shift
25
+ if b[0]&0x80 == 0:
26
+ break
27
+ shift += 7
28
+
29
+ return result
30
+
31
+ def write_uleb128(value: int) -> bytes:
32
+ buf = bytearray()
33
+ while value >= 0x80:
34
+ buf.append(value | 0x80)
35
+ value >>= 7
36
+ buf.append(value)
37
+ return bytes(buf)
38
+
39
+ def compute_osz_hash(buffer: bytes, pos: int, swap: int) -> bytes:
40
+ buf = buffer
41
+ if pos >= 0 and pos < len(buffer):
42
+ buf = bytearray(buffer)
43
+ buf[pos] ^= swap
44
+
45
+ hash = bytearray(hashlib.md5(buf).digest())
46
+ for i in range(8):
47
+ hash[i], hash[i+8] = hash[i+8], hash[i]
48
+
49
+ hash[5] ^= 0x2D
50
+ return bytes(hash)
51
+
52
+ def datetime_from_binary(time: int) -> datetime.datetime:
53
+ n_ticks = time & 0x3FFFFFFFFFFFFFFF
54
+ secs = n_ticks / 1e7
55
+ d1 = datetime.datetime(1, 1, 1)
56
+ t1 = datetime.timedelta(seconds=secs)
57
+ return d1 + t1
@@ -0,0 +1,173 @@
1
+
2
+ from .simple_cryptor import SimpleCryptor
3
+ import struct
4
+
5
+ MAX_WORDS = 16
6
+ MAX_BYTES = MAX_WORDS * 4
7
+ TEA_DELTA = 0x9E3779B9
8
+
9
+ class XXTEA:
10
+ def __init__(self, key: bytes) -> None:
11
+ self.cryptor = SimpleCryptor(key)
12
+ self.key = key
13
+ self.n = 0
14
+
15
+ def decrypt(self, buffer: bytearray, start: int, count: int) -> None:
16
+ self.encrypt_decrypt(buffer, start, count, False)
17
+
18
+ def encrypt(self, buffer: bytearray, start: int, count: int) -> None:
19
+ self.encrypt_decrypt(buffer, start, count, True)
20
+
21
+ def encrypt_decrypt(self, buffer: bytearray, buf_start: int, count: int, encrypt: bool) -> None:
22
+ full_word_count = count // MAX_BYTES
23
+ left_over = count % MAX_BYTES
24
+
25
+ for i in range(full_word_count):
26
+ offset = buf_start + i * MAX_BYTES
27
+ if encrypt:
28
+ self.encrypt_fixed_word_array(buffer, offset)
29
+ else:
30
+ self.decrypt_fixed_word_array(buffer, offset)
31
+
32
+ if left_over == 0:
33
+ return
34
+
35
+ leftover_start = buf_start + full_word_count * MAX_BYTES
36
+ self.n = left_over // 4
37
+
38
+ if self.n > 1:
39
+ if encrypt:
40
+ self.encrypt_words(buffer, leftover_start)
41
+ else:
42
+ self.decrypt_words(buffer, leftover_start)
43
+
44
+ left_over -= self.n * 4
45
+ if left_over == 0:
46
+ return
47
+
48
+ leftover_start += self.n * 4
49
+
50
+ remaining = buffer[leftover_start:leftover_start + left_over]
51
+
52
+ if encrypt:
53
+ self.cryptor.encrypt_bytes(remaining)
54
+ else:
55
+ self.cryptor.decrypt_bytes(remaining)
56
+
57
+ buffer[leftover_start:leftover_start + left_over] = remaining
58
+
59
+ def encrypt_words(self, data: bytearray, offset: int) -> None:
60
+ if len(data) - offset < self.n * 4:
61
+ return
62
+
63
+ v = [struct.unpack_from('<I', data, offset + i * 4)[0] for i in range(self.n)]
64
+
65
+ rounds = 6 + 52 // self.n
66
+ sum_val = 0
67
+ z = v[self.n - 1]
68
+
69
+ while rounds > 0:
70
+ sum_val = (sum_val + TEA_DELTA) & 0xFFFFFFFF
71
+ e = (sum_val >> 2) & 3
72
+
73
+ for p in range(self.n - 1):
74
+ y = v[p + 1]
75
+ v[p] = (v[p] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum_val ^ y) + (self.key[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
76
+ z = v[p]
77
+
78
+ y = v[0]
79
+ p = self.n - 1
80
+ v[self.n - 1] = (v[self.n - 1] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum_val ^ y) + (self.key[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
81
+ z = v[self.n - 1]
82
+ rounds -= 1
83
+
84
+ for i in range(self.n):
85
+ struct.pack_into('<I', data, offset + i * 4, v[i])
86
+
87
+ def decrypt_words(self, data: bytearray, offset: int) -> None:
88
+ if len(data) - offset < self.n * 4:
89
+ return
90
+
91
+ v = [struct.unpack_from('<I', data, offset + i * 4)[0] for i in range(self.n)]
92
+
93
+ rounds = 6 + 52 // self.n
94
+ sum_val = (rounds * TEA_DELTA) & 0xFFFFFFFF
95
+ y = v[0]
96
+
97
+ while True:
98
+ e = (sum_val >> 2) & 3
99
+
100
+ for p in range(self.n - 1, 0, -1):
101
+ z = v[p - 1]
102
+ v[p] = (v[p] - ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum_val ^ y) + (self.key[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
103
+ y = v[p]
104
+
105
+ z = v[self.n - 1]
106
+ p = 0
107
+ v[0] = (v[0] - ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum_val ^ y) + (self.key[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
108
+ y = v[0]
109
+
110
+ sum_val = (sum_val - TEA_DELTA) & 0xFFFFFFFF
111
+ if sum_val == 0:
112
+ break
113
+
114
+ for i in range(self.n):
115
+ struct.pack_into('<I', data, offset + i * 4, v[i])
116
+
117
+ def encrypt_fixed_word_array(self, data: bytearray, offset: int) -> None:
118
+ if len(data) - offset < MAX_BYTES:
119
+ return
120
+
121
+ v = [struct.unpack_from('<I', data, offset + i * 4)[0] for i in range(MAX_WORDS)]
122
+
123
+ rounds = 6 + 52 // MAX_WORDS
124
+ sum_val = 0
125
+ z = v[MAX_WORDS - 1]
126
+
127
+ while rounds > 0:
128
+ sum_val = (sum_val + TEA_DELTA) & 0xFFFFFFFF
129
+ e = (sum_val >> 2) & 3
130
+
131
+ for p in range(MAX_WORDS - 1):
132
+ y = v[p + 1]
133
+ v[p] = (v[p] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum_val ^ y) + (self.key[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
134
+ z = v[p]
135
+
136
+ y = v[0]
137
+ p = MAX_WORDS - 1
138
+ v[MAX_WORDS - 1] = (v[MAX_WORDS - 1] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum_val ^ y) + (self.key[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
139
+ z = v[MAX_WORDS - 1]
140
+ rounds -= 1
141
+
142
+ for i in range(MAX_WORDS):
143
+ struct.pack_into('<I', data, offset + i * 4, v[i])
144
+
145
+ def decrypt_fixed_word_array(self, data: bytearray, offset: int) -> None:
146
+ if len(data) - offset < MAX_BYTES:
147
+ return
148
+
149
+ v = [struct.unpack_from('<I', data, offset + i * 4)[0] for i in range(MAX_WORDS)]
150
+
151
+ rounds = 6 + 52 // MAX_WORDS
152
+ sum_val = (rounds * TEA_DELTA) & 0xFFFFFFFF
153
+ y = v[0]
154
+
155
+ while True:
156
+ e = (sum_val >> 2) & 3
157
+
158
+ for p in range(MAX_WORDS - 1, 0, -1):
159
+ z = v[p - 1]
160
+ v[p] = (v[p] - ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum_val ^ y) + (self.key[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
161
+ y = v[p]
162
+
163
+ z = v[MAX_WORDS - 1]
164
+ p = 0
165
+ v[0] = (v[0] - ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum_val ^ y) + (self.key[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
166
+ y = v[0]
167
+
168
+ sum_val = (sum_val - TEA_DELTA) & 0xFFFFFFFF
169
+ if sum_val == 0:
170
+ break
171
+
172
+ for i in range(MAX_WORDS):
173
+ struct.pack_into('<I', data, offset + i * 4, v[i])
@@ -0,0 +1,13 @@
1
+
2
+ from osz2.xxtea import XXTEA
3
+ from io import BytesIO
4
+
5
+ class XXTEAReader:
6
+ def __init__(self, reader: BytesIO, key: list[int]) -> None:
7
+ self.reader: BytesIO = reader
8
+ self.xxtea: XXTEA = XXTEA(key)
9
+
10
+ def read(self, n: int) -> bytes:
11
+ read = bytearray(self.reader.read(n))
12
+ self.xxtea.decrypt(read, 0, n)
13
+ return bytes(read)
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: osz2
3
+ Version: 1.0.0
4
+ Summary: A python library for reading osz2 files
5
+ Author: Lekuru
6
+ Author-email: contact@lekuru.xyz
7
+ Keywords: osu,osz2,python,bancho
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: author
11
+ Dynamic: author-email
12
+ Dynamic: description
13
+ Dynamic: description-content-type
14
+ Dynamic: keywords
15
+ Dynamic: license-file
16
+ Dynamic: summary
17
+
18
+ # osz2.py
19
+
20
+ [![Python Version](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/)
21
+ [![GitHub License](https://img.shields.io/github/license/Lekuruu/osz2.py)](https://github.com/Lekuruu/osz2.py/blob/main/LICENSE)
22
+ [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Lekuruu/osz2.py/.github%2Fworkflows%2Fbuild.yml)](https://github.com/Lekuruu/osz2.py/actions/workflows/build.yml)
23
+
24
+ osz2.py is a Python library for reading osz2 files. It's a direct port of the existing [Osz2Decryptor](https://github.com/xxCherry/Osz2Decryptor) project by [xxCherry](https://github.com/xxCherry) and [osz2-go](https://github.com/Lekuruu/osz2-go) by me. The Python port itself was done by [@ascenttree](https://github.com/ascenttree); all credit goes to them.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install git+https://github.com/Lekuruu/osz2.py@main
30
+ ```
31
+
32
+ Or install from source:
33
+
34
+ ```bash
35
+ git clone https://github.com/Lekuruu/osz2.py
36
+ cd osz2.py
37
+ pip install -e .
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ This repository provides a command-line interface for easy testing:
43
+
44
+ ```bash
45
+ python cli.py <input.osz2> <output_directory>
46
+ ```
47
+
48
+ But that's not all!
49
+ Here is an example of how to use osz2.py as a library:
50
+
51
+ ```python
52
+ from osz2 import Osz2Package, MetadataType
53
+
54
+ # Parse package from file
55
+ package = Osz2Package.from_file("beatmap.osz2")
56
+
57
+ # Access metadata
58
+ print("Title:", package.metadata.get(MetadataType.Title))
59
+ print("Artist:", package.metadata.get(MetadataType.Artist))
60
+ print("Creator:", package.metadata.get(MetadataType.Creator))
61
+ print("Difficulty:", package.metadata.get(MetadataType.Difficulty))
62
+
63
+ # Access files
64
+ for file in package.files:
65
+ print(f"File: {file.filename}, Size: {len(file.content)} bytes")
66
+
67
+ # Extract specific files
68
+ for file in package.files:
69
+ if not file.filename.endswith(".osu"):
70
+ continue
71
+
72
+ with open(file.filename, "wb") as f:
73
+ f.write(file.content)
74
+ ```
75
+
76
+ ### Metadata-only Mode
77
+
78
+ If you only need to read metadata without extracting files, you can use the `metadata_only` parameter:
79
+
80
+ ```python
81
+ # Only parse metadata
82
+ package = Osz2Package.from_file("beatmap.osz2", metadata_only=True)
83
+
84
+ # Access metadata
85
+ print("Title:", package.metadata.get(MetadataType.Title))
86
+ print("BeatmapSet ID:", package.metadata.get(MetadataType.BeatmapSetID))
87
+ ```
88
+
89
+ ### Alternative Constructors
90
+
91
+ ```python
92
+ # From file path
93
+ package = Osz2Package.from_file("beatmap.osz2")
94
+
95
+ # From bytes
96
+ with open("beatmap.osz2", "rb") as f:
97
+ data = f.read()
98
+ package = Osz2Package.from_bytes(data)
99
+
100
+ # From an io.BufferedReader-like object, e.g. a file stream
101
+ with open("beatmap.osz2", "rb") as f:
102
+ package = Osz2Package(f)
103
+ ```
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ setup.py
4
+ osz2/__init__.py
5
+ osz2/file.py
6
+ osz2/package.py
7
+ osz2/simple_cryptor.py
8
+ osz2/types.py
9
+ osz2/utils.py
10
+ osz2/xxtea.py
11
+ osz2/xxtea_reader.py
12
+ osz2.egg-info/PKG-INFO
13
+ osz2.egg-info/SOURCES.txt
14
+ osz2.egg-info/dependency_links.txt
15
+ osz2.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ osz2
osz2-1.0.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
osz2-1.0.0/setup.py ADDED
@@ -0,0 +1,21 @@
1
+
2
+ import setuptools
3
+ import osz2
4
+ import os
5
+
6
+ current_directory = os.path.dirname(os.path.abspath(__file__))
7
+
8
+ with open(os.path.join(current_directory, "README.md"), "r") as f:
9
+ long_description = f.read()
10
+
11
+ setuptools.setup(
12
+ name="osz2",
13
+ version=osz2.__version__,
14
+ author=osz2.__author__,
15
+ author_email=osz2.__email__,
16
+ description="A python library for reading osz2 files",
17
+ long_description=long_description,
18
+ long_description_content_type="text/markdown",
19
+ packages=setuptools.find_packages(),
20
+ keywords=["osu", "osz2", "python", "bancho"],
21
+ )