legacy-puyo-tools 0.0.1__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.
- legacy_puyo_tools/__init__.py +8 -0
- legacy_puyo_tools/cli.py +177 -0
- legacy_puyo_tools/exceptions.py +13 -0
- legacy_puyo_tools/fpd.py +288 -0
- legacy_puyo_tools/mtx.py +141 -0
- legacy_puyo_tools/py.typed +0 -0
- legacy_puyo_tools-0.0.1.dist-info/METADATA +34 -0
- legacy_puyo_tools-0.0.1.dist-info/RECORD +11 -0
- legacy_puyo_tools-0.0.1.dist-info/WHEEL +4 -0
- legacy_puyo_tools-0.0.1.dist-info/entry_points.txt +2 -0
- legacy_puyo_tools-0.0.1.dist-info/licenses/LICENSE +20 -0
@@ -0,0 +1,8 @@
|
|
1
|
+
"""A conversion tool for files used by the older Puyo games.
|
2
|
+
|
3
|
+
Supports Puyo Puyo 7 and possibly Puyo Puyo! 15th Anniversary `mtx` and
|
4
|
+
`fpd` files. Puyo Puyo!! 20th Anniversary is still not supported yet.
|
5
|
+
|
6
|
+
SPDX-FileCopyrightText: 2025 Samuel Wu
|
7
|
+
SPDX-License-Identifier: MIT
|
8
|
+
"""
|
legacy_puyo_tools/cli.py
ADDED
@@ -0,0 +1,177 @@
|
|
1
|
+
"""A commandline application that interfaces with conversion tools.
|
2
|
+
|
3
|
+
SPDX-FileCopyrightText: 2025 Samuel Wu
|
4
|
+
SPDX-License-Identifier: MIT
|
5
|
+
"""
|
6
|
+
|
7
|
+
from __future__ import annotations
|
8
|
+
|
9
|
+
import argparse
|
10
|
+
import sys
|
11
|
+
from codecs import BOM_UTF16_LE
|
12
|
+
from collections.abc import Callable
|
13
|
+
from importlib import metadata
|
14
|
+
from pathlib import Path
|
15
|
+
from typing import BinaryIO
|
16
|
+
|
17
|
+
from attrs import define
|
18
|
+
|
19
|
+
from legacy_puyo_tools.exceptions import FileFormatError
|
20
|
+
from legacy_puyo_tools.fpd import Fpd
|
21
|
+
from legacy_puyo_tools.mtx import Mtx
|
22
|
+
|
23
|
+
|
24
|
+
@define
|
25
|
+
class _CliNamespace(argparse.Namespace):
|
26
|
+
func: Callable[[type[_CliNamespace]], None]
|
27
|
+
input: BinaryIO
|
28
|
+
output: BinaryIO
|
29
|
+
unicode: BinaryIO
|
30
|
+
fpd: BinaryIO
|
31
|
+
version: bool
|
32
|
+
|
33
|
+
|
34
|
+
def _create_fpd(args: _CliNamespace) -> None:
|
35
|
+
if args.input.read(2) != BOM_UTF16_LE:
|
36
|
+
raise FileFormatError(
|
37
|
+
f"{args.input.name} is not a UTF-16 little-endian encoded text"
|
38
|
+
"file."
|
39
|
+
)
|
40
|
+
|
41
|
+
if args.output:
|
42
|
+
Fpd.read_unicode(args.input).write_fpd(args.output)
|
43
|
+
return
|
44
|
+
|
45
|
+
path = Path(args.input.name).with_suffix("")
|
46
|
+
|
47
|
+
if path.suffix != ".fpd":
|
48
|
+
path = path.with_suffix(".fpd")
|
49
|
+
|
50
|
+
Fpd.read_unicode(args.input).write_fpd_to_path(path)
|
51
|
+
|
52
|
+
|
53
|
+
def _create_mtx(args: _CliNamespace) -> None:
|
54
|
+
raise NotImplementedError()
|
55
|
+
|
56
|
+
|
57
|
+
def _convert_fpd(args: _CliNamespace) -> None:
|
58
|
+
if args.output:
|
59
|
+
args.output.write(BOM_UTF16_LE)
|
60
|
+
Fpd.read_fpd(args.input).write_fpd(args.output)
|
61
|
+
return
|
62
|
+
|
63
|
+
path = Path(args.input.name).with_suffix("")
|
64
|
+
|
65
|
+
if path.suffix != ".fpd":
|
66
|
+
path = path.with_suffix(".fpd")
|
67
|
+
|
68
|
+
Fpd.read_fpd(args.input).write_unicode_to_path(path)
|
69
|
+
|
70
|
+
|
71
|
+
def _convert_mtx(args: _CliNamespace) -> None:
|
72
|
+
if args.fpd:
|
73
|
+
fpd_data = Fpd.read_fpd(args.fpd)
|
74
|
+
else:
|
75
|
+
if args.unicode.read(2) != BOM_UTF16_LE:
|
76
|
+
raise FileFormatError(
|
77
|
+
f"{args.input.name} is not a UTF-16 little-endian encoded text"
|
78
|
+
"file."
|
79
|
+
)
|
80
|
+
|
81
|
+
fpd_data = Fpd.read_unicode(args.unicode)
|
82
|
+
|
83
|
+
if args.output:
|
84
|
+
Mtx.read_mtx(args.input).write_xml(args.output, fpd_data)
|
85
|
+
return
|
86
|
+
|
87
|
+
path = Path(args.input.name).with_suffix("")
|
88
|
+
|
89
|
+
if path.suffix != ".xml":
|
90
|
+
path = path.with_suffix(".xml")
|
91
|
+
|
92
|
+
Mtx.read_mtx(args.input).write_xml_to_file(path, fpd_data)
|
93
|
+
|
94
|
+
|
95
|
+
def _create_parsers(main_parser: argparse.ArgumentParser) -> None:
|
96
|
+
shared_options = argparse.ArgumentParser(add_help=False)
|
97
|
+
shared_options.add_argument(
|
98
|
+
"input", type=argparse.FileType("rb"), help="input file"
|
99
|
+
)
|
100
|
+
shared_options.add_argument(
|
101
|
+
"-o", "--output", type=argparse.FileType("wb"), help="output file"
|
102
|
+
)
|
103
|
+
|
104
|
+
mtx_options = argparse.ArgumentParser(
|
105
|
+
add_help=False, parents=[shared_options]
|
106
|
+
)
|
107
|
+
mtx_options_group = mtx_options.add_mutually_exclusive_group(required=True)
|
108
|
+
mtx_options_group.add_argument(
|
109
|
+
"--fpd", type=argparse.FileType("rb"), help="fpd file"
|
110
|
+
)
|
111
|
+
mtx_options_group.add_argument(
|
112
|
+
"--unicode", type=argparse.FileType("rb"), help="unicode text file"
|
113
|
+
)
|
114
|
+
|
115
|
+
sub_parser = main_parser.add_subparsers()
|
116
|
+
|
117
|
+
create_parser = sub_parser.add_parser("create")
|
118
|
+
create_sub_parser = create_parser.add_subparsers(required=True)
|
119
|
+
|
120
|
+
create_fpd_parser = create_sub_parser.add_parser(
|
121
|
+
"fpd",
|
122
|
+
help="create a fpd file from a unicode text file",
|
123
|
+
parents=[shared_options],
|
124
|
+
)
|
125
|
+
create_fpd_parser.set_defaults(func=_create_fpd)
|
126
|
+
|
127
|
+
create_mtx_parser = create_sub_parser.add_parser(
|
128
|
+
"mtx", help="create a mtx file from a XML file", parents=[mtx_options]
|
129
|
+
)
|
130
|
+
create_mtx_parser.set_defaults(func=_create_mtx)
|
131
|
+
|
132
|
+
convert_parser = sub_parser.add_parser("convert")
|
133
|
+
convert_sub_parser = convert_parser.add_subparsers(required=True)
|
134
|
+
|
135
|
+
convert_fpd_parser = convert_sub_parser.add_parser(
|
136
|
+
"fpd",
|
137
|
+
help="convert a fpd file to a unicode file",
|
138
|
+
parents=[shared_options],
|
139
|
+
)
|
140
|
+
convert_fpd_parser.set_defaults(func=_convert_fpd)
|
141
|
+
|
142
|
+
convert_mtx_parser = convert_sub_parser.add_parser(
|
143
|
+
"mtx", help="convert a mtx file to XML file", parents=[mtx_options]
|
144
|
+
)
|
145
|
+
convert_mtx_parser.set_defaults(func=_convert_mtx)
|
146
|
+
|
147
|
+
|
148
|
+
def main() -> None:
|
149
|
+
"""Entry point for the commandline application."""
|
150
|
+
main_parser = argparse.ArgumentParser(
|
151
|
+
description="A conversion tool for files used by older Puyo games."
|
152
|
+
)
|
153
|
+
main_parser.add_argument(
|
154
|
+
"-v", "--version", help="show version", action="store_true"
|
155
|
+
)
|
156
|
+
|
157
|
+
_create_parsers(main_parser)
|
158
|
+
|
159
|
+
args = main_parser.parse_args(namespace=_CliNamespace)
|
160
|
+
|
161
|
+
if args.version is True:
|
162
|
+
package_name = "legacy-puyo-tools"
|
163
|
+
version = metadata.version(package_name)
|
164
|
+
|
165
|
+
print(f"{package_name} {version}")
|
166
|
+
|
167
|
+
sys.exit(0)
|
168
|
+
|
169
|
+
if not hasattr(args, "func"):
|
170
|
+
main_parser.print_help(sys.stderr)
|
171
|
+
sys.exit(1)
|
172
|
+
|
173
|
+
args.func(args)
|
174
|
+
|
175
|
+
|
176
|
+
if __name__ == "__main__":
|
177
|
+
main()
|
@@ -0,0 +1,13 @@
|
|
1
|
+
"""Exceptions that might be thrown when parsing files.
|
2
|
+
|
3
|
+
SPDX-FileCopyrightText: 2025 Samuel Wu
|
4
|
+
SPDX-License-Identifier: MIT
|
5
|
+
"""
|
6
|
+
|
7
|
+
|
8
|
+
class FormatError(Exception):
|
9
|
+
"""The current input data does not conform to an expected format."""
|
10
|
+
|
11
|
+
|
12
|
+
class FileFormatError(FormatError):
|
13
|
+
"""The file does not conform to a file format or is malformed."""
|
legacy_puyo_tools/fpd.py
ADDED
@@ -0,0 +1,288 @@
|
|
1
|
+
"""fpd conversion tool for older puyo games.
|
2
|
+
|
3
|
+
This module supports the encoding and decoding of the fpd format used by
|
4
|
+
Puyo Puyo! 15th Anniversary and Puyo Puyo 7.
|
5
|
+
|
6
|
+
SPDX-FileCopyrightText: 2025 Samuel Wu
|
7
|
+
SPDX-License-Identifier: MIT
|
8
|
+
"""
|
9
|
+
|
10
|
+
from codecs import BOM_UTF16_LE
|
11
|
+
from io import BytesIO
|
12
|
+
from pathlib import Path
|
13
|
+
from typing import BinaryIO, Self
|
14
|
+
|
15
|
+
from attrs import define
|
16
|
+
|
17
|
+
from legacy_puyo_tools.exceptions import FileFormatError, FormatError
|
18
|
+
|
19
|
+
ENCODING = "utf-16-le"
|
20
|
+
FPD_ENTRY_LENGTH = 3
|
21
|
+
UTF16_LENGTH = 2
|
22
|
+
WIDTH_ENTRY_OFFSET = 2
|
23
|
+
|
24
|
+
|
25
|
+
@define
|
26
|
+
class FpdCharacter:
|
27
|
+
"""A fpd character entry.
|
28
|
+
|
29
|
+
A fpd character is a binary entry that is 3 bytes long and formatted
|
30
|
+
as follows: `XX XX YY`. Where `XX XX` is the character encoded in
|
31
|
+
UTF-16 LE and `YY` is the width of the character.
|
32
|
+
|
33
|
+
Attributes:
|
34
|
+
code_point:
|
35
|
+
A string that stores a single character.
|
36
|
+
width:
|
37
|
+
How wide should the character be, only used in the Nintendo
|
38
|
+
DS versions of the games.
|
39
|
+
"""
|
40
|
+
|
41
|
+
code_point: str
|
42
|
+
width: int
|
43
|
+
|
44
|
+
def __init__(self, code_point: bytes, width: int = 0x00) -> None:
|
45
|
+
"""Initializes a fpd character.
|
46
|
+
|
47
|
+
Args:
|
48
|
+
code_point:
|
49
|
+
A Unicode character in the UTF-16 LE format.
|
50
|
+
width:
|
51
|
+
The width of the character. Defaults to 0x00.
|
52
|
+
"""
|
53
|
+
self.code_point = code_point.decode(ENCODING)
|
54
|
+
self.width = width
|
55
|
+
|
56
|
+
def encode(self) -> bytes:
|
57
|
+
"""Encodes the character back to a fpd character entry.
|
58
|
+
|
59
|
+
Returns:
|
60
|
+
The character in UTF-16 LE format and its width.
|
61
|
+
"""
|
62
|
+
return self.code_point.encode(ENCODING) + self.width.to_bytes()
|
63
|
+
|
64
|
+
@classmethod
|
65
|
+
def decode(cls, fpd_entry: bytes) -> Self:
|
66
|
+
"""Decodes a fpd character into its code point and width.
|
67
|
+
|
68
|
+
Args:
|
69
|
+
fpd_entry:
|
70
|
+
A fpd character entry that is 3 bytes long.
|
71
|
+
|
72
|
+
Raises:
|
73
|
+
FormatError:
|
74
|
+
The entry given does not conform to the fpd character
|
75
|
+
format.
|
76
|
+
|
77
|
+
Returns:
|
78
|
+
A fpd character entry containing its code point and width.
|
79
|
+
"""
|
80
|
+
if len(fpd_entry) != FPD_ENTRY_LENGTH:
|
81
|
+
raise FormatError(
|
82
|
+
f"{fpd_entry} does not matches size {FPD_ENTRY_LENGTH}"
|
83
|
+
)
|
84
|
+
|
85
|
+
return cls(fpd_entry[:UTF16_LENGTH], fpd_entry[WIDTH_ENTRY_OFFSET])
|
86
|
+
|
87
|
+
|
88
|
+
@define
|
89
|
+
class Fpd:
|
90
|
+
"""A fpd character table.
|
91
|
+
|
92
|
+
The fpd stores character table in which each entry is placed right
|
93
|
+
next to each other and the indices is offset by multiples of `0x03`.
|
94
|
+
I.e. The 1st character is at index `0x00`, the 2nd character is at
|
95
|
+
index `0x03`, the 3rd character is at index `0x06`, etc.
|
96
|
+
|
97
|
+
Attributes:
|
98
|
+
entries:
|
99
|
+
A list of fpd character entries.
|
100
|
+
"""
|
101
|
+
|
102
|
+
entries: list[FpdCharacter]
|
103
|
+
|
104
|
+
def __getitem__(self, index: int) -> str:
|
105
|
+
"""Retrieves a character from the fpd character table.
|
106
|
+
|
107
|
+
Args:
|
108
|
+
index:
|
109
|
+
The index of the character to retrieve.
|
110
|
+
|
111
|
+
Returns:
|
112
|
+
A string that contains the requested character.
|
113
|
+
"""
|
114
|
+
return self.entries[index].code_point
|
115
|
+
|
116
|
+
@classmethod
|
117
|
+
def read_fpd_from_path(cls, path: Path) -> Self:
|
118
|
+
"""Reads and extract characters from a fpd file.
|
119
|
+
|
120
|
+
Args:
|
121
|
+
path:
|
122
|
+
A path to a fpd encoded file that contains a fpd
|
123
|
+
character table.
|
124
|
+
|
125
|
+
Raises:
|
126
|
+
FileFormatError:
|
127
|
+
The fpd file contain a entry that does not conform
|
128
|
+
to the fpd character format.
|
129
|
+
|
130
|
+
Returns:
|
131
|
+
A fpd character table.
|
132
|
+
"""
|
133
|
+
with Path(path).open("rb") as fp:
|
134
|
+
try:
|
135
|
+
return cls.read_fpd(fp)
|
136
|
+
except FormatError as e:
|
137
|
+
raise FileFormatError(f"{path} is not a valid fpd file") from e
|
138
|
+
|
139
|
+
@classmethod
|
140
|
+
def read_fpd(cls, fp: BinaryIO) -> Self:
|
141
|
+
"""Reads and extract characters from a file object.
|
142
|
+
|
143
|
+
Args:
|
144
|
+
fp:
|
145
|
+
A file object that points to a fpd encoded stream.
|
146
|
+
|
147
|
+
Returns:
|
148
|
+
A fpd character table.
|
149
|
+
"""
|
150
|
+
return cls.decode_fpd(fp.read())
|
151
|
+
|
152
|
+
@classmethod
|
153
|
+
def decode_fpd(cls, data: bytes) -> Self:
|
154
|
+
"""Extracts the fpd character table from a fpd encoded stream.
|
155
|
+
|
156
|
+
Args:
|
157
|
+
data:
|
158
|
+
A fpd encoded stream that contains a fpd character
|
159
|
+
table.
|
160
|
+
|
161
|
+
Returns:
|
162
|
+
A fpd character table.
|
163
|
+
"""
|
164
|
+
return cls([
|
165
|
+
FpdCharacter.decode(data[i : i + FPD_ENTRY_LENGTH])
|
166
|
+
for i in range(0, len(data), FPD_ENTRY_LENGTH)
|
167
|
+
])
|
168
|
+
|
169
|
+
def write_fpd_to_path(self, path: Path) -> None:
|
170
|
+
"""Writes the fpd character table to a fpd encoded file.
|
171
|
+
|
172
|
+
Args:
|
173
|
+
path:
|
174
|
+
A path to write the fpd encoded stream.
|
175
|
+
"""
|
176
|
+
with Path(path).open("wb") as fp:
|
177
|
+
self.write_fpd(fp)
|
178
|
+
|
179
|
+
def write_fpd(self, fp: BinaryIO) -> None:
|
180
|
+
"""Writes the fpd character table to a file object.
|
181
|
+
|
182
|
+
Args:
|
183
|
+
fp:
|
184
|
+
A binary file object.
|
185
|
+
"""
|
186
|
+
fp.write(self.encode_fpd())
|
187
|
+
|
188
|
+
def encode_fpd(self) -> bytes:
|
189
|
+
"""Encodes the fpd character table into a fpd encoded stream.
|
190
|
+
|
191
|
+
Returns:
|
192
|
+
A fpd encoded stream that contains the fpd character table.
|
193
|
+
"""
|
194
|
+
with BytesIO() as bytes_buffer:
|
195
|
+
for character in self.entries:
|
196
|
+
bytes_buffer.write(character.encode())
|
197
|
+
|
198
|
+
return bytes_buffer.getvalue()
|
199
|
+
|
200
|
+
@classmethod
|
201
|
+
def read_unicode_from_path(cls, path: Path) -> Self:
|
202
|
+
"""Reads and convert characters from a UTF-16 LE text file.
|
203
|
+
|
204
|
+
Arguments:
|
205
|
+
path:
|
206
|
+
A path to a UTF-16 LE text file.
|
207
|
+
|
208
|
+
Raises:
|
209
|
+
FileFormatError:
|
210
|
+
The file is not a UTF-16 LE encoded text file or is
|
211
|
+
missing the Byte Order Mark for UTF-16 LE.
|
212
|
+
|
213
|
+
Returns:
|
214
|
+
A fpd character table.
|
215
|
+
"""
|
216
|
+
with Path(path).open("rb") as fp:
|
217
|
+
# Check the Byte Order Mark (BOM) to see if it is really a
|
218
|
+
# UTF-16 LE text file
|
219
|
+
if fp.read(2) != BOM_UTF16_LE:
|
220
|
+
raise FileFormatError(f"{path} is not a UTF-16 LE text file.")
|
221
|
+
|
222
|
+
return cls.read_unicode(fp)
|
223
|
+
|
224
|
+
@classmethod
|
225
|
+
def read_unicode(cls, fp: BinaryIO) -> Self:
|
226
|
+
"""Reads and convert UTF-16 LE characters from a file object.
|
227
|
+
|
228
|
+
Args:
|
229
|
+
fp:
|
230
|
+
A binary file object.
|
231
|
+
|
232
|
+
Returns:
|
233
|
+
A fpd character table.
|
234
|
+
"""
|
235
|
+
return cls.decode_unicode(fp.read())
|
236
|
+
|
237
|
+
# TODO: Somehow allow people to specify the width of the character
|
238
|
+
# during decoding
|
239
|
+
@classmethod
|
240
|
+
def decode_unicode(cls, unicode: bytes) -> Self:
|
241
|
+
"""Converts a UTF-16 LE stream into a fpd character table.
|
242
|
+
|
243
|
+
Args:
|
244
|
+
unicode:
|
245
|
+
A UTF-16 LE encoded character stream.
|
246
|
+
|
247
|
+
Returns:
|
248
|
+
A fpd character table.
|
249
|
+
"""
|
250
|
+
return cls([
|
251
|
+
FpdCharacter(unicode[i : i + UTF16_LENGTH])
|
252
|
+
for i in range(0, len(unicode), UTF16_LENGTH)
|
253
|
+
])
|
254
|
+
|
255
|
+
def write_unicode_to_path(self, path: Path) -> None:
|
256
|
+
"""Writes the fpd character table to a UTF-16 LE text file.
|
257
|
+
|
258
|
+
Args:
|
259
|
+
path:
|
260
|
+
A path to store the converted UTF-16 LE text file.
|
261
|
+
"""
|
262
|
+
with Path(path).open("wb") as fp:
|
263
|
+
# Write the Byte Order Mark (BOM) for plain text editors
|
264
|
+
fp.write(BOM_UTF16_LE)
|
265
|
+
|
266
|
+
self.write_unicode(fp)
|
267
|
+
|
268
|
+
def write_unicode(self, fp: BinaryIO) -> None:
|
269
|
+
"""Writes the fpd character table to a file object.
|
270
|
+
|
271
|
+
Args:
|
272
|
+
fp:
|
273
|
+
A binary file object.
|
274
|
+
"""
|
275
|
+
fp.write(self.encode_unicode())
|
276
|
+
|
277
|
+
def encode_unicode(self) -> bytes:
|
278
|
+
"""Encodes the fpd character table into a UTF-16 LE text stream.
|
279
|
+
|
280
|
+
Returns:
|
281
|
+
A UTF-16 LE encoded text stream with characters from the
|
282
|
+
fpd.
|
283
|
+
"""
|
284
|
+
with BytesIO() as bytes_buffer:
|
285
|
+
for character in self.entries:
|
286
|
+
bytes_buffer.write(character.code_point.encode(ENCODING))
|
287
|
+
|
288
|
+
return bytes_buffer.getvalue()
|
legacy_puyo_tools/mtx.py
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
"""Manzai text conversion tool for older Puyo Puyo games.
|
2
|
+
|
3
|
+
This module converts `mtx` files to and from XML for modding Puyo games.
|
4
|
+
Currently supports Puyo Puyo 7 and might support Puyo Puyo! 15th
|
5
|
+
Anniversary.
|
6
|
+
|
7
|
+
SPDX-FileCopyrightText: 2025 Samuel Wu
|
8
|
+
SPDX-License-Identifier: MIT
|
9
|
+
"""
|
10
|
+
|
11
|
+
from collections.abc import Callable
|
12
|
+
from itertools import pairwise
|
13
|
+
from pathlib import Path
|
14
|
+
from typing import BinaryIO, Literal, Self
|
15
|
+
|
16
|
+
from attrs import define
|
17
|
+
from lxml import etree
|
18
|
+
|
19
|
+
from legacy_puyo_tools.exceptions import FileFormatError, FormatError
|
20
|
+
from legacy_puyo_tools.fpd import Fpd
|
21
|
+
|
22
|
+
CHARACTER_WIDTH = 2
|
23
|
+
ENDIAN = "little"
|
24
|
+
|
25
|
+
INT32_OFFSET = 8
|
26
|
+
INT32_SIZE = 4
|
27
|
+
INT64_OFFSET = 16
|
28
|
+
INT64_SIZE = 8
|
29
|
+
|
30
|
+
|
31
|
+
def _read_character(data: bytes, i: int) -> int:
|
32
|
+
return int.from_bytes(data[i : i + CHARACTER_WIDTH], ENDIAN)
|
33
|
+
|
34
|
+
|
35
|
+
def _create_offset_reader(width: int) -> Callable[[bytes, int], int]:
|
36
|
+
def offset_reader(data: bytes, i: int) -> int:
|
37
|
+
return int.from_bytes(data[i : i + width], ENDIAN)
|
38
|
+
|
39
|
+
return offset_reader
|
40
|
+
|
41
|
+
|
42
|
+
def _identify_mtx(data: bytes) -> tuple[Literal[8, 16], Literal[4, 8]]:
|
43
|
+
if int.from_bytes(data[:4], ENDIAN) == INT32_OFFSET:
|
44
|
+
return (INT32_OFFSET, INT32_SIZE)
|
45
|
+
|
46
|
+
if int.from_bytes(data[8:16], ENDIAN) == INT64_OFFSET:
|
47
|
+
return (INT64_OFFSET, INT64_SIZE)
|
48
|
+
|
49
|
+
raise FormatError("The given data is not in a valid `mtx` format")
|
50
|
+
|
51
|
+
|
52
|
+
type MtxString = list[int]
|
53
|
+
|
54
|
+
|
55
|
+
@define
|
56
|
+
class Mtx:
|
57
|
+
strings: list[MtxString]
|
58
|
+
|
59
|
+
@classmethod
|
60
|
+
def read_mtx_from_file(cls, path: Path) -> Self:
|
61
|
+
with Path(path).open("rb") as fp:
|
62
|
+
try:
|
63
|
+
return cls.read_mtx(fp)
|
64
|
+
except FormatError as e:
|
65
|
+
raise FileFormatError(
|
66
|
+
f"{path} is not a valid `mtx` file"
|
67
|
+
) from e
|
68
|
+
|
69
|
+
@classmethod
|
70
|
+
def read_mtx(cls, fp: BinaryIO) -> Self:
|
71
|
+
return cls.decode_mtx(fp.read())
|
72
|
+
|
73
|
+
@classmethod
|
74
|
+
def decode_mtx(cls, data: bytes) -> Self:
|
75
|
+
length = int.from_bytes(data[:4], ENDIAN)
|
76
|
+
|
77
|
+
if length != len(data):
|
78
|
+
raise FormatError("The size of the given data does not match")
|
79
|
+
|
80
|
+
section_table_index_offset, int_width = _identify_mtx(data[4:16])
|
81
|
+
read_offset = _create_offset_reader(int_width)
|
82
|
+
|
83
|
+
section_table_offset = read_offset(data, section_table_index_offset)
|
84
|
+
string_table_offset = read_offset(data, section_table_offset)
|
85
|
+
|
86
|
+
sections = [
|
87
|
+
read_offset(data, section_table_offset + (i * int_width))
|
88
|
+
for i in range(
|
89
|
+
(string_table_offset - section_table_offset) // int_width
|
90
|
+
)
|
91
|
+
]
|
92
|
+
|
93
|
+
# Add the length to the sections so we can read to end of stream
|
94
|
+
sections.append(length)
|
95
|
+
|
96
|
+
strings: list[MtxString] = []
|
97
|
+
|
98
|
+
for current_string_offset, next_string_offset in pairwise(sections):
|
99
|
+
strings.append([
|
100
|
+
_read_character(
|
101
|
+
data, current_string_offset + (i * CHARACTER_WIDTH)
|
102
|
+
)
|
103
|
+
for i in range(next_string_offset - current_string_offset)
|
104
|
+
])
|
105
|
+
|
106
|
+
return cls(strings)
|
107
|
+
|
108
|
+
def write_xml_to_file(self, path: Path, fpd: Fpd) -> None:
|
109
|
+
with Path(path).open("wb") as fp:
|
110
|
+
self.write_xml(fp, fpd)
|
111
|
+
|
112
|
+
def write_xml(self, fp: BinaryIO, fpd: Fpd) -> None:
|
113
|
+
fp.write(self.encode_xml(fpd))
|
114
|
+
|
115
|
+
def encode_xml(self, fpd: Fpd) -> bytes:
|
116
|
+
root = etree.Element("mtx")
|
117
|
+
sheet = etree.SubElement(root, "sheet")
|
118
|
+
|
119
|
+
for string in self.strings:
|
120
|
+
dialog = etree.SubElement(sheet, "text")
|
121
|
+
dialog.text = "\n"
|
122
|
+
|
123
|
+
for character in string:
|
124
|
+
match character:
|
125
|
+
case 0xF813:
|
126
|
+
dialog.append(etree.Element("arrow"))
|
127
|
+
# TODO: Figure out what this control character does
|
128
|
+
case 0xF883:
|
129
|
+
dialog.text += "0xF883"
|
130
|
+
case 0xFFFD:
|
131
|
+
dialog.text += "\n"
|
132
|
+
case 0xFFFF:
|
133
|
+
break
|
134
|
+
case _:
|
135
|
+
dialog.text += fpd[character]
|
136
|
+
|
137
|
+
etree.indent(root)
|
138
|
+
|
139
|
+
return etree.tostring(
|
140
|
+
root, encoding="utf-8", xml_declaration=True, pretty_print=True
|
141
|
+
)
|
File without changes
|
@@ -0,0 +1,34 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: legacy-puyo-tools
|
3
|
+
Version: 0.0.1
|
4
|
+
Summary: A tool to edit text for older Puyo Puyo games.
|
5
|
+
Author-email: Samuel Wu <twopizza9621536@gmail.com>
|
6
|
+
License-Expression: MIT
|
7
|
+
License-File: LICENSE
|
8
|
+
Requires-Python: >=3.13
|
9
|
+
Requires-Dist: attrs>=25.3.0
|
10
|
+
Requires-Dist: lxml>=6.0.0
|
11
|
+
Description-Content-Type: text/markdown
|
12
|
+
|
13
|
+
# Legacy Puyo Tools
|
14
|
+
|
15
|
+
Supports Puyo Puyo 7 and possibly Puyo Puyo! 15th Anniversary `mtx` and `fpd`
|
16
|
+
files. Puyo Puyo!! 20th Anniversary is still not supported yet. (Create an issue
|
17
|
+
or pull request to support `fnt` and additional `mtx` controls). Also, legacy as
|
18
|
+
in older Puyo Puyo games, not that this tool is decapitated.
|
19
|
+
|
20
|
+
## Why
|
21
|
+
|
22
|
+
The [Puyo Text Editor][1] can already do what Legacy Puyo Tools does and is the
|
23
|
+
inspiration of this tool, but there are advantages to rewrite it in Python:
|
24
|
+
|
25
|
+
- Better cross compatibility with Linux.
|
26
|
+
- Don't have to update the language version every time it becomes End of Life.
|
27
|
+
- Avoids the rigidness of using a pure object-oriented design.
|
28
|
+
|
29
|
+
## License
|
30
|
+
|
31
|
+
Under the MIT License. Based on [Puyo Text Editor][1] which is also under the
|
32
|
+
MIT License.
|
33
|
+
|
34
|
+
[1]: https://github.com/nickworonekin/puyo-text-editor
|
@@ -0,0 +1,11 @@
|
|
1
|
+
legacy_puyo_tools/__init__.py,sha256=tTzyNju0CFsXWki9ttjO6qnNx4Dlmur-1zgJ-n9toB0,277
|
2
|
+
legacy_puyo_tools/cli.py,sha256=gqwjiuSqAzuJRc_Tei1WRxRWGMC0AycWhf940oY-UrY,4934
|
3
|
+
legacy_puyo_tools/exceptions.py,sha256=vqoMG7VjUddzWGMIvzWtE18U6fnmMW7VcpwdOIoOiBc,341
|
4
|
+
legacy_puyo_tools/fpd.py,sha256=XXVCMlYD_eJAhmIQzDlRFvqm-FG9imm68bLooM2-mi4,8306
|
5
|
+
legacy_puyo_tools/mtx.py,sha256=8dGHE719LuOZ9fi8uEgAKh5YIdCuvo9bXAEbcwxaC8Q,4253
|
6
|
+
legacy_puyo_tools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
7
|
+
legacy_puyo_tools-0.0.1.dist-info/METADATA,sha256=PCxhnsrQHopcml8PQbgEuBGBP4z_4rPbZWHMkFftFxc,1167
|
8
|
+
legacy_puyo_tools-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
9
|
+
legacy_puyo_tools-0.0.1.dist-info/entry_points.txt,sha256=SWkgMVVrmWMKaFJXbb0vx-qAjqMWnxm3_0AVwYCdZLs,65
|
10
|
+
legacy_puyo_tools-0.0.1.dist-info/licenses/LICENSE,sha256=hu6L3ztT5uuc-EMaEgsjrhAHSTh56efyD-tVaEpc7Rw,1066
|
11
|
+
legacy_puyo_tools-0.0.1.dist-info/RECORD,,
|
@@ -0,0 +1,20 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Samuel Wu
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
10
|
+
subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|