koro 1.1.6__py3-none-any.whl → 2.0.0.post1__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.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.1
2
+ Name: koro
3
+ Version: 2.0.0.post1
4
+ Summary: Tools for manipulating levels made in Marble Saga: Kororinpa
5
+ Home-page: https://github.com/DigitalDetective47/koro
6
+ Author: DigitalDetective47
7
+ Author-email: ninji2701@gmail.com
8
+ Project-URL: Bug Tracker, https://github.com/DigitalDetective47/koro/issues
9
+ Project-URL: Documentation, https://github.com/DigitalDetective47/koro/wiki
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: License :: OSI Approved :: The Unlicense (Unlicense)
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: File Formats
18
+ Classifier: Topic :: Games/Entertainment
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ License-File: LICENSE
22
+
23
+ koro (stylized in all lowercase) is a Python package that can read and modify stages from *Marble Saga: Kororinpa*.
24
+
25
+ # *Marble Saga: Kororinpa*
26
+
27
+ *Marble Saga: Kororinpa* is a video game released for the Nintendo Wii in March of 2009 in North America; the game was released in PAL regions under the title *Marbles! Balance Challenge* in May of the same year. Like its predecessor *Kororinpa*/*Kororinpa: Marble Mania*, it is a ball‐rolling game which is very similar to the *Super Monkey Ball* series in which the player character is controlled by tilting the game world. This game makes use of the Wii Remote's motion control capabilities by using the orientation of the controller to manipulate the world.
28
+
29
+ # Problems
30
+
31
+ *Marble Saga: Kororinpa* included a stage editor in which parts could be created by combining junk parts collected within the main game. The game provides the player with 20 slots in which to save stages that they have created. During the time period following the game's release, players could share their created stages using the WiiConnect24 service. After WiiConnect24 shut down on the 28<sup>th</sup> of June 2013, sharing stages with other players became impossible through official means. Sharing save files is not possible through the Wii system menu as the game had online leaderboards for ten stages specifically designed for online competition. As a result, saves of this game are marked as protected and cannot be copied from the save manager present in the Wii system menu.
32
+
33
+ # This package
34
+
35
+ This package allows you to extract the saved stages from your save file and store them in their own files, and to import stages downloaded online into your existing save file. (This package does not provide tools to get saves to or from the Wii console, there is plenty of homebrew software already in existence for this purpose.) This package also contains reverse&hyphen;engineered replicas of the game's compression format used internally, allowing for stage substitution in mods.
36
+
37
+ # Usage
38
+
39
+ To install this package, simply run
40
+ ```
41
+ pip install koro
42
+ ```
43
+ in a command prompt. For detailed documentation of the contents of the package, please view the wiki. For basic users, simple command&hyphen;line tools are available in the `scripts` folder of this repository. **Use of these tools requires installing the package from PyPI.**
44
+
45
+ ## Playing downloaded stages
46
+
47
+ `unpacker.py` is a script designed to inject stages downloaded online into your save file. Simply run the script with the stages, the data directory of your save file, and if injecting a single stage, the slot to inject it into. The stages should then appear in the **Friend** tab. To find the location of your save in Dophin, right&hyphen;click the game and select **Open Wii Save Folder**.
48
+
49
+ ## Uploading your stages
50
+
51
+ `packer.py` is a script that allows you to easily extract and upload stages that you've created. Run the script with your save directory, destination (ZIP archive), and optionally which stages to export. This script only exports stages stored in the **Original** tab of the editor. To specify which stages to export, simply enter the stage numbers in the order that they should appear when downloaded. If a custom ordering is not specified, the default is to extract all 20 stages in the order that they appear in&hyphen;game. To share single levels, extract files from the resulting archive.
@@ -0,0 +1,14 @@
1
+ koro/__init__.py,sha256=xS3s862eN55uy83K2Dwchurhh9Euh1te2ZPia2lRVLI,200
2
+ koro/slot/__init__.py,sha256=ldVeVLwXIgPZJsCp4-ix78ewRgsAKVwa8BnpcdnpfIc,419
3
+ koro/slot/bin.py,sha256=XuvEc1rn85j6EYirjvDZhQ0y81lO8dRJPk4JVnyzln4,6686
4
+ koro/slot/file.py,sha256=g5nlOddY2MiGdDVnxY7lpbxE74ZHWhG5VPJJddSY7PY,1726
5
+ koro/slot/save.py,sha256=vZLftvjDyiHgZrMgthE8H-EbWIxtdT_MN3Jb-WIc4Eo,4582
6
+ koro/slot/xml.py,sha256=C2g1OlGDwhlVocEeUg7tYxQf1mfRVNopH0yH1UG1Cvw,42992
7
+ koro/stage/__init__.py,sha256=kQs0k1NwhrRBhNvZOj8edYpKH_v8AkrAkuNm1ioQfI8,2547
8
+ koro/stage/model.py,sha256=h2d9w68iXjQHcpVpEK61QjbYq9p9diRv-aCTiUgpW9c,8717
9
+ koro/stage/part.py,sha256=o7ox1Y2RsfXyWG0Z4MQ49f7rXKP7y_Xl4qYqBBOjtZM,48640
10
+ koro-2.0.0.post1.dist-info/LICENSE,sha256=Q2ptU2E48gOsMzhYz_kqVovmJ5d1KzrblLyqD2_-fvY,1235
11
+ koro-2.0.0.post1.dist-info/METADATA,sha256=7gV_yfq4OmhP8gXaDlU_RQpmCZu7Rff9pjtoKr-2W_4,4371
12
+ koro-2.0.0.post1.dist-info/WHEEL,sha256=Z4pYXqR_rTB7OWNDYFOm1qRk0RX6GFP2o8LgvP453Hk,91
13
+ koro-2.0.0.post1.dist-info/top_level.txt,sha256=Msq6ssMwv56hnBQqwaTdJJkxM7x7BZ-3JfQbkepQAEY,5
14
+ koro-2.0.0.post1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.41.2)
2
+ Generator: setuptools (70.3.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
koro/file/__init__.py DELETED
@@ -1,40 +0,0 @@
1
- from os import remove
2
- from os.path import abspath, exists
3
- from shutil import rmtree
4
-
5
- __all__ = ["Location"]
6
-
7
-
8
- class Location:
9
- __match_args__ = ("path",)
10
- __slots__ = ("_path",)
11
-
12
- _path: str
13
-
14
- def __init__(self, path: str, /) -> None:
15
- self._path = abspath(path)
16
-
17
- def __bool__(self) -> bool:
18
- """Whether this file location is accessible and exists."""
19
- return exists(self.path)
20
-
21
- def delete(self) -> None:
22
- try:
23
- remove(self.path)
24
- except IsADirectoryError:
25
- rmtree(self.path)
26
-
27
- def __eq__(self, other: object, /) -> bool:
28
- return (
29
- self.path == other.path if isinstance(other, Location) else NotImplemented
30
- )
31
-
32
- def __hash__(self) -> int:
33
- return hash(self.path)
34
-
35
- @property
36
- def path(self) -> str:
37
- return self._path
38
-
39
- def __repr__(self) -> str:
40
- return f"{type(self).__name__}({self.path!r})"
koro/file/dir.py DELETED
@@ -1,212 +0,0 @@
1
- from collections.abc import Sequence
2
- from operator import index
3
- from os import SEEK_CUR
4
- from os.path import abspath, dirname, isfile, join
5
- from typing import Final, Optional, SupportsIndex, TypeGuard, overload
6
-
7
- from ..item.group import Group
8
- from ..item.level import Level, LevelNotFoundError
9
- from ..item.save import Page, Save
10
- from . import Location
11
-
12
- __all__ = ["DirGroup", "DirLevel", "DirLevelNotFoundError", "DirSave"]
13
-
14
- _BLOCK_SIZE: Final[int] = 2048
15
- _EMPTY_BLOCK: Final[bytes] = b"\x00" * _BLOCK_SIZE
16
- _LEVEL_ALLOCATION_SIZE: Final[int] = 156864
17
-
18
-
19
- class DirLevelNotFoundError(LevelNotFoundError):
20
- pass
21
-
22
-
23
- class DirLevel(Level):
24
- __match_args__ = ("path", "page", "id")
25
- __slots__ = ("_offset", "_path")
26
-
27
- _offset: int
28
- _path: str
29
-
30
- def __init__(self, path: str, /, page: Page, id: SupportsIndex) -> None:
31
- i = index(id)
32
- if 0 <= i < 20:
33
- self._offset = 8 + _LEVEL_ALLOCATION_SIZE * (i & 3)
34
- self._path = join(abspath(path), f"ed0{(id >> 2) + 5 * page.value}.dat")
35
- else:
36
- raise ValueError(id)
37
-
38
- def __bool__(self) -> bool:
39
- with open(self._path, "rb") as f:
40
- f.seek(self._offset)
41
- return f.read(1) != b"\x00"
42
-
43
- def delete(self) -> None:
44
- with open(self._path, "r+b") as f:
45
- f.seek(self._offset)
46
- more: bool = f.read(1) != b"\x00"
47
- if more:
48
- while more:
49
- f.seek(-1, SEEK_CUR)
50
- f.write(_EMPTY_BLOCK)
51
- more = f.read(1) != b"\x00"
52
- else:
53
- raise DirLevelNotFoundError
54
-
55
- def __eq__(self, other: object, /) -> bool:
56
- return (
57
- self._offset == other._offset and self._path == other._path
58
- if isinstance(other, DirLevel)
59
- else NotImplemented
60
- )
61
-
62
- def __hash__(self) -> int:
63
- return hash((self._offset, self._path))
64
-
65
- @property
66
- def id(self) -> int:
67
- return int(self._path[-5]) % 5 << 2 | self._offset // _LEVEL_ALLOCATION_SIZE
68
-
69
- def __len__(self) -> int:
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
90
-
91
- @property
92
- def page(self) -> Page:
93
- return Page(ord(self._path[-5]) > 52)
94
-
95
- @property
96
- def path(self) -> str:
97
- return dirname(self._path)
98
-
99
- def read(self) -> bytes:
100
- with open(self._path, "rb") as f:
101
- f.seek(self._offset)
102
- block: Final[bytearray] = bytearray(f.read(_BLOCK_SIZE))
103
- if block[0]:
104
- result: Final[bytearray] = bytearray()
105
- while block[-1]:
106
- result.extend(block)
107
- f.readinto(block)
108
- hi: int = _BLOCK_SIZE - 1
109
- lo: int = 0
110
- test: int
111
- while hi != lo:
112
- test = (hi - lo >> 1) + lo
113
- if block[test]:
114
- lo = test + 1
115
- else:
116
- hi = test
117
- return bytes(result + block[:hi])
118
- else:
119
- raise DirLevelNotFoundError
120
-
121
- def __repr__(self) -> str:
122
- return f"{type(self).__name__}({self.path!r}, {self.page!r}, {self.id!r})"
123
-
124
- def write(self, new_content: bytes, /) -> None:
125
- with open(self._path, "r+b") as f:
126
- f.seek(self._offset)
127
- f.write(new_content)
128
- while f.read(1) != b"\x00":
129
- f.seek(-1, SEEK_CUR)
130
- f.write(_EMPTY_BLOCK)
131
-
132
-
133
- class DirGroup(Group[DirLevel]):
134
- __match_args__ = ("path", "page")
135
- __slots__ = ("_page", "_path")
136
-
137
- _page: Page
138
- _path: str
139
-
140
- def __init__(self, path: str, /, page: Page) -> None:
141
- self._page = page
142
- self._path = abspath(path)
143
-
144
- def __contains__(self, value: object, /) -> TypeGuard[DirLevel]:
145
- return (
146
- isinstance(value, DirLevel)
147
- and value.path == self.path
148
- and value.page is self.page
149
- )
150
-
151
- def count(self, value: object) -> int:
152
- return int(value in self)
153
-
154
- def __eq__(self, other: object, /) -> bool:
155
- return (
156
- self.page == other.page and self.path == other.path
157
- if isinstance(other, DirGroup)
158
- else NotImplemented
159
- )
160
-
161
- @overload
162
- def __getitem__(self, index: SupportsIndex, /) -> DirLevel:
163
- pass
164
-
165
- @overload
166
- def __getitem__(self, indices: slice, /) -> Sequence[DirLevel]:
167
- pass
168
-
169
- def __getitem__(
170
- self, key: SupportsIndex | slice, /
171
- ) -> DirLevel | Sequence[DirLevel]:
172
- if isinstance(key, slice):
173
- return tuple((self[i] for i in range(*key.indices(20))))
174
- else:
175
- i: int = index(key)
176
- if -20 <= i < 20:
177
- return DirLevel(self.path, self.page, i % 20)
178
- else:
179
- raise IndexError(key)
180
-
181
- def __hash__(self) -> int:
182
- return hash((self.path, self.page))
183
-
184
- def index(self, value: object, start: int = 0, stop: Optional[int] = None) -> int:
185
- if value in self and value.id in range(*slice(start, stop).indices(20)):
186
- return value.id
187
- else:
188
- raise ValueError
189
-
190
- @property
191
- def page(self) -> Page:
192
- return self._page
193
-
194
- @property
195
- def path(self) -> str:
196
- return self._path
197
-
198
- def __repr__(self) -> str:
199
- return f"{type(self).__name__}({self.path!r}, {self.page!r})"
200
-
201
-
202
- class DirSave(Location, Save[DirGroup]):
203
- __slots__ = ()
204
-
205
- def __bool__(self) -> bool:
206
- return all((isfile(join(self.path, f"ed0{i}.dat")) for i in range(10)))
207
-
208
- def __getitem__(self, key: Page, /) -> DirGroup:
209
- if isinstance(key, Page):
210
- return DirGroup(self.path, key)
211
- else:
212
- raise KeyError(key)
koro/file/lvl.py DELETED
@@ -1,40 +0,0 @@
1
- from os.path import getsize
2
- from warnings import warn
3
-
4
- from ..item.level import Level, LevelNotFoundError
5
- from . import Location
6
-
7
- __all__ = ["LvlLevel", "LvlLevelNotFoundError"]
8
-
9
-
10
- class LvlLevelNotFoundError(FileNotFoundError, LevelNotFoundError):
11
- pass
12
-
13
-
14
- class LvlLevel(Location, Level):
15
- __slots__ = ()
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
-
25
- def delete(self) -> None:
26
- try:
27
- return super().delete()
28
- except FileNotFoundError as e:
29
- raise LvlLevelNotFoundError(*e.args)
30
-
31
- def __len__(self) -> int:
32
- return getsize(self.path)
33
-
34
- def read(self) -> bytes:
35
- with open(self.path, "rb") as f:
36
- return f.read()
37
-
38
- def write(self, new_content: bytes, /) -> None:
39
- with open(self.path, "wb") as f:
40
- f.write(new_content)
koro/file/zip.py DELETED
@@ -1,223 +0,0 @@
1
- from collections.abc import Iterable, Sequence
2
- from itertools import filterfalse
3
- from operator import index
4
- from os.path import abspath
5
- from re import fullmatch
6
- from typing import Final, Optional, SupportsIndex, TypeGuard, overload
7
- from warnings import warn
8
- from zipfile import ZipFile, ZipInfo
9
-
10
- from ..item.group import Group
11
- from ..item.level import Level, LevelNotFoundError
12
- from . import Location
13
- from .bin import BinLevel
14
-
15
- __all__ = ["ZipGroup", "ZipLevel", "ZipLevelNotFoundError"]
16
-
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:
23
- return f"{str(index(id) + 1).zfill(2)}.lvl"
24
-
25
-
26
- class ZipLevelNotFoundError(KeyError, LevelNotFoundError):
27
- pass
28
-
29
-
30
- class ZipLevel(Level):
31
- __match_args__ = ("path", "id")
32
- __slots__ = ("_id", "_path")
33
-
34
- _id: int
35
- _path: str
36
-
37
- def __init__(self, path: str, /, id: SupportsIndex) -> None:
38
- i: int = index(id)
39
- if 0 <= i < 20:
40
- self._id = i
41
- self._path = abspath(path)
42
- else:
43
- raise ValueError(id)
44
-
45
- def __bool__(self) -> bool:
46
- with ZipFile(self.path) as a:
47
- return self.fn in a.namelist()
48
-
49
- def delete(self) -> None:
50
- with ZipFile(self.path) as a:
51
- if self.fn not in a.namelist() and self.old_fn not in a.namelist():
52
- raise ZipLevelNotFoundError
53
- contents: Final[dict[ZipInfo, bytes]] = {}
54
- for info in filterfalse(
55
- lambda info: info.filename == self.fn or info.filename == self.old_fn,
56
- a.infolist(),
57
- ):
58
- contents[info] = a.read(info)
59
- with ZipFile(self.path, "w") as a:
60
- for x in contents.items():
61
- a.writestr(*x)
62
-
63
- def __eq__(self, other: object, /) -> bool:
64
- return (
65
- self.path == other.path and self.id == other.id
66
- if isinstance(other, ZipLevel)
67
- else NotImplemented
68
- )
69
-
70
- def __hash__(self) -> int:
71
- return hash((self.path, self.id))
72
-
73
- @property
74
- def fn(self) -> str:
75
- return _id_to_fn(self.id)
76
-
77
- @property
78
- def id(self) -> int:
79
- return self._id
80
-
81
- def __len__(self) -> int:
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)
96
-
97
- @property
98
- def path(self) -> str:
99
- return self._path
100
-
101
- def read(self) -> bytes:
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))
114
-
115
- def __repr__(self) -> str:
116
- return f"{type(self).__name__}({self.path!r}, {self.id!r})"
117
-
118
- def write(self, new_content: bytes, /) -> None:
119
- contents: Final[dict[ZipInfo, bytes]] = {}
120
- with ZipFile(self.path, "a") as a:
121
- for info in filterfalse(
122
- lambda info: info.filename == self.fn or info.filename == self.old_fn,
123
- a.infolist(),
124
- ):
125
- contents[info] = a.read(info)
126
- with ZipFile(self.path, "w") as a:
127
- for x in contents.items():
128
- a.writestr(*x)
129
- a.writestr(self.fn, BinLevel.compress(new_content))
130
-
131
-
132
- class ZipGroup(Location, Group[ZipLevel]):
133
- __slots__ = ()
134
-
135
- def __init__(self, path: str) -> None:
136
- super().__init__(path)
137
- try:
138
- with ZipFile(self.path) as a:
139
- for id in range(20):
140
- if _id_to_old_fn(id) in a.namelist():
141
- warn(
142
- FutureWarning(
143
- "This ZipGroup contains levels using the deprecated LVL format. Update this file by writing to it or by using the update function."
144
- )
145
- )
146
- break
147
- except FileNotFoundError:
148
- pass
149
-
150
- def __contains__(self, value: object, /) -> TypeGuard[ZipLevel]:
151
- return isinstance(value, ZipLevel) and value.path == self.path
152
-
153
- def count(self, value: object) -> int:
154
- return int(value in self)
155
-
156
- @overload
157
- def __getitem__(self, index: SupportsIndex, /) -> ZipLevel:
158
- pass
159
-
160
- @overload
161
- def __getitem__(self, indices: slice, /) -> Sequence[ZipLevel]:
162
- pass
163
-
164
- def __getitem__(
165
- self, key: SupportsIndex | slice, /
166
- ) -> ZipLevel | Sequence[ZipLevel]:
167
- if isinstance(key, slice):
168
- return tuple((self[i] for i in range(*key.indices(20))))
169
- elif -20 <= index(key) < 20:
170
- return ZipLevel(self.path, index(key) % 20)
171
- else:
172
- raise IndexError(key)
173
-
174
- def index(self, value: object, start: int = 0, stop: Optional[int] = None) -> int:
175
- if value in self and value.id in range(*slice(start, stop).indices(20)):
176
- return value.id
177
- else:
178
- raise ValueError
179
-
180
- def fill_mask(self) -> Sequence[bool]:
181
- with ZipFile(self.path) as a:
182
- return [
183
- _id_to_fn(i) in a.namelist() or _id_to_old_fn in a.namelist()
184
- for i in range(20)
185
- ]
186
-
187
- def read(self) -> Iterable[Optional[bytes]]:
188
- with ZipFile(self.path) as a:
189
- return [
190
- BinLevel.decompress(a.read(_id_to_fn(id)))
191
- if _id_to_fn(id) in a.namelist()
192
- else a.read(_id_to_old_fn(id))
193
- if _id_to_old_fn(id) in a.namelist()
194
- else None
195
- for id in range(20)
196
- ]
197
-
198
- def update(self) -> None:
199
- self.write(self.read())
200
- warn(
201
- FutureWarning(
202
- "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."
203
- )
204
- )
205
-
206
- def write(self, new_content: Iterable[Optional[bytes]], /) -> None:
207
- contents: Final[dict[ZipInfo, bytes]] = {}
208
- if self:
209
- with ZipFile(self.path) as a:
210
- for info in filterfalse(
211
- lambda info: fullmatch(
212
- r"(0[1-9]|1\d|20)\.(bin|lvl)", info.filename
213
- ),
214
- a.infolist(),
215
- ):
216
- contents[info] = a.read(info)
217
- with ZipFile(self.path, "w") as a:
218
- for x in contents.items():
219
- a.writestr(*x)
220
- for id, content in filterfalse(
221
- lambda x: x[1] is None, enumerate(new_content)
222
- ):
223
- a.writestr(_id_to_fn(id), BinLevel.compress(content))
koro/item/__init__.py DELETED
File without changes
koro/item/group.py DELETED
@@ -1,48 +0,0 @@
1
- from abc import ABC
2
- from collections.abc import Iterable, Sequence
3
- from itertools import chain, repeat
4
- from typing import Final, Generic, Optional, TypeVar
5
-
6
- from .level import Level, LevelNotFoundError
7
-
8
- __all__ = ["Group"]
9
-
10
- _L = TypeVar("_L", bound=Level)
11
-
12
-
13
- class Group(ABC, Generic[_L], Sequence[_L]):
14
- """A group of 20 levels.
15
- Note that levels are 0-indexed within this interface, but 1-indexed in-game.
16
- """
17
-
18
- __slots__ = ()
19
-
20
- def fill_mask(self) -> Sequence[bool]:
21
- """Return which level IDs within this Group exist."""
22
- return [bool(l) for l in self]
23
-
24
- def __len__(self) -> int:
25
- return 20
26
-
27
- def read(self) -> Iterable[Optional[bytes]]:
28
- """Read all of the levels within this Group. Empty slots yield None."""
29
- content: Optional[bytes]
30
- result: Final[list[Optional[bytes]]] = []
31
- for level in self:
32
- try:
33
- content = level.read()
34
- except LevelNotFoundError:
35
- content = None
36
- result.append(content)
37
- return result
38
-
39
- def write(self, new_content: Iterable[Optional[bytes]], /) -> None:
40
- """Replace the contents of this Group with the specified new content. None values will empty the slot they correspond to."""
41
- for src, dest in zip(chain(new_content, repeat(None)), self):
42
- if src is None:
43
- try:
44
- dest.delete()
45
- except LevelNotFoundError:
46
- pass
47
- else:
48
- dest.write(src)