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 +8 -0
- osz2-1.0.0/PKG-INFO +103 -0
- osz2-1.0.0/README.md +86 -0
- osz2-1.0.0/osz2/__init__.py +13 -0
- osz2-1.0.0/osz2/file.py +13 -0
- osz2-1.0.0/osz2/package.py +157 -0
- osz2-1.0.0/osz2/simple_cryptor.py +51 -0
- osz2-1.0.0/osz2/types.py +27 -0
- osz2-1.0.0/osz2/utils.py +57 -0
- osz2-1.0.0/osz2/xxtea.py +173 -0
- osz2-1.0.0/osz2/xxtea_reader.py +13 -0
- osz2-1.0.0/osz2.egg-info/PKG-INFO +103 -0
- osz2-1.0.0/osz2.egg-info/SOURCES.txt +15 -0
- osz2-1.0.0/osz2.egg-info/dependency_links.txt +1 -0
- osz2-1.0.0/osz2.egg-info/top_level.txt +1 -0
- osz2-1.0.0/setup.cfg +4 -0
- osz2-1.0.0/setup.py +21 -0
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
|
+
[](https://www.python.org/)
|
|
21
|
+
[](https://github.com/Lekuruu/osz2.py/blob/main/LICENSE)
|
|
22
|
+
[](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
|
+
[](https://www.python.org/)
|
|
4
|
+
[](https://github.com/Lekuruu/osz2.py/blob/main/LICENSE)
|
|
5
|
+
[](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
|
osz2-1.0.0/osz2/file.py
ADDED
|
@@ -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
|
osz2-1.0.0/osz2/types.py
ADDED
|
@@ -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
|
osz2-1.0.0/osz2/utils.py
ADDED
|
@@ -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
|
osz2-1.0.0/osz2/xxtea.py
ADDED
|
@@ -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
|
+
[](https://www.python.org/)
|
|
21
|
+
[](https://github.com/Lekuruu/osz2.py/blob/main/LICENSE)
|
|
22
|
+
[](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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
osz2
|
osz2-1.0.0/setup.cfg
ADDED
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
|
+
)
|