koro 1.0.7__py3-none-any.whl → 1.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.
- koro/file/__init__.py +4 -0
- koro/file/bin.py +178 -0
- koro/file/dir.py +28 -20
- koro/file/lvl.py +10 -2
- koro/file/zip.py +74 -27
- koro/item/__init__.py +3 -0
- koro/item/group.py +47 -0
- koro/item/level.py +108 -0
- koro/item/save.py +29 -0
- {koro-1.0.7.dist-info → koro-1.1.0.dist-info}/METADATA +1 -1
- koro-1.1.0.dist-info/RECORD +15 -0
- {koro-1.0.7.dist-info → koro-1.1.0.dist-info}/WHEEL +1 -1
- koro-1.0.7.dist-info/RECORD +0 -10
- {koro-1.0.7.dist-info → koro-1.1.0.dist-info}/LICENSE +0 -0
- {koro-1.0.7.dist-info → koro-1.1.0.dist-info}/top_level.txt +0 -0
koro/file/__init__.py
CHANGED
koro/file/bin.py
ADDED
@@ -0,0 +1,178 @@
|
|
1
|
+
from itertools import chain
|
2
|
+
from typing import Final
|
3
|
+
|
4
|
+
from ..item.level import Level, LevelNotFoundError
|
5
|
+
from . import Location
|
6
|
+
|
7
|
+
__all__ = ["BinLevel", "BinLevelNotFoundError"]
|
8
|
+
|
9
|
+
|
10
|
+
class BinLevelNotFoundError(FileNotFoundError, LevelNotFoundError):
|
11
|
+
pass
|
12
|
+
|
13
|
+
|
14
|
+
class BinLevel(Location, Level):
|
15
|
+
__slots__ = ()
|
16
|
+
|
17
|
+
@staticmethod
|
18
|
+
def compress(data: bytes, /) -> bytes:
|
19
|
+
"""Compress the given level data into the game's format."""
|
20
|
+
buffer: bytearray = bytearray(1024)
|
21
|
+
buffer_index: int = 958
|
22
|
+
chunk: bytearray
|
23
|
+
data_index: int = 0
|
24
|
+
output: Final[bytearray] = bytearray(
|
25
|
+
b"\x00\x00\x00\x01\x00\x00\x00\x08"
|
26
|
+
+ len(data).to_bytes(4, byteorder="big")
|
27
|
+
+ b"\x00\x00\x00\x01"
|
28
|
+
)
|
29
|
+
reference_indices: list[int]
|
30
|
+
test_buffer: bytearray
|
31
|
+
test_length: int
|
32
|
+
test_reference_indicies: list[int]
|
33
|
+
while data_index < len(data):
|
34
|
+
chunk = bytearray(b"\x00")
|
35
|
+
for _ in range(8):
|
36
|
+
if data_index >= len(data):
|
37
|
+
output.extend(chunk)
|
38
|
+
return output
|
39
|
+
if len(data) - data_index <= 2:
|
40
|
+
buffer[buffer_index] = data[data_index]
|
41
|
+
buffer_index = buffer_index + 1 & 1023
|
42
|
+
chunk[0] = chunk[0] >> 1 | 128
|
43
|
+
chunk.append(data[data_index])
|
44
|
+
data_index += 1
|
45
|
+
continue
|
46
|
+
reference_indices = []
|
47
|
+
for i in chain(range(buffer_index, 1024), range(buffer_index)):
|
48
|
+
if data[data_index] == buffer[i]:
|
49
|
+
reference_indices.append(i)
|
50
|
+
if not reference_indices:
|
51
|
+
buffer[buffer_index] = data[data_index]
|
52
|
+
buffer_index = buffer_index + 1 & 1023
|
53
|
+
chunk[0] = chunk[0] >> 1 | 128
|
54
|
+
chunk.append(data[data_index])
|
55
|
+
data_index += 1
|
56
|
+
continue
|
57
|
+
test_buffer = buffer.copy()
|
58
|
+
test_buffer[buffer_index] = data[data_index]
|
59
|
+
for i in reference_indices.copy():
|
60
|
+
if data[data_index + 1] != test_buffer[i - 1023]:
|
61
|
+
reference_indices.remove(i)
|
62
|
+
if not reference_indices:
|
63
|
+
buffer[buffer_index] = data[data_index]
|
64
|
+
buffer_index = buffer_index + 1 & 1023
|
65
|
+
chunk[0] = chunk[0] >> 1 | 128
|
66
|
+
chunk.append(data[data_index])
|
67
|
+
data_index += 1
|
68
|
+
continue
|
69
|
+
test_buffer[buffer_index - 1023] = data[data_index + 1]
|
70
|
+
for i in reference_indices.copy():
|
71
|
+
if data[data_index + 2] != test_buffer[i - 1022]:
|
72
|
+
reference_indices.remove(i)
|
73
|
+
if not reference_indices:
|
74
|
+
buffer[buffer_index] = data[data_index]
|
75
|
+
buffer_index = buffer_index + 1 & 1023
|
76
|
+
chunk[0] = chunk[0] >> 1 | 128
|
77
|
+
chunk.append(data[data_index])
|
78
|
+
data_index += 1
|
79
|
+
continue
|
80
|
+
test_length = 4
|
81
|
+
test_reference_indicies = reference_indices.copy()
|
82
|
+
while test_length <= min(66, len(data) - data_index):
|
83
|
+
test_buffer[buffer_index + test_length - 1025] = data[
|
84
|
+
data_index + test_length - 1
|
85
|
+
]
|
86
|
+
for i in test_reference_indicies.copy():
|
87
|
+
if (
|
88
|
+
data[data_index + test_length - 1]
|
89
|
+
!= test_buffer[i + test_length - 1025]
|
90
|
+
):
|
91
|
+
test_reference_indicies.remove(i)
|
92
|
+
if test_reference_indicies:
|
93
|
+
reference_indices = test_reference_indicies.copy()
|
94
|
+
else:
|
95
|
+
break
|
96
|
+
test_length += 1
|
97
|
+
chunk[0] >>= 1
|
98
|
+
test_length -= 1
|
99
|
+
if buffer_index + test_length >= 1024:
|
100
|
+
buffer[buffer_index:] = data[
|
101
|
+
data_index : data_index + 1024 - buffer_index
|
102
|
+
]
|
103
|
+
buffer[: buffer_index + test_length - 1024] = data[
|
104
|
+
data_index + 1024 - buffer_index : data_index + test_length
|
105
|
+
]
|
106
|
+
else:
|
107
|
+
buffer[buffer_index : buffer_index + test_length] = data[
|
108
|
+
data_index : data_index + test_length
|
109
|
+
]
|
110
|
+
buffer_index = buffer_index + test_length & 1023
|
111
|
+
chunk.extend(
|
112
|
+
(
|
113
|
+
reference_indices[0] & 255,
|
114
|
+
reference_indices[0] >> 2 & 192 | test_length - 3,
|
115
|
+
)
|
116
|
+
)
|
117
|
+
data_index += test_length
|
118
|
+
output.extend(chunk)
|
119
|
+
return output + b"\x00"
|
120
|
+
|
121
|
+
@staticmethod
|
122
|
+
def decompress(data: bytes, /) -> bytes:
|
123
|
+
"""Decompress the given data into raw level data."""
|
124
|
+
buffer: Final[bytearray] = bytearray(1024)
|
125
|
+
buffer_index: int = 958
|
126
|
+
handle: int | bytearray
|
127
|
+
flags: int
|
128
|
+
offset: int
|
129
|
+
raw: Final[bytearray] = bytearray(data[:15:-1])
|
130
|
+
ref: bytes
|
131
|
+
result: Final[bytearray] = bytearray()
|
132
|
+
result_size: Final[int] = int.from_bytes(data[8:12], byteorder="big")
|
133
|
+
while len(result) < result_size:
|
134
|
+
flags = raw.pop()
|
135
|
+
for _ in range(8):
|
136
|
+
if flags & 1:
|
137
|
+
handle = raw.pop()
|
138
|
+
buffer[buffer_index] = handle
|
139
|
+
buffer_index = buffer_index + 1 & 1023
|
140
|
+
result.append(handle)
|
141
|
+
else:
|
142
|
+
if len(raw) < 2:
|
143
|
+
return result
|
144
|
+
ref = bytes((raw.pop() for _ in range(2)))
|
145
|
+
offset = (ref[1] << 2 & 768) + ref[0]
|
146
|
+
handle = bytearray()
|
147
|
+
for i in range((ref[1] & 63) + 3):
|
148
|
+
handle.append(buffer[offset + i - 1024])
|
149
|
+
buffer[buffer_index] = handle[-1]
|
150
|
+
buffer_index = buffer_index + 1 & 1023
|
151
|
+
result.extend(handle)
|
152
|
+
flags >>= 1
|
153
|
+
return result.replace(b"<EDITUSER> 3 </EDITUSER>", b"<EDITUSER> 2 </EDITUSER>")
|
154
|
+
|
155
|
+
def delete(self) -> None:
|
156
|
+
try:
|
157
|
+
return super().delete()
|
158
|
+
except FileNotFoundError as e:
|
159
|
+
raise BinLevelNotFoundError(*e.args)
|
160
|
+
|
161
|
+
def __len__(self) -> int:
|
162
|
+
try:
|
163
|
+
with open(self.path, "rb") as f:
|
164
|
+
f.seek(8)
|
165
|
+
return int.from_bytes(f.read(4), byteorder="big")
|
166
|
+
except FileNotFoundError as e:
|
167
|
+
BinLevelNotFoundError(*e.args)
|
168
|
+
|
169
|
+
def read(self) -> bytes:
|
170
|
+
try:
|
171
|
+
with open(self.path, "rb") as f:
|
172
|
+
return self.decompress(f.read())
|
173
|
+
except FileNotFoundError as e:
|
174
|
+
raise BinLevelNotFoundError(*e.args)
|
175
|
+
|
176
|
+
def write(self, new_content: bytes, /) -> None:
|
177
|
+
with open(self.path, "wb") as f:
|
178
|
+
f.write(self.compress(new_content))
|
koro/file/dir.py
CHANGED
@@ -7,14 +7,19 @@ from typing import Final, Optional, SupportsIndex, TypeGuard, overload
|
|
7
7
|
from ..item.group import Group
|
8
8
|
from ..item.level import Level, LevelNotFoundError
|
9
9
|
from ..item.save import Page, Save
|
10
|
-
|
11
10
|
from . import Location
|
12
11
|
|
12
|
+
__all__ = ["DirGroup", "DirLevel", "DirLevelNotFoundError", "DirSave"]
|
13
|
+
|
13
14
|
_BLOCK_SIZE: Final[int] = 2048
|
14
15
|
_EMPTY_BLOCK: Final[bytes] = b"\x00" * _BLOCK_SIZE
|
15
16
|
_LEVEL_ALLOCATION_SIZE: Final[int] = 156864
|
16
17
|
|
17
18
|
|
19
|
+
class DirLevelNotFoundError(LevelNotFoundError):
|
20
|
+
pass
|
21
|
+
|
22
|
+
|
18
23
|
class DirLevel(Level):
|
19
24
|
__match_args__ = ("path", "page", "id")
|
20
25
|
__slots__ = ("_offset", "_path")
|
@@ -45,7 +50,7 @@ class DirLevel(Level):
|
|
45
50
|
f.write(_EMPTY_BLOCK)
|
46
51
|
more = f.read(1) != b"\x00"
|
47
52
|
else:
|
48
|
-
raise
|
53
|
+
raise DirLevelNotFoundError
|
49
54
|
|
50
55
|
def __eq__(self, other: object, /) -> bool:
|
51
56
|
return (
|
@@ -62,23 +67,26 @@ class DirLevel(Level):
|
|
62
67
|
return int(self._path[-5]) % 5 << 2 | self._offset // _LEVEL_ALLOCATION_SIZE
|
63
68
|
|
64
69
|
def __len__(self) -> int:
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
70
|
+
if self:
|
71
|
+
with open(self._path, "rb") as f:
|
72
|
+
f.seek(self._offset)
|
73
|
+
block: Final[bytearray] = bytearray(f.read(_BLOCK_SIZE))
|
74
|
+
block_offset: int = 0
|
75
|
+
while block[-1]:
|
76
|
+
f.readinto(block)
|
77
|
+
block_offset += _BLOCK_SIZE
|
78
|
+
hi: int = _BLOCK_SIZE - 1
|
79
|
+
lo: int = 0
|
80
|
+
test: int
|
81
|
+
while hi != lo:
|
82
|
+
test = (hi - lo >> 1) + lo
|
83
|
+
if block[test]:
|
84
|
+
lo = test + 1
|
85
|
+
else:
|
86
|
+
hi = test
|
87
|
+
return block_offset + hi
|
88
|
+
else:
|
89
|
+
raise DirLevelNotFoundError
|
82
90
|
|
83
91
|
@property
|
84
92
|
def page(self) -> Page:
|
@@ -108,7 +116,7 @@ class DirLevel(Level):
|
|
108
116
|
hi = test
|
109
117
|
return bytes(result + block[:hi])
|
110
118
|
else:
|
111
|
-
raise
|
119
|
+
raise DirLevelNotFoundError
|
112
120
|
|
113
121
|
def __repr__(self) -> str:
|
114
122
|
return f"{type(self).__name__}({self.path!r}, {self.page!r}, {self.id!r})"
|
koro/file/lvl.py
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
from os.path import getsize
|
2
|
+
from warnings import warn
|
2
3
|
|
3
4
|
from ..item.level import Level, LevelNotFoundError
|
4
|
-
|
5
5
|
from . import Location
|
6
6
|
|
7
|
-
__all__ = ["LvlLevel"]
|
7
|
+
__all__ = ["LvlLevel", "LvlLevelNotFoundError"]
|
8
8
|
|
9
9
|
|
10
10
|
class LvlLevelNotFoundError(FileNotFoundError, LevelNotFoundError):
|
@@ -14,6 +14,14 @@ class LvlLevelNotFoundError(FileNotFoundError, LevelNotFoundError):
|
|
14
14
|
class LvlLevel(Location, Level):
|
15
15
|
__slots__ = ()
|
16
16
|
|
17
|
+
def __init__(self, path: str, /) -> None:
|
18
|
+
super().__init__(path)
|
19
|
+
warn(
|
20
|
+
FutureWarning(
|
21
|
+
"The LVL format for storing level data is deprecated. Level data should be converted to the BIN format using BinLevel for better compression and compatibility."
|
22
|
+
)
|
23
|
+
)
|
24
|
+
|
17
25
|
def delete(self) -> None:
|
18
26
|
try:
|
19
27
|
return super().delete()
|
koro/file/zip.py
CHANGED
@@ -4,17 +4,22 @@ from operator import index
|
|
4
4
|
from os.path import abspath
|
5
5
|
from re import fullmatch
|
6
6
|
from typing import Final, Optional, SupportsIndex, TypeGuard, overload
|
7
|
+
from warnings import warn
|
7
8
|
from zipfile import ZipFile, ZipInfo
|
8
9
|
|
9
10
|
from ..item.group import Group
|
10
11
|
from ..item.level import Level, LevelNotFoundError
|
11
|
-
|
12
12
|
from . import Location
|
13
|
+
from .bin import BinLevel
|
13
14
|
|
14
|
-
__all__ = ["ZipGroup", "ZipLevel"]
|
15
|
+
__all__ = ["ZipGroup", "ZipLevel", "ZipLevelNotFoundError"]
|
15
16
|
|
16
17
|
|
17
18
|
def _id_to_fn(id: SupportsIndex, /) -> str:
|
19
|
+
return f"{str(index(id) + 1).zfill(2)}.bin"
|
20
|
+
|
21
|
+
|
22
|
+
def _id_to_old_fn(id: SupportsIndex, /) -> str:
|
18
23
|
return f"{str(index(id) + 1).zfill(2)}.lvl"
|
19
24
|
|
20
25
|
|
@@ -43,11 +48,12 @@ class ZipLevel(Level):
|
|
43
48
|
|
44
49
|
def delete(self) -> None:
|
45
50
|
with ZipFile(self.path) as a:
|
46
|
-
if self.fn not in a.namelist():
|
51
|
+
if self.fn not in a.namelist() and self.old_fn not in a.namelist():
|
47
52
|
raise ZipLevelNotFoundError
|
48
53
|
contents: Final[dict[ZipInfo, bytes]] = {}
|
49
54
|
for info in filterfalse(
|
50
|
-
lambda info: info.filename == self.fn
|
55
|
+
lambda info: info.filename == self.fn or info.filename == self.old_fn,
|
56
|
+
a.infolist(),
|
51
57
|
):
|
52
58
|
contents[info] = a.read(info)
|
53
59
|
with ZipFile(self.path, "w") as a:
|
@@ -73,22 +79,38 @@ class ZipLevel(Level):
|
|
73
79
|
return self._id
|
74
80
|
|
75
81
|
def __len__(self) -> int:
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
82
|
+
with ZipFile(self.path) as a:
|
83
|
+
try:
|
84
|
+
a.getinfo(self.fn)
|
85
|
+
except KeyError:
|
86
|
+
try:
|
87
|
+
return a.getinfo(self.old_fn).file_size
|
88
|
+
except KeyError:
|
89
|
+
raise ZipLevelNotFoundError
|
90
|
+
else:
|
91
|
+
return int.from_bytes(a.read(self.fn)[8:12], byteorder="big")
|
92
|
+
|
93
|
+
@property
|
94
|
+
def old_fn(self) -> str:
|
95
|
+
return _id_to_old_fn(self.id)
|
81
96
|
|
82
97
|
@property
|
83
98
|
def path(self) -> str:
|
84
99
|
return self._path
|
85
100
|
|
86
101
|
def read(self) -> bytes:
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
102
|
+
with ZipFile(self.path) as a:
|
103
|
+
try:
|
104
|
+
a.getinfo(self.fn)
|
105
|
+
except KeyError:
|
106
|
+
try:
|
107
|
+
a.getinfo(self.old_fn)
|
108
|
+
except KeyError:
|
109
|
+
raise ZipLevelNotFoundError
|
110
|
+
else:
|
111
|
+
return a.read(self.old_fn)
|
112
|
+
else:
|
113
|
+
return BinLevel.decompress(a.read(self.fn))
|
92
114
|
|
93
115
|
def __repr__(self) -> str:
|
94
116
|
return f"{type(self).__name__}({self.path!r}, {self.id!r})"
|
@@ -97,18 +119,31 @@ class ZipLevel(Level):
|
|
97
119
|
contents: Final[dict[ZipInfo, bytes]] = {}
|
98
120
|
with ZipFile(self.path, "a") as a:
|
99
121
|
for info in filterfalse(
|
100
|
-
lambda info: info.filename == self.fn
|
122
|
+
lambda info: info.filename == self.fn or info.filename == self.old_fn,
|
123
|
+
a.infolist(),
|
101
124
|
):
|
102
125
|
contents[info] = a.read(info)
|
103
126
|
with ZipFile(self.path, "w") as a:
|
104
127
|
for x in contents.items():
|
105
128
|
a.writestr(*x)
|
106
|
-
a.writestr(self.fn, new_content)
|
129
|
+
a.writestr(self.fn, BinLevel.compress(new_content))
|
107
130
|
|
108
131
|
|
109
|
-
class ZipGroup(Group[ZipLevel]
|
132
|
+
class ZipGroup(Location, Group[ZipLevel]):
|
110
133
|
__slots__ = ()
|
111
134
|
|
135
|
+
def __init__(self, path: str) -> None:
|
136
|
+
super().__init__(path)
|
137
|
+
with ZipFile(self.path) as a:
|
138
|
+
for id in range(20):
|
139
|
+
if _id_to_old_fn(id) in a.namelist():
|
140
|
+
warn(
|
141
|
+
FutureWarning(
|
142
|
+
"This ZipGroup contains levels using the deprecated LVL format. Update this file by writing to it or by using the update function."
|
143
|
+
)
|
144
|
+
)
|
145
|
+
break
|
146
|
+
|
112
147
|
def __contains__(self, value: object, /) -> TypeGuard[ZipLevel]:
|
113
148
|
return isinstance(value, ZipLevel) and value.path == self.path
|
114
149
|
|
@@ -139,28 +174,40 @@ class ZipGroup(Group[ZipLevel], Location):
|
|
139
174
|
else:
|
140
175
|
raise ValueError
|
141
176
|
|
142
|
-
def init(self) -> None:
|
143
|
-
with ZipFile(self.path, "x") as a:
|
144
|
-
pass
|
145
|
-
|
146
177
|
def fill_mask(self) -> Sequence[bool]:
|
147
178
|
with ZipFile(self.path) as a:
|
148
|
-
return [
|
179
|
+
return [
|
180
|
+
_id_to_fn(i) in a.namelist() or _id_to_old_fn in a.namelist()
|
181
|
+
for i in range(20)
|
182
|
+
]
|
149
183
|
|
150
184
|
def read(self) -> Iterable[Optional[bytes]]:
|
151
185
|
with ZipFile(self.path) as a:
|
152
|
-
name_list: Final[list[str]] = a.namelist()
|
153
186
|
return [
|
154
|
-
a.read(
|
155
|
-
|
187
|
+
BinLevel.decompress(a.read(_id_to_fn(id)))
|
188
|
+
if _id_to_fn(id) in a.namelist()
|
189
|
+
else a.read(_id_to_old_fn(id))
|
190
|
+
if _id_to_old_fn(id) in a.namelist()
|
191
|
+
else None
|
192
|
+
for id in range(20)
|
156
193
|
]
|
157
194
|
|
195
|
+
def update(self) -> None:
|
196
|
+
self.write(self.read())
|
197
|
+
warn(
|
198
|
+
FutureWarning(
|
199
|
+
"This function will be removed alongside support for the LVL format, and exists soley as a convienient way of converting existing ZIP files to the new BIN format."
|
200
|
+
)
|
201
|
+
)
|
202
|
+
|
158
203
|
def write(self, new_content: Iterable[Optional[bytes]], /) -> None:
|
159
204
|
contents: Final[dict[ZipInfo, bytes]] = {}
|
160
205
|
if self:
|
161
206
|
with ZipFile(self.path) as a:
|
162
207
|
for info in filterfalse(
|
163
|
-
lambda info: fullmatch(
|
208
|
+
lambda info: fullmatch(
|
209
|
+
r"(0[1-9]|1\d|20)\.(bin|lvl)", info.filename
|
210
|
+
),
|
164
211
|
a.infolist(),
|
165
212
|
):
|
166
213
|
contents[info] = a.read(info)
|
@@ -170,4 +217,4 @@ class ZipGroup(Group[ZipLevel], Location):
|
|
170
217
|
for id, content in filterfalse(
|
171
218
|
lambda x: x[1] is None, enumerate(new_content)
|
172
219
|
):
|
173
|
-
a.writestr(_id_to_fn(id), content)
|
220
|
+
a.writestr(_id_to_fn(id), BinLevel.compress(content))
|
koro/item/__init__.py
ADDED
koro/item/group.py
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
from abc import ABC
|
2
|
+
from collections.abc import Iterable, Sequence
|
3
|
+
from typing import Final, Generic, Optional, TypeVar
|
4
|
+
|
5
|
+
from .level import Level, LevelNotFoundError
|
6
|
+
|
7
|
+
__all__ = ["Group"]
|
8
|
+
|
9
|
+
_L = TypeVar("_L", bound=Level)
|
10
|
+
|
11
|
+
|
12
|
+
class Group(ABC, Generic[_L], Sequence[_L]):
|
13
|
+
"""A group of 20 levels.
|
14
|
+
Note that levels are 0-indexed within this interface, but 1-indexed in-game.
|
15
|
+
"""
|
16
|
+
|
17
|
+
__slots__ = ()
|
18
|
+
|
19
|
+
def fill_mask(self) -> Sequence[bool]:
|
20
|
+
"""Return which level IDs within this Group exist."""
|
21
|
+
return [bool(l) for l in self]
|
22
|
+
|
23
|
+
def __len__(self) -> int:
|
24
|
+
return 20
|
25
|
+
|
26
|
+
def read(self) -> Iterable[Optional[bytes]]:
|
27
|
+
"""Read all of the levels within this Group. Empty slots yield None."""
|
28
|
+
content: Optional[bytes]
|
29
|
+
result: Final[list[Optional[bytes]]] = []
|
30
|
+
for level in self:
|
31
|
+
try:
|
32
|
+
content = level.read()
|
33
|
+
except LevelNotFoundError:
|
34
|
+
content = None
|
35
|
+
result.append(content)
|
36
|
+
return result
|
37
|
+
|
38
|
+
def write(self, new_content: Iterable[Optional[bytes]], /) -> None:
|
39
|
+
"""Replace the contents of this Group with the specified new content. None values will empty the slot they correspond to."""
|
40
|
+
for src, dest in zip(new_content, self, strict=True):
|
41
|
+
if src is None:
|
42
|
+
try:
|
43
|
+
dest.delete()
|
44
|
+
except LevelNotFoundError:
|
45
|
+
pass
|
46
|
+
else:
|
47
|
+
dest.write(src)
|
koro/item/level.py
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
from abc import ABC, abstractmethod
|
2
|
+
from collections.abc import Sized
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from enum import Enum, unique
|
5
|
+
from typing import Final
|
6
|
+
from warnings import warn
|
7
|
+
|
8
|
+
__all__ = ["Level", "LevelNotFoundError", "LevelStatistics", "Theme"]
|
9
|
+
|
10
|
+
|
11
|
+
@unique
|
12
|
+
class Theme(Enum):
|
13
|
+
THE_EMPTY_LOT = 0
|
14
|
+
NEIGHBORS_HOUSE = 1
|
15
|
+
SIZZLIN_DESERT = 2
|
16
|
+
CHILL_MOUNTAIN = 3
|
17
|
+
OCEAN_TREASURE = 4
|
18
|
+
SPACE_STATION = 5
|
19
|
+
STUMP_TEMPLE = 6
|
20
|
+
CANDY_ISLAND = 7
|
21
|
+
HAUNTED_HOUSE = 8
|
22
|
+
CITY = 9
|
23
|
+
TUTORIAL = 11
|
24
|
+
HAUNTED_HOUSE_DARKNESS = 12
|
25
|
+
NIGHT_CITY = 13
|
26
|
+
|
27
|
+
def __str__(self) -> str:
|
28
|
+
return (
|
29
|
+
"The Empty Lot",
|
30
|
+
"Neighbor's House",
|
31
|
+
"Sizzlin' Desert",
|
32
|
+
"Chill Mountain",
|
33
|
+
"Ocean Treasure",
|
34
|
+
"Space Station",
|
35
|
+
"Stump Temple",
|
36
|
+
"Candy Island",
|
37
|
+
"Haunted House",
|
38
|
+
"City",
|
39
|
+
None,
|
40
|
+
"Tutorial",
|
41
|
+
"Haunted House Darkness",
|
42
|
+
"Night City",
|
43
|
+
)[self.value]
|
44
|
+
|
45
|
+
|
46
|
+
@dataclass(frozen=True, match_args=False, kw_only=True, slots=True)
|
47
|
+
class LevelStatistics:
|
48
|
+
crystals: int
|
49
|
+
filesize: int
|
50
|
+
theme: Theme
|
51
|
+
|
52
|
+
|
53
|
+
class LevelNotFoundError(LookupError):
|
54
|
+
pass
|
55
|
+
|
56
|
+
|
57
|
+
class Level(ABC, Sized):
|
58
|
+
__slots__ = ()
|
59
|
+
|
60
|
+
def about(self) -> LevelStatistics:
|
61
|
+
content: Final[bytes] = self.read()
|
62
|
+
return LevelStatistics(
|
63
|
+
crystals=content.count(b"<anmtype> 49 </anmtype>"),
|
64
|
+
filesize=len(content),
|
65
|
+
theme=Theme(int(content[87:89])),
|
66
|
+
)
|
67
|
+
|
68
|
+
@abstractmethod
|
69
|
+
def __bool__(self) -> bool:
|
70
|
+
"""Return whether this level exists."""
|
71
|
+
|
72
|
+
@abstractmethod
|
73
|
+
def delete(self) -> None:
|
74
|
+
"""Delete this level if it exists, otherwise raise LevelNotFoundError."""
|
75
|
+
pass
|
76
|
+
|
77
|
+
def encode(self) -> bytes:
|
78
|
+
"""Return a bytes object that when written to a file can overwrite an official level."""
|
79
|
+
warn(
|
80
|
+
FutureWarning(
|
81
|
+
"The use of this function is deprecated as the new BIN format is compatible with the official levels and can be substituted into the game directly."
|
82
|
+
)
|
83
|
+
)
|
84
|
+
data: Final[bytearray] = bytearray(self.read())
|
85
|
+
header: Final[bytes] = (
|
86
|
+
b"\x00\x00\x00\x01\x00\x00\x00\x08"
|
87
|
+
+ len(data).to_bytes(4, byteorder="big")
|
88
|
+
+ b"\x00\x00\x00\x01"
|
89
|
+
)
|
90
|
+
i: int = 0
|
91
|
+
while i < len(data):
|
92
|
+
data.insert(i, 255)
|
93
|
+
i += 9
|
94
|
+
return header + data + b"\x00"
|
95
|
+
|
96
|
+
def __len__(self) -> int:
|
97
|
+
"""The file size of this level."""
|
98
|
+
return len(self.read())
|
99
|
+
|
100
|
+
@abstractmethod
|
101
|
+
def read(self) -> bytes:
|
102
|
+
"""Return the contents of this level if it exists, otherwise raise LevelNotFoundError."""
|
103
|
+
pass
|
104
|
+
|
105
|
+
@abstractmethod
|
106
|
+
def write(self, new_content: bytes, /) -> None:
|
107
|
+
"""Replace the contents of this level, or create it if it doesn't exist."""
|
108
|
+
pass
|
koro/item/save.py
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
from abc import ABC
|
2
|
+
from collections.abc import Iterator, Mapping
|
3
|
+
from enum import Enum, unique
|
4
|
+
from typing import Generic, TypeGuard, TypeVar
|
5
|
+
|
6
|
+
from .group import Group
|
7
|
+
|
8
|
+
__all__ = ["Save", "Page"]
|
9
|
+
|
10
|
+
_G = TypeVar("_G", bound=Group)
|
11
|
+
|
12
|
+
|
13
|
+
@unique
|
14
|
+
class Page(Enum):
|
15
|
+
ORIGINAL = 0
|
16
|
+
FRIEND = 1
|
17
|
+
|
18
|
+
|
19
|
+
class Save(ABC, Generic[_G], Mapping[Page, _G]):
|
20
|
+
__slots__ = ()
|
21
|
+
|
22
|
+
def __contains__(self, key: object, /) -> TypeGuard[Page]:
|
23
|
+
return isinstance(key, Page)
|
24
|
+
|
25
|
+
def __iter__(self) -> Iterator[Page]:
|
26
|
+
return iter(Page)
|
27
|
+
|
28
|
+
def __len__(self) -> int:
|
29
|
+
return 2
|
@@ -0,0 +1,15 @@
|
|
1
|
+
koro/__init__.py,sha256=clmcgzd33gY_tOC3U5U-010NYVZZrC-9Og6AYAovIjo,176
|
2
|
+
koro/file/__init__.py,sha256=rrsPxeKSG6WXprbO-xDZR1E9GdQ2xuzPvoZ08C3QSQc,1038
|
3
|
+
koro/file/bin.py,sha256=2rV6BQN-6HH-ilV8WpInI5xWjds9Mo3_4kzQLEDPmqU,7482
|
4
|
+
koro/file/dir.py,sha256=pnMQJAQRlzcm_MC14goDeKlQ3llKhywM1FdADHM8xds,6632
|
5
|
+
koro/file/lvl.py,sha256=lrVpQyZFj1JlVPYx-OkcCne8VdjHCWQIuOX0pls1Dto,1130
|
6
|
+
koro/file/zip.py,sha256=hFV3rnro0pTeg7mpoKEvQnO-Oo93_DgnxJQCJupkVI8,7332
|
7
|
+
koro/item/__init__.py,sha256=3RXQS-HSN6EDGLk6sY4xip-oy8JiV31XshsCWVCgie8,65
|
8
|
+
koro/item/group.py,sha256=lwmx_1FMD4LdOxxuqPDN2Dl_8MZD9OJcy259SS8Apzk,1538
|
9
|
+
koro/item/level.py,sha256=KXiOK3RGC72WK4lduf3RO9Cs1zXaJRk0EfJCDndA4bs,3068
|
10
|
+
koro/item/save.py,sha256=LUV79vW3gY__jVVVG1kcYW-wzsGCRlYq126nulbT37Y,607
|
11
|
+
koro-1.1.0.dist-info/LICENSE,sha256=Q2ptU2E48gOsMzhYz_kqVovmJ5d1KzrblLyqD2_-fvY,1235
|
12
|
+
koro-1.1.0.dist-info/METADATA,sha256=5Tt71Gav-Itj_GMxWkdYMaMCxssxWLpHmqiRxdfojY0,628
|
13
|
+
koro-1.1.0.dist-info/WHEEL,sha256=5sUXSg9e4bi7lTLOHcm6QEYwO5TIF1TNbTSVFVjcJcc,92
|
14
|
+
koro-1.1.0.dist-info/top_level.txt,sha256=Msq6ssMwv56hnBQqwaTdJJkxM7x7BZ-3JfQbkepQAEY,5
|
15
|
+
koro-1.1.0.dist-info/RECORD,,
|
koro-1.0.7.dist-info/RECORD
DELETED
@@ -1,10 +0,0 @@
|
|
1
|
-
koro/__init__.py,sha256=clmcgzd33gY_tOC3U5U-010NYVZZrC-9Og6AYAovIjo,176
|
2
|
-
koro/file/__init__.py,sha256=_UIx5h1YBoheqQeorBV5XrbZXusAOipbFsesqUeLJwk,948
|
3
|
-
koro/file/dir.py,sha256=sxNM_COlADES3oVnlutApXPd2SvWuANRnxMC6RdQ448,6348
|
4
|
-
koro/file/lvl.py,sha256=cxOJGuzzs3yVTAdi-clhXPn1A7-TU3p-FJW3JLZBTyY,752
|
5
|
-
koro/file/zip.py,sha256=hK8XemvmM3dhyNPevCt9lXm0p24HYbzyHp40yA5O5cg,5472
|
6
|
-
koro-1.0.7.dist-info/LICENSE,sha256=Q2ptU2E48gOsMzhYz_kqVovmJ5d1KzrblLyqD2_-fvY,1235
|
7
|
-
koro-1.0.7.dist-info/METADATA,sha256=crpDh19AkPI1uTIzGUGA1Qr84CvGVhNVUc5DIJchWYI,628
|
8
|
-
koro-1.0.7.dist-info/WHEEL,sha256=2wepM1nk4DS4eFpYrW1TTqPcoGNfHhhO_i5m4cOimbo,92
|
9
|
-
koro-1.0.7.dist-info/top_level.txt,sha256=Msq6ssMwv56hnBQqwaTdJJkxM7x7BZ-3JfQbkepQAEY,5
|
10
|
-
koro-1.0.7.dist-info/RECORD,,
|
File without changes
|
File without changes
|