hacktools 1.0.1__cp315-cp315-win32.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.
hacktools/common.py ADDED
@@ -0,0 +1,2312 @@
1
+ """Common utilities shared by all the platform modules.
2
+
3
+ Includes the :class:`Stream` class for binary I/O, CLI bootstrapping for
4
+ tools, logging, text section and XLIFF translation files, string extraction
5
+ and repacking, folder and patching helpers, and texture utilities.
6
+ """
7
+ from io import BytesIO, StringIO
8
+ import xml.etree.ElementTree as ET
9
+ import logging
10
+ import math
11
+ import os
12
+ import re
13
+ import shlex
14
+ import shutil
15
+ import sys
16
+ import stat
17
+ import struct
18
+ import subprocess
19
+ import typing
20
+ import zlib
21
+
22
+ hasClick = False
23
+ hasTqdm = False
24
+ hasGUI = False
25
+
26
+ try:
27
+ import click
28
+ hasClick = True
29
+ except ImportError:
30
+ pass
31
+
32
+ try:
33
+ from tqdm import tqdm
34
+ hasTqdm = True
35
+ except ImportError:
36
+ pass
37
+
38
+ table: dict[str, str] = {}
39
+
40
+
41
+ # File reading
42
+ class Stream(object):
43
+ """Endian-aware binary stream, wrapping a file or an in-memory buffer.
44
+
45
+ The underlying file is opened when entering the context manager. Most
46
+ read and write methods have an At variant that operates at the given
47
+ position and restores the current one afterwards.
48
+
49
+ Args:
50
+ fpath: Path of the file to open, unused for memory streams.
51
+ mode: Mode passed to open, or "m" for an in-memory stream.
52
+ little: Whether the stream is little endian.
53
+
54
+ Attributes:
55
+ f: The underlying file object, or the file path before opening.
56
+ mode: The mode the stream was created with.
57
+ endian: Endianness prefix used for struct operations, "<" or ">".
58
+ half: Nibble buffered by :meth:`readHalf` and :meth:`writeHalf`, or None.
59
+ """
60
+ def __init__(self, fpath: str = "", mode: str = "m", little: bool = True):
61
+ self.f = fpath
62
+ self.mode = mode
63
+ self.endian = "<" if little else ">"
64
+ self.half: int | None = None
65
+
66
+ def __enter__(self):
67
+ if self.mode == "m":
68
+ self.f = BytesIO()
69
+ else:
70
+ self.f = open(self.f, self.mode)
71
+ return self
72
+
73
+ def __exit__(self, exc_type, exc_value, traceback):
74
+ self.f.close()
75
+
76
+ def close(self) -> None:
77
+ """Close the underlying file."""
78
+ self.f.close()
79
+
80
+ def tell(self) -> int:
81
+ """Return the current position in the stream."""
82
+ return self.f.tell()
83
+
84
+ def seek(self, pos: int, whence: int = 0) -> None:
85
+ """Move the current position of the stream.
86
+
87
+ Args:
88
+ pos: Offset to move to, relative to whence.
89
+ whence: 0 for absolute, 1 for relative, 2 for relative to the end.
90
+ """
91
+ self.f.seek(pos, whence)
92
+
93
+ def read(self, n: int = -1) -> bytes:
94
+ """Read n bytes, or all the remaining ones.
95
+
96
+ Args:
97
+ n: Number of bytes to read, or -1 to read until the end.
98
+
99
+ Returns:
100
+ The data that was read.
101
+ """
102
+ return self.f.read(n)
103
+
104
+ def readAt(self, pos: int, n: int = -1) -> bytes:
105
+ """Read n bytes at pos, restoring the current position afterwards.
106
+
107
+ Args:
108
+ pos: Position to read at.
109
+ n: Number of bytes to read, or -1 to read until the end.
110
+
111
+ Returns:
112
+ The data that was read.
113
+ """
114
+ current = self.tell()
115
+ self.seek(pos)
116
+ ret = self.read(n)
117
+ self.seek(current)
118
+ return ret
119
+
120
+ def write(self, data: bytes) -> None:
121
+ """Write data to the stream.
122
+
123
+ Args:
124
+ data: Data to write.
125
+ """
126
+ self.f.write(data)
127
+
128
+ def writeAt(self, pos: int, data: bytes) -> None:
129
+ """Write data at pos, restoring the current position afterwards.
130
+
131
+ Args:
132
+ pos: Position to write at.
133
+ data: Data to write.
134
+ """
135
+ current = self.tell()
136
+ self.seek(pos)
137
+ self.write(data)
138
+ self.seek(current)
139
+
140
+ def peek(self, n: int) -> bytes:
141
+ """Read n bytes without advancing the current position.
142
+
143
+ Args:
144
+ n: Number of bytes to read.
145
+
146
+ Returns:
147
+ The data that was read.
148
+ """
149
+ pos = self.tell()
150
+ ret = self.read(n)
151
+ self.seek(pos)
152
+ return ret
153
+
154
+ def writeLine(self, data: str) -> None:
155
+ """Write a string followed by a line feed, for text mode streams.
156
+
157
+ Args:
158
+ data: String to write.
159
+ """
160
+ self.f.write(data + "\n")
161
+
162
+ def setEndian(self, little: bool) -> None:
163
+ """Set the endianness of the stream.
164
+
165
+ Args:
166
+ little: Whether the stream is little endian.
167
+ """
168
+ self.endian = "<" if little else ">"
169
+
170
+ def swapEndian(self) -> None:
171
+ """Switch the stream between little and big endian."""
172
+ self.endian = "<" if self.endian == ">" else ">"
173
+
174
+ def readLong(self) -> int:
175
+ """Read a signed 64-bit integer."""
176
+ return struct.unpack(self.endian + "q", self.read(8))[0]
177
+
178
+ def readLongAt(self, pos: int) -> int:
179
+ """Read a signed 64-bit integer at pos, restoring the current position."""
180
+ current = self.tell()
181
+ self.seek(pos)
182
+ ret = self.readLong()
183
+ self.seek(current)
184
+ return ret
185
+
186
+ def readULong(self) -> int:
187
+ """Read an unsigned 64-bit integer."""
188
+ return struct.unpack(self.endian + "Q", self.read(8))[0]
189
+
190
+ def readULongAt(self, pos: int) -> int:
191
+ """Read an unsigned 64-bit integer at pos, restoring the current position."""
192
+ current = self.tell()
193
+ self.seek(pos)
194
+ ret = self.readULong()
195
+ self.seek(current)
196
+ return ret
197
+
198
+ def readInt(self) -> int:
199
+ """Read a signed 32-bit integer."""
200
+ return struct.unpack(self.endian + "i", self.read(4))[0]
201
+
202
+ def readIntAt(self, pos: int) -> int:
203
+ """Read a signed 32-bit integer at pos, restoring the current position."""
204
+ current = self.tell()
205
+ self.seek(pos)
206
+ ret = self.readInt()
207
+ self.seek(current)
208
+ return ret
209
+
210
+ def readUInt(self) -> int:
211
+ """Read an unsigned 32-bit integer."""
212
+ return struct.unpack(self.endian + "I", self.read(4))[0]
213
+
214
+ def readUIntAt(self, pos: int) -> int:
215
+ """Read an unsigned 32-bit integer at pos, restoring the current position."""
216
+ current = self.tell()
217
+ self.seek(pos)
218
+ ret = self.readUInt()
219
+ self.seek(current)
220
+ return ret
221
+
222
+ def readFloat(self) -> float:
223
+ """Read a 32-bit float."""
224
+ return struct.unpack(self.endian + "f", self.read(4))[0]
225
+
226
+ def readFloatAt(self, pos: int) -> float:
227
+ """Read a 32-bit float at pos, restoring the current position."""
228
+ current = self.tell()
229
+ self.seek(pos)
230
+ ret = self.readFloat()
231
+ self.seek(current)
232
+ return ret
233
+
234
+ def readDouble(self) -> float:
235
+ """Read a 64-bit float."""
236
+ return struct.unpack(self.endian + "d", self.read(8))[0]
237
+
238
+ def readDoubleAt(self, pos: int) -> float:
239
+ """Read a 64-bit float at pos, restoring the current position."""
240
+ current = self.tell()
241
+ self.seek(pos)
242
+ ret = self.readDouble()
243
+ self.seek(current)
244
+ return ret
245
+
246
+ def readShort(self) -> int:
247
+ """Read a signed 16-bit integer."""
248
+ return struct.unpack(self.endian + "h", self.read(2))[0]
249
+
250
+ def readShortAt(self, pos: int) -> int:
251
+ """Read a signed 16-bit integer at pos, restoring the current position."""
252
+ current = self.tell()
253
+ self.seek(pos)
254
+ ret = self.readShort()
255
+ self.seek(current)
256
+ return ret
257
+
258
+ def readUShort(self) -> int:
259
+ """Read an unsigned 16-bit integer."""
260
+ return struct.unpack(self.endian + "H", self.read(2))[0]
261
+
262
+ def readUShortAt(self, pos: int) -> int:
263
+ """Read an unsigned 16-bit integer at pos, restoring the current position."""
264
+ current = self.tell()
265
+ self.seek(pos)
266
+ ret = self.readUShort()
267
+ self.seek(current)
268
+ return ret
269
+
270
+ def readByte(self) -> int:
271
+ """Read an unsigned 8-bit integer."""
272
+ return struct.unpack("B", self.read(1))[0]
273
+
274
+ def readByteAt(self, pos: int) -> int:
275
+ """Read an unsigned 8-bit integer at pos, restoring the current position."""
276
+ current = self.tell()
277
+ self.seek(pos)
278
+ ret = self.readByte()
279
+ self.seek(current)
280
+ return ret
281
+
282
+ def readSByte(self) -> int:
283
+ """Read a signed 8-bit integer."""
284
+ return struct.unpack("b", self.read(1))[0]
285
+
286
+ def readSByteAt(self, pos: int) -> int:
287
+ """Read a signed 8-bit integer at pos, restoring the current position."""
288
+ current = self.tell()
289
+ self.seek(pos)
290
+ ret = self.readSByte()
291
+ self.seek(current)
292
+ return ret
293
+
294
+ def readHalf(self, big: bool = False) -> int:
295
+ """Read a nibble, buffering the byte it belongs to.
296
+
297
+ The first call reads and buffers a full byte, the second one consumes
298
+ the remaining nibble.
299
+
300
+ Args:
301
+ big: Whether to read the high nibble of a new byte first.
302
+
303
+ Returns:
304
+ The nibble that was read.
305
+ """
306
+ if self.half is None:
307
+ self.half = self.readByte()
308
+ if big:
309
+ return self.half >> 4
310
+ return self.half & 0x0f
311
+ else:
312
+ ret = self.half >> 4 if not big else self.half & 0x0f
313
+ self.half = None
314
+ return ret
315
+
316
+ def readZeros(self, size: int) -> None:
317
+ """Skip zero bytes, stopping at the first non-zero byte or at size.
318
+
319
+ Args:
320
+ size: Position to stop at.
321
+ """
322
+ while self.tell() < size:
323
+ byte = self.readByte()
324
+ if byte != 0x00:
325
+ self.seek(-1, 1)
326
+ break
327
+
328
+ def readBytes(self, n: int, upper: bool = False) -> str:
329
+ """Read n bytes and format them as space-separated hex values.
330
+
331
+ Args:
332
+ n: Number of bytes to read.
333
+ upper: Whether to use uppercase hex digits.
334
+
335
+ Returns:
336
+ The formatted string, with a trailing space.
337
+ """
338
+ ret = ""
339
+ for i in range(n):
340
+ ret += toHex(self.readByte(), upper) + " "
341
+ return ret
342
+
343
+ def readString(self, length: int) -> str:
344
+ """Read a fixed-length string, one character per byte.
345
+
346
+ Null bytes are skipped, while the 0x82 and 0x86 control characters
347
+ are replaced with a space.
348
+
349
+ Args:
350
+ length: Number of bytes to read.
351
+
352
+ Returns:
353
+ The string that was read.
354
+ """
355
+ ret = ""
356
+ for _ in range(length):
357
+ byte = self.readByte()
358
+ # These control characters can be found in texture names, replace them with a space
359
+ if byte == 0x82 or byte == 0x86:
360
+ byte = 0x20
361
+ if byte != 0:
362
+ ret += chr(byte)
363
+ return ret
364
+
365
+ def readStringAt(self, pos: int, length: int) -> str:
366
+ """Read a fixed-length string at pos, restoring the current position."""
367
+ current = self.tell()
368
+ self.seek(pos)
369
+ ret = self.readString(length)
370
+ self.seek(current)
371
+ return ret
372
+
373
+ def readNullString(self) -> str:
374
+ """Read a null-terminated string, one character per byte."""
375
+ ret = ""
376
+ while True:
377
+ byte = self.readByte()
378
+ if byte == 0:
379
+ break
380
+ else:
381
+ ret += chr(byte)
382
+ return ret
383
+
384
+ def readNullStringAt(self, pos: int) -> str:
385
+ """Read a null-terminated string at pos, restoring the current position."""
386
+ current = self.tell()
387
+ self.seek(pos)
388
+ ret = self.readNullString()
389
+ self.seek(current)
390
+ return ret
391
+
392
+ def readEncodedString(self, encoding: str = "utf-8") -> str:
393
+ """Read a null-terminated string decoded with the given encoding.
394
+
395
+ Args:
396
+ encoding: Encoding to decode the string with.
397
+
398
+ Returns:
399
+ The decoded string.
400
+ """
401
+ num = 0
402
+ pos = self.tell()
403
+ while True:
404
+ byte = self.readByte()
405
+ if byte == 0:
406
+ break
407
+ else:
408
+ num += 1
409
+ self.seek(pos)
410
+ ret = self.read(num).decode(encoding)
411
+ self.readByte()
412
+ return ret
413
+
414
+ def readEncodedStringAt(self, pos: int, encoding: str = "utf-8") -> str:
415
+ """Read a null-terminated encoded string at pos, restoring the current position."""
416
+ current = self.tell()
417
+ self.seek(pos)
418
+ ret = self.readEncodedString(encoding)
419
+ self.seek(current)
420
+ return ret
421
+
422
+ def writeLong(self, num: int) -> None:
423
+ """Write num as a signed 64-bit integer."""
424
+ self.f.write(struct.pack(self.endian + "q", num))
425
+
426
+ def writeLongAt(self, pos: int, num: int) -> None:
427
+ """Write num as a signed 64-bit integer at pos, restoring the current position."""
428
+ current = self.tell()
429
+ self.seek(pos)
430
+ self.writeLong(num)
431
+ self.seek(current)
432
+
433
+ def writeULong(self, num: int) -> None:
434
+ """Write num as an unsigned 64-bit integer."""
435
+ self.f.write(struct.pack(self.endian + "Q", num))
436
+
437
+ def writeULongAt(self, pos: int, num: int) -> None:
438
+ """Write num as an unsigned 64-bit integer at pos, restoring the current position."""
439
+ current = self.tell()
440
+ self.seek(pos)
441
+ self.writeULong(num)
442
+ self.seek(current)
443
+
444
+ def writeInt(self, num: int) -> None:
445
+ """Write num as a signed 32-bit integer."""
446
+ self.f.write(struct.pack(self.endian + "i", num))
447
+
448
+ def writeIntAt(self, pos: int, num: int) -> None:
449
+ """Write num as a signed 32-bit integer at pos, restoring the current position."""
450
+ current = self.tell()
451
+ self.seek(pos)
452
+ self.writeInt(num)
453
+ self.seek(current)
454
+
455
+ def writeUInt(self, num: int) -> None:
456
+ """Write num as an unsigned 32-bit integer."""
457
+ self.f.write(struct.pack(self.endian + "I", num))
458
+
459
+ def writeUIntAt(self, pos: int, num: int) -> None:
460
+ """Write num as an unsigned 32-bit integer at pos, restoring the current position."""
461
+ current = self.tell()
462
+ self.seek(pos)
463
+ self.writeUInt(num)
464
+ self.seek(current)
465
+
466
+ def writeFloat(self, num: float) -> None:
467
+ """Write num as a 32-bit float."""
468
+ self.f.write(struct.pack(self.endian + "f", num))
469
+
470
+ def writeFloatAt(self, pos: int, num: float) -> None:
471
+ """Write num as a 32-bit float at pos, restoring the current position."""
472
+ current = self.tell()
473
+ self.seek(pos)
474
+ self.writeFloat(num)
475
+ self.seek(current)
476
+
477
+ def writeDouble(self, num: float) -> None:
478
+ """Write num as a 64-bit float."""
479
+ self.f.write(struct.pack(self.endian + "d", num))
480
+
481
+ def writeDoubleAt(self, pos: int, num: float) -> None:
482
+ """Write num as a 64-bit float at pos, restoring the current position."""
483
+ current = self.tell()
484
+ self.seek(pos)
485
+ self.writeDouble(num)
486
+ self.seek(current)
487
+
488
+ def writeShort(self, num: int) -> None:
489
+ """Write num as a signed 16-bit integer."""
490
+ self.f.write(struct.pack(self.endian + "h", num))
491
+
492
+ def writeShortAt(self, pos: int, num: int) -> None:
493
+ """Write num as a signed 16-bit integer at pos, restoring the current position."""
494
+ current = self.tell()
495
+ self.seek(pos)
496
+ self.writeShort(num)
497
+ self.seek(current)
498
+
499
+ def writeUShort(self, num: int) -> None:
500
+ """Write num as an unsigned 16-bit integer."""
501
+ self.f.write(struct.pack(self.endian + "H", num))
502
+
503
+ def writeUShortAt(self, pos: int, num: int) -> None:
504
+ """Write num as an unsigned 16-bit integer at pos, restoring the current position."""
505
+ current = self.tell()
506
+ self.seek(pos)
507
+ self.writeUShort(num)
508
+ self.seek(current)
509
+
510
+ def writeByte(self, num: int) -> None:
511
+ """Write num as an unsigned 8-bit integer."""
512
+ self.f.write(struct.pack("B", num))
513
+
514
+ def writeByteAt(self, pos: int, num: int) -> None:
515
+ """Write num as an unsigned 8-bit integer at pos, restoring the current position."""
516
+ current = self.tell()
517
+ self.seek(pos)
518
+ self.writeByte(num)
519
+ self.seek(current)
520
+
521
+ def writeSByte(self, num: int) -> None:
522
+ """Write num as a signed 8-bit integer."""
523
+ self.f.write(struct.pack("b", num))
524
+
525
+ def writeSByteAt(self, pos: int, num: int) -> None:
526
+ """Write num as a signed 8-bit integer at pos, restoring the current position."""
527
+ current = self.tell()
528
+ self.seek(pos)
529
+ self.writeSByte(num)
530
+ self.seek(current)
531
+
532
+ def writeHalf(self, num: int, little: bool = True) -> None:
533
+ """Write a nibble, buffering it until a full byte can be written.
534
+
535
+ Args:
536
+ num: Nibble to write.
537
+ little: Whether the first nibble of each byte is the low one.
538
+ """
539
+ if self.half is None:
540
+ self.half = num
541
+ else:
542
+ if little:
543
+ self.writeByte((num << 4) | self.half)
544
+ else:
545
+ self.writeByte((self.half << 4) | num)
546
+ self.half = None
547
+
548
+ def writeString(self, str: str) -> None:
549
+ """Write a string encoded as ASCII.
550
+
551
+ Args:
552
+ str: String to write.
553
+ """
554
+ self.f.write(str.encode("ascii"))
555
+
556
+ def writeZero(self, num: int) -> None:
557
+ """Write num zero bytes.
558
+
559
+ Args:
560
+ num: Number of bytes to write.
561
+ """
562
+ self.writeBytes(0x0, num)
563
+
564
+ def writeBytes(self, byte: int, num: int) -> None:
565
+ """Write num copies of a byte value.
566
+
567
+ Args:
568
+ byte: Byte value to write.
569
+ num: Number of copies to write.
570
+ """
571
+ if num > 0:
572
+ for i in range(num):
573
+ self.writeByte(byte)
574
+
575
+ def truncate(self) -> None:
576
+ """Truncate the underlying file at the current position."""
577
+ self.f.truncate()
578
+
579
+
580
+ # CLI/GUI
581
+ if hasClick:
582
+ appname = ""
583
+ appversion = ""
584
+ datafolder = ""
585
+ filecheck = ""
586
+ crc = -1
587
+
588
+
589
+ def setupTool(_appname: str = "", _appversion: str = "", _datafolder: str = "", _filecheck: str = "", _crc: int = -1) -> None:
590
+ """Set up and run the command-line interface of a tool.
591
+
592
+ Args:
593
+ _appname: Name of the tool, shown at startup.
594
+ _appversion: Version of the tool, shown at startup.
595
+ _datafolder: Folder with the tool data files, created if missing.
596
+ _filecheck: Path of a file required to run, usually the rom.
597
+ _crc: Expected CRC32 checksum of the _filecheck file, or -1 to skip the check.
598
+ """
599
+ global appname, appversion, datafolder, filecheck, crc
600
+ appname = _appname
601
+ appversion = _appversion
602
+ datafolder = _datafolder
603
+ filecheck = _filecheck
604
+ crc = _crc
605
+ cli()
606
+
607
+
608
+ @click.group(invoke_without_command=True)
609
+ @click.option("--log", is_flag=True, default=False)
610
+ @click.option("--gui", is_flag=True, default=False)
611
+ @click.pass_context
612
+ def cli(ctx, log, gui):
613
+ setupFileLogging(log)
614
+ if ctx.invoked_subcommand is None:
615
+ multi = typing.cast(click.MultiCommand, ctx.command)
616
+ ctx.invoke(multi.get_command(ctx, "main"), gui=gui)
617
+ else:
618
+ if not runStartup():
619
+ quit()
620
+
621
+
622
+ @cli.command(hidden=True)
623
+ @click.option("--gui", is_flag=True, default=False)
624
+ def main(gui):
625
+ if not gui:
626
+ runCLI()
627
+ else:
628
+ runGUI()
629
+
630
+
631
+ def runStartup(skipcrc: bool = False) -> bool:
632
+ """Log version information and check that the required file is present.
633
+
634
+ Args:
635
+ skipcrc: Whether to skip the CRC32 check.
636
+
637
+ Returns:
638
+ False if the required file is missing, True otherwise.
639
+ """
640
+ if datafolder != "" and not os.path.isdir(datafolder):
641
+ makeFolder(datafolder)
642
+ if appname != "":
643
+ logMessage(appname + " version " + appversion)
644
+ logMessage("Python", sys.version)
645
+ from . import __version__
646
+ logMessage("hacktools version", __version__)
647
+ if filecheck != "" and not os.path.isfile(filecheck):
648
+ logError(filecheck, "file not found.")
649
+ return False
650
+ if crc >= 0 and not skipcrc and not os.path.isfile(".skipcrc"):
651
+ checkcrc = crcFile(filecheck)
652
+ if crc != checkcrc:
653
+ logMessage("Checksum mismatch for", filecheck, "(" + toHex(checkcrc) + ", expected", toHex(crc) + ")")
654
+ logMessage("The tool might still work but you should run it on a good dump of the game.")
655
+ return True
656
+
657
+
658
+ def runCLI() -> None:
659
+ """Run the tool as an interactive command prompt."""
660
+ if not runStartup():
661
+ quit()
662
+ if len(sys.argv) > 1:
663
+ cli()
664
+ return
665
+ with click.Context(cli) as ctx:
666
+ click.echo(cli.get_help(ctx))
667
+ click.echo("")
668
+ while True:
669
+ cmd = click.prompt("Type a command").strip()
670
+ cmdlow = cmd.lower()
671
+ if cmdlow == "exit" or cmdlow == "quit" or cmdlow == "q":
672
+ break
673
+ sys.argv = shlex.split(sys.argv[0] + " " + cmd)
674
+ cli(standalone_mode=False)
675
+
676
+
677
+ def runGUI() -> None:
678
+ """Run the tool with the GUI frontend."""
679
+ global hasGUI
680
+ hasGUI = True
681
+ from .gui import GUIApp
682
+ guiapp = GUIApp()
683
+ guiapp.initialize(cli, appname, appversion, datafolder)
684
+ guiapp.mainloop()
685
+
686
+
687
+ # Logging
688
+ class FileFilter(logging.Filter):
689
+ """Logging filter that hides progress messages starting with "prg-"."""
690
+ def filter(self, record):
691
+ return not record.getMessage().startswith("prg-")
692
+
693
+
694
+ def setupFileLogging(log: bool) -> None:
695
+ """Set up logging to the tool.log file.
696
+
697
+ Args:
698
+ log: Whether to also log debug messages.
699
+ """
700
+ logging.getLogger("PIL.PngImagePlugin").setLevel(logging.CRITICAL + 1)
701
+ filehandler = logging.FileHandler(filename="tool.log", encoding="utf-8", mode="w")
702
+ filehandler.addFilter(FileFilter())
703
+ logging.basicConfig(handlers=[filehandler], format="[%(levelname)s] %(message)s", level=logging.DEBUG if log else logging.INFO)
704
+
705
+
706
+ def logMessage(*messages) -> None:
707
+ """Log a message at info level, also shown on the console.
708
+
709
+ Args:
710
+ *messages: Values joined with a space.
711
+ """
712
+ message = " ".join(str(x) for x in messages)
713
+ logging.info(message)
714
+ if hasTqdm and sys.stdout is not None:
715
+ tqdm.write(message)
716
+
717
+
718
+ def logDebug(*messages) -> None:
719
+ """Log a message at debug level, only written to the log file.
720
+
721
+ Args:
722
+ *messages: Values joined with a space.
723
+ """
724
+ message = " ".join(str(x) for x in messages)
725
+ logging.debug(message)
726
+
727
+
728
+ def logWarning(*messages) -> None:
729
+ """Log a warning message, only written to the log file.
730
+
731
+ Args:
732
+ *messages: Values joined with a space.
733
+ """
734
+ message = " ".join(str(x) for x in messages)
735
+ logging.debug("[WARNING] " + message)
736
+
737
+
738
+ def logError(*messages) -> None:
739
+ """Log a message at error level, also shown on the console.
740
+
741
+ Args:
742
+ *messages: Values joined with a space.
743
+ """
744
+ message = " ".join(str(x) for x in messages)
745
+ logging.error(message)
746
+ if hasTqdm and sys.stdout is not None:
747
+ tqdm.write("[ERROR] " + message)
748
+
749
+
750
+ def varsHex(o) -> str:
751
+ """Format the attributes of an object like vars, but with ints as hex.
752
+
753
+ Args:
754
+ o: Object to format.
755
+
756
+ Returns:
757
+ A string with the int, str and list attributes of the object.
758
+ """
759
+ ret = []
760
+ for k in o.__dict__.keys():
761
+ v = o.__dict__.__getitem__(k)
762
+ if type(v) is int:
763
+ ret.append("'" + k + "': " + toHex(v))
764
+ elif type(v) is str:
765
+ ret.append("'" + k + "': '" + v + "'")
766
+ elif type(v) is list:
767
+ ret.append("'" + k + "': " + str(v) + "")
768
+ return ", ".join(ret)
769
+
770
+
771
+ def showProgress(iterable):
772
+ """Wrap an iterable with a progress bar, if tqdm is available.
773
+
774
+ Args:
775
+ iterable: Iterable to wrap.
776
+
777
+ Returns:
778
+ The wrapped iterable, or the original one if tqdm is not available.
779
+ """
780
+ if hasTqdm:
781
+ if hasGUI:
782
+ from .gui import tqdm_gui
783
+ return tqdm_gui(iterable=iterable)
784
+ else:
785
+ return tqdm(iterable=iterable)
786
+ return iterable
787
+
788
+
789
+ # Strings
790
+ def toHex(byte: int, upper: bool = False) -> str:
791
+ """Format a number as hex, without the 0x prefix and padded to two digits.
792
+
793
+ Args:
794
+ byte: Number to format.
795
+ upper: Whether to use uppercase hex digits.
796
+
797
+ Returns:
798
+ The formatted string, with a minus sign in front if negative.
799
+ """
800
+ hexstr = hex(byte)[2:]
801
+ if not upper:
802
+ hexstr = hexstr.lower()
803
+ else:
804
+ hexstr = hexstr.upper()
805
+ if len(hexstr) == 1:
806
+ return "0" + hexstr
807
+ if hexstr[0] == "x":
808
+ return "-" + hexstr[1:]
809
+ return hexstr
810
+
811
+
812
+ def isAscii(s: str) -> bool:
813
+ """Check if a string only contains printable ASCII characters.
814
+
815
+ Args:
816
+ s: String to check.
817
+
818
+ Returns:
819
+ True if no character is a control code or above ASCII 127.
820
+ """
821
+ for i in range(len(s)):
822
+ if ord(s[i]) >= 128 or ord(s[i]) < 0x20:
823
+ return False
824
+ return True
825
+
826
+
827
+ def codeToChar(code: int, encoding: str = "shift_jis", little: bool = True) -> str:
828
+ """Convert a character code to a string.
829
+
830
+ Args:
831
+ code: Character code, ASCII if below 128, 2-byte encoded otherwise.
832
+ encoding: Encoding used for 2-byte codes.
833
+ little: Byte order of 2-byte codes.
834
+
835
+ Returns:
836
+ The decoded character, or an empty string if it can't be decoded.
837
+ """
838
+ try:
839
+ if code < 128:
840
+ return struct.pack("B", code).decode("ascii")
841
+ return struct.pack(("<" if little else ">") + "H", code).decode(encoding)
842
+ except UnicodeDecodeError:
843
+ return ""
844
+
845
+
846
+ def loadTable(tablefile: str) -> None:
847
+ """Load a replacement table file into the global table dictionary.
848
+
849
+ Args:
850
+ tablefile: Path of the table file, with one key=value pair per line.
851
+ """
852
+ if os.path.isfile(tablefile):
853
+ with open(tablefile, "r", encoding="utf-8", newline="") as ft:
854
+ for line in ft:
855
+ line = line.strip("\r\n")
856
+ if line.find("=") > 0:
857
+ linesplit = line.split("=", 1)
858
+ table[linesplit[0]] = linesplit[1]
859
+
860
+
861
+ def shiftPointer(pointer: int, pointerdiff: dict[int, int]) -> int:
862
+ """Shift a pointer by the length differences that apply before it.
863
+
864
+ Args:
865
+ pointer: Original pointer value.
866
+ pointerdiff: Dictionary of position -> length difference.
867
+
868
+ Returns:
869
+ The pointer shifted by all the differences at earlier positions.
870
+ """
871
+ newpointer = pointer
872
+ for k, v in pointerdiff.items():
873
+ if k < pointer:
874
+ newpointer += v
875
+ # if newpointer != pointer:
876
+ # logDebug("Shifted pointer", toHex(pointer), "to", toHex(newpointer))
877
+ return newpointer
878
+
879
+
880
+ def checkShiftJIS(first: int, second: int) -> bool:
881
+ """Check if two bytes form a valid 2-byte Shift-JIS character.
882
+
883
+ Args:
884
+ first: First byte.
885
+ second: Second byte.
886
+
887
+ Returns:
888
+ True if the bytes are a valid Shift-JIS sequence.
889
+ """
890
+ # Based on https://www.lemoda.net/c/detect-shift-jis/
891
+ status = False
892
+ if (first >= 0x81 and first <= 0x84) or (first >= 0x87 and first <= 0x9f):
893
+ if second >= 0x40 and second <= 0xfc:
894
+ status = True
895
+ elif first >= 0xe0 and first <= 0xef:
896
+ if second >= 0x40 and second <= 0xfc:
897
+ status = True
898
+ return status
899
+
900
+
901
+ def openSection(file: str, filestart: int = 1, fileend: int = 10):
902
+ """Open a text section file, concatenating numbered parts if split.
903
+
904
+ Args:
905
+ file: Path of the file, with a {} placeholder for the part number.
906
+ filestart: First part number to check.
907
+ fileend: Last part number to check.
908
+
909
+ Returns:
910
+ A file-like object with the file contents, or None if not found.
911
+ """
912
+ if not os.path.isfile(file.format("")):
913
+ if os.path.isfile(file.format(str(filestart))):
914
+ section = StringIO()
915
+ for i in range(filestart, fileend + 1):
916
+ sectionfile = file.format(str(i))
917
+ if os.path.isfile(sectionfile):
918
+ with open(sectionfile, "r", encoding="utf-8", newline="") as f:
919
+ section.write(f.read())
920
+ else:
921
+ break
922
+ return section
923
+ else:
924
+ return None
925
+ else:
926
+ return open(file.format(""), "r", encoding="utf-8", newline="")
927
+
928
+
929
+ def getSectionNames(f) -> list[str]:
930
+ """Get the names of the !FILE: sections in an open section file.
931
+
932
+ Args:
933
+ f: File-like object to read from.
934
+
935
+ Returns:
936
+ The list of section names.
937
+ """
938
+ ret = []
939
+ try:
940
+ f.seek(0)
941
+ for line in f:
942
+ line = line.rstrip("\r\n").replace("\ufeff", "")
943
+ if line.startswith("!FILE:"):
944
+ ret.append(line[6:].split("#")[0])
945
+ except UnicodeDecodeError:
946
+ return ret
947
+ return ret
948
+
949
+
950
+ def getSection(f, title: str, comment: str = "#", fixchars: list = [], justone: bool = True, inorder: bool = False):
951
+ """Read the entries of a !FILE: section from an open section file.
952
+
953
+ Args:
954
+ f: File-like object to read from.
955
+ title: Name of the section, or "" to read from the start.
956
+ comment: Comment marker, contents after it are ignored.
957
+ fixchars: List of (old, new) character replacements to apply.
958
+ justone: Whether to stop at the next section, instead of merging all
959
+ the sections with the same name.
960
+ inorder: Whether to return entries in order instead of a dictionary.
961
+
962
+ Returns:
963
+ A dictionary of string -> list of translations, or a list of
964
+ {"name", "value"} entries if inorder is True.
965
+ """
966
+ ret: dict[str, list[str]] | list[dict[str, str]] = {} if not inorder else []
967
+ found = title == ""
968
+ try:
969
+ f.seek(0)
970
+ for line in f:
971
+ line = line.rstrip("\r\n").replace("\ufeff", "")
972
+ if found and line.startswith("!FILE:"):
973
+ if not justone:
974
+ found = False
975
+ else:
976
+ break
977
+ if not found and line.startswith("!FILE:" + title):
978
+ found = True
979
+ elif found and line.startswith(comment) and inorder:
980
+ ret.append({"name": line, "value": ""})
981
+ elif found and line.find("=") > 0:
982
+ split = line.split("=", 1)
983
+ split[1] = split[1].split(comment)[0]
984
+ if split[0] not in ret and not inorder:
985
+ ret[split[0]] = []
986
+ for fixchar in fixchars:
987
+ split[1] = split[1].replace(fixchar[0], fixchar[1])
988
+ if inorder:
989
+ ret.append({"name": split[0], "value": split[1]})
990
+ else:
991
+ ret[split[0]].append(split[1])
992
+ except UnicodeDecodeError:
993
+ return ret
994
+ return ret
995
+
996
+
997
+ def getSections(file: str, comment: str = "#", fixchars: list = [], inorder: bool = False) -> dict:
998
+ """Read all the sections from a section file.
999
+
1000
+ Args:
1001
+ file: Path of the section file.
1002
+ comment: Comment marker, contents after it are ignored.
1003
+ fixchars: List of (old, new) character replacements to apply.
1004
+ inorder: Whether sections contain ordered entries, see :func:`getSection`.
1005
+
1006
+ Returns:
1007
+ A dictionary of section name -> section entries.
1008
+ """
1009
+ sections = {}
1010
+ with open(file, "r", encoding="utf-8", newline="") as wsb:
1011
+ files = getSectionNames(wsb)
1012
+ for file in files:
1013
+ sections[file] = getSection(wsb, file, comment, fixchars, inorder=inorder)
1014
+ return sections
1015
+
1016
+
1017
+ def getSectionPercentage(section: dict, chartot: int = 0, transtot: int = 0) -> tuple[int, int]:
1018
+ """Count the total and translated characters in a section.
1019
+
1020
+ Args:
1021
+ section: Section dictionary returned by :func:`getSection`.
1022
+ chartot: Character count to add to.
1023
+ transtot: Translated character count to add to.
1024
+
1025
+ Returns:
1026
+ A tuple (chartot, transtot) with the updated counts.
1027
+ """
1028
+ for s in section.keys():
1029
+ strlen = len(s)
1030
+ for s2 in section[s]:
1031
+ chartot += strlen
1032
+ if s2 != "":
1033
+ transtot += strlen
1034
+ return chartot, transtot
1035
+
1036
+
1037
+ def mergeSections(file1: str, file2: str, output: str, comment: str = "#", fixchars: list = []) -> None:
1038
+ """Merge two section files, filling empty translations in file1 from file2.
1039
+
1040
+ Args:
1041
+ file1: Path of the main section file.
1042
+ file2: Path of the section file to take missing translations from.
1043
+ output: Path of the merged output file.
1044
+ comment: Comment marker, contents after it are ignored.
1045
+ fixchars: List of (old, new) character replacements to apply.
1046
+ """
1047
+ sections1 = getSections(file1, comment, fixchars, inorder=True)
1048
+ sections2 = getSections(file2, comment, fixchars)
1049
+ with open(output, "w", encoding="utf-8", newline="") as out:
1050
+ for section in sections1.keys():
1051
+ out.write("!FILE:" + section + "\n")
1052
+ for v in sections1[section]:
1053
+ s = v["name"]
1054
+ if s.startswith(comment):
1055
+ out.write(s + "\n")
1056
+ continue
1057
+ sectionstr = v["value"]
1058
+ if sectionstr == "":
1059
+ for section2 in sections2.keys():
1060
+ if s in sections2[section2] and sections2[section2][s][0] != "":
1061
+ sectionstr = sections2[section2][s][0]
1062
+ break
1063
+ out.write(s + "=" + sectionstr + "\n")
1064
+
1065
+
1066
+ class TranslationFile:
1067
+ """Translation file in the XLIFF format used by Weblate.
1068
+
1069
+ Args:
1070
+ path: Path of the xliff file to load, or "" to create an empty one.
1071
+
1072
+ Attributes:
1073
+ files: Dictionary of file name -> XML file element.
1074
+ lookup: Dictionary of source text -> translation, filled by :meth:`preloadLookup`.
1075
+ offlookup: Dictionary of offset -> source text, filled by :meth:`preloadLookup`.
1076
+ offsets: Dictionary of file name -> list of offsets, filled by :meth:`preloadOffsets`.
1077
+ chartot: Total character count, filled by :meth:`preloadLookup`.
1078
+ transtot: Translated character count, filled by :meth:`preloadLookup`.
1079
+ root: Root XML element.
1080
+ """
1081
+ def __init__(self, path: str = ""):
1082
+ self.files = {}
1083
+ self.lookup: dict[str, str] = {}
1084
+ self.chartot = 0
1085
+ self.transtot = 0
1086
+ if path == "":
1087
+ self.root = ET.Element("xliff")
1088
+ self.root.set("version", "1.2")
1089
+ self.root.set("xmlns", "urn:oasis:names:tc:xliff:document:1.2")
1090
+ else:
1091
+ ET.register_namespace("", "urn:oasis:names:tc:xliff:document:1.2")
1092
+ tree = ET.parse(path)
1093
+ self.root = tree.getroot()
1094
+ for file in self.root:
1095
+ self.files[file.attrib["original"]] = file
1096
+
1097
+ def mergeSection(self, path: str, filename: str = "", section: str = "", comments: str = "#", fixchars: list = []) -> None:
1098
+ """Merge the translations from a section file into this file.
1099
+
1100
+ Args:
1101
+ path: Path of the section file.
1102
+ filename: Only merge into this file entry, or "" for all of them.
1103
+ section: Name of the section to read, or "" to read from the start.
1104
+ comments: Comment marker, contents after it are ignored.
1105
+ fixchars: List of (old, new) character replacements to apply.
1106
+ """
1107
+ with open(path, "r", encoding="utf-8", newline="") as bin:
1108
+ mergesection = getSection(bin, section, comments, fixchars=fixchars, justone=False)
1109
+ # Check the merge section
1110
+ for file in self.root:
1111
+ if filename != "" and file.attrib["original"] != filename:
1112
+ continue
1113
+ for unit in file[0]:
1114
+ check = unit[0].text
1115
+ if check in mergesection and mergesection[check][0] != "":
1116
+ newcheck = mergesection[check][0]
1117
+ if len(mergesection[check]) > 1:
1118
+ mergesection[check].pop(0)
1119
+ unit[1].text = newcheck
1120
+ unit[1].set("state", "translated")
1121
+ unit.set("approved", "no")
1122
+
1123
+ def addEntry(self, text: str, filename: str, offset: int, translation: str = "", comment: str = "") -> None:
1124
+ """Add a translation unit to a file, creating the file entry if needed.
1125
+
1126
+ Args:
1127
+ text: Source text.
1128
+ filename: Name of the file entry.
1129
+ offset: Offset of the string, used as the unit ID.
1130
+ translation: Translated text, if any.
1131
+ comment: Note to attach to the unit, if any.
1132
+ """
1133
+ # Check if we need to add a new file
1134
+ if filename not in self.files:
1135
+ file = ET.SubElement(self.root, "file", {"xml:space": "preserve"})
1136
+ file.set("original", filename)
1137
+ file.set("source-language", "ja")
1138
+ file.set("target-language", "en")
1139
+ ET.SubElement(file, "body")
1140
+ self.files[filename] = file
1141
+ else:
1142
+ file = self.files[filename]
1143
+ # Add the new entry
1144
+ unit = ET.SubElement(file[0], "trans-unit", {"id": str(offset), "xml:space": "preserve"})
1145
+ source = ET.SubElement(unit, "source")
1146
+ source.text = text
1147
+ target = ET.SubElement(unit, "target")
1148
+ if translation != "":
1149
+ target.text = translation
1150
+ if comment != "":
1151
+ note = ET.SubElement(unit, "note")
1152
+ note.text = comment
1153
+
1154
+ def preloadLookup(self, comments: str = "#") -> None:
1155
+ """Fill the lookup dictionaries and the progress counts.
1156
+
1157
+ Args:
1158
+ comments: Comment marker, stripped from the translations.
1159
+ """
1160
+ self.lookup = {}
1161
+ self.offlookup = {}
1162
+ self.chartot = 0
1163
+ self.transtot = 0
1164
+ for file in self.files:
1165
+ for unit in self.files[file][0]:
1166
+ self.offlookup[int(unit.attrib["id"])] = unit[0].text
1167
+ self.chartot += len(unit[0].text)
1168
+ if unit[1].text is not None and unit[1].text != "":
1169
+ if comments in unit[1].text:
1170
+ unit[1].text = unit[1].text.split(comments)[0]
1171
+ self.lookup[unit[0].text] = unit[1].text
1172
+ self.transtot += len(unit[0].text)
1173
+
1174
+ def preloadOffsets(self) -> None:
1175
+ """Fill the offsets dictionary with the unit IDs of each file."""
1176
+ self.offsets: dict[str, list[int]] = {}
1177
+ for file in self.files:
1178
+ self.offsets[file] = []
1179
+ for unit in self.files[file][0]:
1180
+ self.offsets[file].append(int(unit.attrib["id"]))
1181
+
1182
+ def getEntry(self, text: str, filename: str, offset: int) -> str:
1183
+ """Get the translation for a string.
1184
+
1185
+ Tries to match by offset first, then by source text in the same file
1186
+ entry, then by source text in the whole lookup.
1187
+
1188
+ Args:
1189
+ text: Source text.
1190
+ filename: Name of the file entry.
1191
+ offset: Offset of the string.
1192
+
1193
+ Returns:
1194
+ The translation, or "" if not found or not translated.
1195
+ """
1196
+ stroffset = str(offset)
1197
+ if filename in self.files:
1198
+ # Try to match offset
1199
+ for unit in self.files[filename][0]:
1200
+ if unit.attrib["id"] == stroffset and unit[1].text is not None and unit[1].text != "":
1201
+ return unit[1].text
1202
+ # Try to match string
1203
+ for unit in self.files[filename][0]:
1204
+ if unit[0].text == text and unit[1].text is not None and unit[1].text != "":
1205
+ return unit[1].text
1206
+ # If nothing was found, run a search on the whole file
1207
+ if text in self.lookup:
1208
+ return self.lookup[text]
1209
+ return ""
1210
+
1211
+ def setEntry(self, text: str, filename: str, offset: int, newtext: str) -> None:
1212
+ """Set the translation for a string, matched by offset or source text.
1213
+
1214
+ Args:
1215
+ text: Source text.
1216
+ filename: Name of the file entry.
1217
+ offset: Offset of the string.
1218
+ newtext: Translation to set.
1219
+ """
1220
+ stroffset = str(offset)
1221
+ if filename in self.files:
1222
+ # Try to match offset
1223
+ for unit in self.files[filename][0]:
1224
+ if unit.attrib["id"] == stroffset:
1225
+ unit[1].text = newtext
1226
+ return
1227
+ # Try to match string
1228
+ for unit in self.files[filename][0]:
1229
+ if unit[0].text == text:
1230
+ unit[1].text = newtext
1231
+
1232
+ def hasFile(self, filename: str) -> bool:
1233
+ """Check if a file entry exists.
1234
+
1235
+ Args:
1236
+ filename: Name of the file entry.
1237
+
1238
+ Returns:
1239
+ True if the file entry exists.
1240
+ """
1241
+ return filename in self.files
1242
+
1243
+ def getProgress(self) -> float:
1244
+ """Return the translation percentage, as counted by :meth:`preloadLookup`."""
1245
+ if self.chartot == 0:
1246
+ return 0
1247
+ return (100 * self.transtot) / self.chartot
1248
+
1249
+ def save(self, filename: str, dummy: bool = False) -> None:
1250
+ """Save the translation file, formatted like Weblate would.
1251
+
1252
+ Args:
1253
+ filename: Output path, parent folders are created if missing.
1254
+ dummy: Whether to add a dummy entry, to avoid saving an empty file.
1255
+ """
1256
+ if dummy:
1257
+ self.addEntry("dummy line", "dummy", 0, "dummy translation", "Ignore this")
1258
+ makeFolders(os.path.dirname(filename))
1259
+ self._pretty_print(self.root)
1260
+ xmlstr = ET.tostring(self.root, encoding="unicode", xml_declaration=True)
1261
+ # Change this to match what Weblate does
1262
+ xmlstr = xmlstr.replace("<target />", "<target/>") + "\n"
1263
+ xmlstr = xmlstr.replace("<?xml version='1.0' encoding='utf-8'?>", "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
1264
+ with open(filename, "w", encoding="utf-8", newline="") as f:
1265
+ f.write(xmlstr)
1266
+
1267
+ def _pretty_print(self, current, parent=None, index=-1, depth=0):
1268
+ for i, node in enumerate(current):
1269
+ self._pretty_print(node, current, i, depth + 1)
1270
+ if parent is not None:
1271
+ if index == 0:
1272
+ parent.text = "\n" + (" " * depth)
1273
+ else:
1274
+ parent[index - 1].tail = "\n" + (" " * depth)
1275
+ if index == len(parent) - 1:
1276
+ current.tail = "\n" + (" " * (depth - 1))
1277
+
1278
+
1279
+ class FontGlyph:
1280
+ """Position and width information for a single font glyph.
1281
+
1282
+ Attributes:
1283
+ start: Start offset of the glyph in the font.
1284
+ width: Width of the glyph.
1285
+ length: Horizontal space occupied by the glyph when drawn.
1286
+ char: Character the glyph represents.
1287
+ code: Character code.
1288
+ index: Index of the glyph in the font.
1289
+ """
1290
+ def __init__(self, start, width, length, char="", code=0, index=0):
1291
+ self.start: int = start
1292
+ self.width: int = width
1293
+ self.length: int = length
1294
+ self.char: str = char
1295
+ self.code: int = code
1296
+ self.index: int = index
1297
+
1298
+
1299
+ def wordwrap(text: str, glyphs: dict[str, FontGlyph], width: int, codefunc=None, default: int = 6, linebreak: str = "|", sectionsep: str = ">>", strip: bool = True) -> str:
1300
+ """Word wrap text to fit a pixel width, based on glyph lengths.
1301
+
1302
+ Args:
1303
+ text: Text to wrap.
1304
+ glyphs: Dictionary of character -> :class:`FontGlyph`.
1305
+ width: Maximum line width, in pixels.
1306
+ codefunc: Function called with (text, position) that returns the
1307
+ length of the control code at that position, or 0 if none.
1308
+ default: Length of characters not found in glyphs.
1309
+ linebreak: Line break marker.
1310
+ sectionsep: Separator between sections that are wrapped independently.
1311
+ strip: Whether to strip whitespace from the wrapped lines.
1312
+
1313
+ Returns:
1314
+ The wrapped text, with lines joined by linebreak.
1315
+ """
1316
+ # Based on http://code.activestate.com/recipes/577946-word-wrap-for-proportional-fonts/
1317
+ lines = []
1318
+ if sectionsep != "" and text.count(sectionsep) > 0:
1319
+ lines = text.split(sectionsep)
1320
+ for i in range(len(lines)):
1321
+ lines[i] = wordwrap(lines[i], glyphs, width, codefunc, default, linebreak, sectionsep)
1322
+ return sectionsep.join(lines)
1323
+ if linebreak != "\n":
1324
+ text = text.replace(linebreak, "\n")
1325
+ pattern = re.compile(r"(\s+)")
1326
+ lookup = dict((c, glyphs[c].length if c in glyphs else default) for c in set(text))
1327
+ for line in text.splitlines():
1328
+ tokens = pattern.split(line)
1329
+ tokens.append("")
1330
+ widths = []
1331
+ for token in tokens:
1332
+ tokenwidth = 0
1333
+ i = 0
1334
+ while i < len(token):
1335
+ if codefunc is not None:
1336
+ skip = codefunc(token, i)
1337
+ if skip > 0:
1338
+ i += skip
1339
+ continue
1340
+ tokenwidth += lookup[token[i]]
1341
+ i += 1
1342
+ widths.append(tokenwidth)
1343
+ start, total = 0, 0
1344
+ for index in range(0, len(tokens), 2):
1345
+ if total + widths[index] > width:
1346
+ end = index + 2 if index == start else index
1347
+ lines.append("".join(tokens[start:end]))
1348
+ start, total = end, 0
1349
+ if end == index + 2:
1350
+ continue
1351
+ total += widths[index] + widths[index + 1]
1352
+ if start < len(tokens):
1353
+ lines.append("".join(tokens[start:]))
1354
+ if strip:
1355
+ lines = [line.strip() for line in lines]
1356
+ else:
1357
+ for i in range(len(lines)):
1358
+ if lines[i].startswith(" "):
1359
+ lines[i] = lines[i][1:]
1360
+ if lines[i].endswith(" "):
1361
+ lines[i] = lines[i][:-1]
1362
+ return linebreak.join(lines)
1363
+
1364
+
1365
+ def centerLines(text: str, glyphs: dict[str, FontGlyph], width: int, codefunc=None, default: int = 6, linebreak: str = "|", centercode: str = "<<") -> str:
1366
+ """Center the lines marked with centercode, by prepending spaces.
1367
+
1368
+ Args:
1369
+ text: Text with the lines to center.
1370
+ glyphs: Dictionary of character -> :class:`FontGlyph`.
1371
+ width: Total width to center within, in pixels.
1372
+ codefunc: Function called with (text, position) that returns the
1373
+ length of the control code at that position, or 0 if none.
1374
+ default: Length of characters not found in glyphs.
1375
+ linebreak: Line break marker.
1376
+ centercode: Marker at the start of the lines to center.
1377
+
1378
+ Returns:
1379
+ The text with the marked lines centered.
1380
+ """
1381
+ lines = text.split(linebreak)
1382
+ for i in range(len(lines)):
1383
+ if not lines[i].startswith(centercode):
1384
+ continue
1385
+ lines[i] = lines[i][len(centercode):]
1386
+ length = 0
1387
+ j = 0
1388
+ while j < len(lines[i]):
1389
+ if codefunc is not None:
1390
+ skip = codefunc(lines[i], j)
1391
+ if skip > 0:
1392
+ j += skip
1393
+ continue
1394
+ length += glyphs[lines[i][j]].length if lines[i][j] in glyphs else default
1395
+ j += 1
1396
+ spacelen = glyphs[" "].length
1397
+ spacing = int(((width - length) / 2) / spacelen)
1398
+ lines[i] = (" " * spacing) + lines[i]
1399
+ return linebreak.join(lines)
1400
+
1401
+
1402
+ def readEncodedString(f: Stream, encoding: str = "shift_jis", upper: bool = False) -> str:
1403
+ """Read a null-terminated encoded string from a stream.
1404
+
1405
+ Line feeds are read as "|", and invalid 2-byte sequences followed by
1406
+ 0x01 are kept as UNK(xxyy) markers.
1407
+
1408
+ Args:
1409
+ f: Stream to read from.
1410
+ encoding: Encoding used for 2-byte characters.
1411
+ upper: Whether UNK markers use uppercase hex digits.
1412
+
1413
+ Returns:
1414
+ The decoded string.
1415
+ """
1416
+ sjis = ""
1417
+ while True:
1418
+ b1 = f.readByte()
1419
+ if b1 == 0x0a:
1420
+ sjis += "|"
1421
+ elif b1 == 0x00:
1422
+ break
1423
+ else:
1424
+ b2 = f.readByte()
1425
+ if not checkShiftJIS(b1, b2):
1426
+ if b2 == 0x01:
1427
+ sjis += "UNK(" + toHex(b1, upper) + toHex(b2, upper) + ")"
1428
+ else:
1429
+ f.seek(-1, 1)
1430
+ sjis += chr(b1)
1431
+ else:
1432
+ f.seek(-2, 1)
1433
+ try:
1434
+ sjis += f.read(2).decode(encoding).replace("〜", "~")
1435
+ except UnicodeDecodeError:
1436
+ logError("[ERROR] UnicodeDecodeError")
1437
+ sjis += "[ERROR" + str(f.tell() - 2) + "]"
1438
+ return sjis
1439
+
1440
+
1441
+ def detectEncodedString(f: Stream, encoding: str = "shift_jis", startascii: list = [0x25], startenc: list = [], upper: bool = False) -> str:
1442
+ """Try to read a null-terminated encoded string from a stream.
1443
+
1444
+ Args:
1445
+ f: Stream to read from.
1446
+ encoding: Encoding used for 2-byte characters.
1447
+ startascii: ASCII bytes the string is allowed to start with.
1448
+ startenc: (first, second) byte tuples allowed as unknown start codes.
1449
+ upper: Whether UNK markers use uppercase hex digits.
1450
+
1451
+ Returns:
1452
+ The detected string, or "" if the data doesn't look like one.
1453
+ """
1454
+ ret = ""
1455
+ sjis = 0
1456
+ while True:
1457
+ b1 = f.readByte()
1458
+ if b1 == 0x0a:
1459
+ ret += "|"
1460
+ elif b1 == 0x00:
1461
+ break
1462
+ elif b1 >= 28 and b1 <= 126 and (len(ret) > 0 or b1 in startascii):
1463
+ ret += chr(b1)
1464
+ else:
1465
+ b2 = f.readByte()
1466
+ if checkShiftJIS(b1, b2):
1467
+ f.seek(-2, 1)
1468
+ try:
1469
+ ret += f.read(2).decode(encoding).replace("〜", "~")
1470
+ sjis += 1
1471
+ except UnicodeDecodeError:
1472
+ if ret.count("UNK(") >= 5:
1473
+ return ""
1474
+ ret += "UNK(" + toHex(b1, upper) + toHex(b2, upper) + ")"
1475
+ elif (b1, b2) in startenc or (len(ret) > 1 and ret.count("UNK(") < 5):
1476
+ ret += "UNK(" + toHex(b1, upper) + toHex(b2, upper) + ")"
1477
+ else:
1478
+ return ""
1479
+ return ret
1480
+
1481
+
1482
+ def detectASCIIString(f: Stream, encoding: str = "ascii", startascii: list = []) -> str:
1483
+ """Try to read a null-terminated ASCII string from a stream.
1484
+
1485
+ Args:
1486
+ f: Stream to read from.
1487
+ encoding: Unused, kept for compatibility with :func:`detectEncodedString`.
1488
+ startascii: Unused, kept for compatibility with :func:`detectEncodedString`.
1489
+
1490
+ Returns:
1491
+ The detected string, or "" if a non-ASCII byte is found.
1492
+ """
1493
+ ret = ""
1494
+ while True:
1495
+ b1 = f.readByte()
1496
+ if b1 == 0x0a:
1497
+ ret += "|"
1498
+ elif b1 == 0x00:
1499
+ break
1500
+ elif b1 >= 28 and b1 <= 126:
1501
+ ret += chr(b1)
1502
+ else:
1503
+ return ""
1504
+ return ret
1505
+
1506
+
1507
+ def writeEncodedString(f: Stream, s: str, maxlen: int = 0, encoding: str = "shift_jis", zerobytes: int = 1) -> int:
1508
+ """Write an encoded string to a stream, terminated by zero bytes.
1509
+
1510
+ "|" characters are written as line feeds, and UNK(xxyy) markers as
1511
+ their raw bytes.
1512
+
1513
+ Args:
1514
+ f: Stream to write to.
1515
+ s: String to write.
1516
+ maxlen: Maximum length in bytes, or 0 for no limit.
1517
+ encoding: Encoding used for non-ASCII characters.
1518
+ zerobytes: Number of zero bytes used as terminator.
1519
+
1520
+ Returns:
1521
+ The number of bytes written, or -1 if the string doesn't fit in maxlen.
1522
+ """
1523
+ i = 0
1524
+ x = 0
1525
+ s = s.replace("~", "〜")
1526
+ while x < len(s):
1527
+ c = s[x]
1528
+ if c == "U" and x < len(s) - 4 and s[x:x+4] == "UNK(":
1529
+ if maxlen > 0 and i+2 > maxlen:
1530
+ return -1
1531
+ code = s[x+4] + s[x+5]
1532
+ f.write(bytes.fromhex(code))
1533
+ code = s[x+6] + s[x+7]
1534
+ f.write(bytes.fromhex(code))
1535
+ x += 8
1536
+ i += 2
1537
+ elif c == "|":
1538
+ if maxlen > 0 and i+1 > maxlen:
1539
+ return -1
1540
+ f.writeByte(0x0a)
1541
+ i += 1
1542
+ elif ord(c) < 128:
1543
+ if maxlen > 0 and i+1 > maxlen:
1544
+ return -1
1545
+ f.writeByte(ord(c))
1546
+ i += 1
1547
+ else:
1548
+ if maxlen > 0 and i+2 > maxlen:
1549
+ return -1
1550
+ f.write(c.encode(encoding))
1551
+ i += 2
1552
+ x += 1
1553
+ if zerobytes > 0:
1554
+ if zerobytes > 1:
1555
+ if maxlen > 0 and i+1 > maxlen:
1556
+ return -1
1557
+ i += zerobytes - 1
1558
+ f.writeZero(zerobytes)
1559
+ return i
1560
+
1561
+
1562
+ def extractBinaryStrings(infile: str, binranges: list, func=detectEncodedString, encoding: str = "shift_jis") -> tuple[list[str], list[list[int]]]:
1563
+ """Extract encoded strings from ranges of a binary file.
1564
+
1565
+ Args:
1566
+ infile: Path of the binary file.
1567
+ binranges: List of (start, end) ranges to scan.
1568
+ func: Function used to detect strings, called with (stream, encoding).
1569
+ encoding: Encoding passed to func.
1570
+
1571
+ Returns:
1572
+ A tuple (strings, positions), where positions holds the list of
1573
+ offsets each string was found at.
1574
+ """
1575
+ strings = []
1576
+ positions = []
1577
+ insize = os.path.getsize(infile)
1578
+ with Stream(infile, "rb") as f:
1579
+ for binrange in binranges:
1580
+ f.seek(binrange[0])
1581
+ while f.tell() < binrange[1] and f.tell() < insize - 2:
1582
+ pos = f.tell()
1583
+ check = func(f, encoding)
1584
+ if check != "":
1585
+ if check not in strings:
1586
+ logDebug("Found string", check, "at", toHex(pos))
1587
+ strings.append(check)
1588
+ positions.append([pos])
1589
+ else:
1590
+ positions[strings.index(check)].append(pos)
1591
+ pos = f.tell() - 1
1592
+ f.seek(pos + 1)
1593
+ return strings, positions
1594
+
1595
+
1596
+ class BinaryPointer:
1597
+ """Old and new location of a string relocated by :func:`repackBinaryStrings`.
1598
+
1599
+ Attributes:
1600
+ old: Pointer to the original string location.
1601
+ new: Pointer to the new string location.
1602
+ str: The string the pointer refers to.
1603
+ """
1604
+ def __init__(self, old, new, str):
1605
+ self.old: int = old
1606
+ self.new: int = new
1607
+ self.str: bytes = str
1608
+
1609
+
1610
+ def repackBinaryStrings(section, infile: str, outfile: str, binranges: list, freeranges: list | None = None, readfunc=detectEncodedString, writefunc=writeEncodedString, encoding: str = "shift_jis", pointerstart: int = 0, injectstart: int = 0, fallbackf: Stream | None = None, injectfallback: int = 0, sectionname: str = "bin", preformat=None, postformat=None, pointeralign: int = 1) -> tuple[list[BinaryPointer], list | None]:
1611
+ """Repack translated strings into ranges of a binary file.
1612
+
1613
+ Strings found in binranges are replaced in place when the translation
1614
+ fits. When it doesn't and freeranges is given, the translation is written
1615
+ in one of those ranges instead, pointers to it are searched and updated
1616
+ in the whole file, and the freed space is added to the free ranges pool.
1617
+
1618
+ Args:
1619
+ section: Dictionary of translations, or a :class:`TranslationFile`.
1620
+ infile: Path of the original binary file.
1621
+ outfile: Path of the output binary file, already copied from infile.
1622
+ binranges: List of (start, end) ranges to scan.
1623
+ freeranges: List of (start, end) ranges that can hold relocated
1624
+ strings. A range can have a third element with the pointer start
1625
+ to use for it, or a boolean to use injectstart.
1626
+ readfunc: Function used to detect strings, called with (stream, encoding).
1627
+ writefunc: Function used to write strings, called with
1628
+ (stream, string, maxlen, encoding).
1629
+ encoding: Encoding passed to readfunc and writefunc.
1630
+ pointerstart: Value added to file offsets to form pointers, usually
1631
+ the load address of the binary.
1632
+ injectstart: Pointer start for free ranges marked with a boolean.
1633
+ fallbackf: Stream to write strings to when no free range has room.
1634
+ injectfallback: Pointer start of the fallback stream, 0 to disable it.
1635
+ sectionname: File entry name used when section is a TranslationFile.
1636
+ preformat: Function called on each detected string, returning
1637
+ (string, pre, post) data passed to postformat.
1638
+ postformat: Function called with (translation, pre, post), returning
1639
+ the final string to write.
1640
+ pointeralign: Alignment required for pointer matches, to skip false
1641
+ positives.
1642
+
1643
+ Returns:
1644
+ A tuple (notfound, freeranges) with the list of :class:`BinaryPointer`
1645
+ that weren't found and the updated free ranges, or None if freeranges
1646
+ wasn't given.
1647
+ """
1648
+ insize = os.path.getsize(infile)
1649
+ notfound = []
1650
+ with Stream(infile, "rb") as fi:
1651
+ if freeranges is not None:
1652
+ allbin = fi.read()
1653
+ strpointers: dict[str, int] = {}
1654
+ freeranges = [list(x) for x in freeranges]
1655
+ with Stream(outfile, "r+b") as fo:
1656
+ for binrange in binranges:
1657
+ fi.seek(binrange[0])
1658
+ while fi.tell() < binrange[1] and fi.tell() < insize - 2:
1659
+ pos = fi.tell()
1660
+ check = readfunc(fi, encoding)
1661
+ if check != "":
1662
+ pre = post = ""
1663
+ if preformat is not None:
1664
+ check, pre, post = preformat(check)
1665
+ if isinstance(section, TranslationFile):
1666
+ newsjis = section.getEntry(check, sectionname, pos)
1667
+ else:
1668
+ newsjis = section[check][0] if check in section else ""
1669
+ if newsjis != "":
1670
+ if len(section[check]) > 1:
1671
+ section[check].pop(0)
1672
+ if newsjis != "":
1673
+ if newsjis == "!":
1674
+ newsjis = ""
1675
+ if postformat is not None:
1676
+ newsjis = postformat(newsjis, pre, post)
1677
+ newsjislog = newsjis.encode("ascii", "ignore")
1678
+ logDebug("Replacing string at", toHex(pos), "with", newsjislog)
1679
+ fo.seek(pos)
1680
+ endpos = fi.tell() - 1
1681
+ newlen = writefunc(fo, newsjis, endpos - pos + 1, encoding)
1682
+ fo.seek(-1, 1)
1683
+ if fo.readByte() != 0:
1684
+ fo.writeZero(1)
1685
+ if newlen < 0:
1686
+ if (freeranges is None and injectfallback == 0) or pointerstart == 0:
1687
+ logError("String", newsjislog, "is too long.")
1688
+ else:
1689
+ # Add this to the freeranges
1690
+ freeranges.append([pos, endpos])
1691
+ logDebug("Adding new freerange", toHex(pos), toHex(endpos))
1692
+ range = None
1693
+ rangelen = 0
1694
+ for c in newsjis:
1695
+ rangelen += 1 if ord(c) < 256 else 2
1696
+ for freerange in freeranges:
1697
+ if freerange[1] - freerange[0] > rangelen:
1698
+ range = freerange
1699
+ break
1700
+ if range is None and newsjis not in strpointers and injectfallback == 0:
1701
+ logError("No more room! Skipping", newsjislog, "...")
1702
+ freeranges.pop()
1703
+ else:
1704
+ # Write the string in a new portion of the rom
1705
+ if newsjis in strpointers:
1706
+ newpointer = strpointers[newsjis]
1707
+ elif range is None:
1708
+ logDebug("No room for the string", newsjislog, ", redirecting to fallback")
1709
+ fallbackpos = fallbackf.tell()
1710
+ writefunc(fallbackf, newsjis, 0, encoding)
1711
+ fallbackf.seek(-1, 1)
1712
+ if fallbackf.readByte() != 0:
1713
+ fallbackf.writeZero(1)
1714
+ newpointer = injectfallback + fallbackpos
1715
+ strpointers[newsjis] = newpointer
1716
+ else:
1717
+ logDebug("No room for the string", newsjislog, ", redirecting to", toHex(range[0]))
1718
+ fo.seek(range[0])
1719
+ writefunc(fo, newsjis, 0, encoding)
1720
+ fo.seek(-1, 1)
1721
+ if fo.readByte() != 0:
1722
+ fo.writeZero(1)
1723
+ newpointer = range[0]
1724
+ # For the injected range, add injectstart, otherwise add pointerstart
1725
+ if (len(range) == 3):
1726
+ if isinstance(range[2], bool):
1727
+ newpointer += injectstart
1728
+ else:
1729
+ newpointer += int(range[2])
1730
+ else:
1731
+ newpointer += pointerstart
1732
+ range[0] = fo.tell()
1733
+ strpointers[newsjis] = newpointer
1734
+ # Search and replace the old pointer
1735
+ pointer = pointerstart + pos
1736
+ pointersearch = struct.pack("<I", pointer)
1737
+ index = 0
1738
+ logDebug("Searching for pointer", toHex(pointer))
1739
+ foundone = False
1740
+ while index < len(allbin):
1741
+ index = allbin.find(pointersearch, index)
1742
+ if index < 0:
1743
+ break
1744
+ # Skip matches that aren't aligned: the pattern can collide with
1745
+ # bytes inside code/data, and overwriting those corrupts the binary
1746
+ if pointeralign > 1 and index % pointeralign != 0:
1747
+ index += 1
1748
+ continue
1749
+ foundone = True
1750
+ logDebug("Replaced pointer at", toHex(pointerstart + index), "with", toHex(newpointer))
1751
+ fo.seek(index)
1752
+ fo.writeUInt(newpointer)
1753
+ index += 4
1754
+ if not foundone:
1755
+ logWarning("Pointer", toHex(pointer), "->", toHex(newpointer), "not found for string", newsjislog)
1756
+ # freeranges.pop()
1757
+ notfound.append(BinaryPointer(pointer, newpointer, newsjislog))
1758
+ else:
1759
+ fo.writeZero(endpos - fo.tell())
1760
+ pos = fi.tell() - 1
1761
+ fi.seek(pos + 1)
1762
+ return notfound, freeranges
1763
+
1764
+
1765
+ # Folders
1766
+ def makeFolder(folder: str, clear: bool = True) -> None:
1767
+ """Create a folder, optionally deleting an existing one first.
1768
+
1769
+ Args:
1770
+ folder: Path of the folder to create.
1771
+ clear: Whether to delete the existing folder first.
1772
+ """
1773
+ if clear:
1774
+ clearFolder(folder)
1775
+ os.mkdir(folder)
1776
+
1777
+
1778
+ def clearFolder(folder: str) -> None:
1779
+ """Delete a folder and its contents, clearing read-only flags.
1780
+
1781
+ Args:
1782
+ folder: Path of the folder to delete.
1783
+ """
1784
+ if os.path.isdir(folder):
1785
+ # Ensure files aren't marked as read-only
1786
+ for root, dirs, files in os.walk(folder, topdown=False):
1787
+ for name in files:
1788
+ filename = os.path.join(root, name)
1789
+ os.chmod(filename, stat.S_IWUSR)
1790
+ shutil.rmtree(folder)
1791
+
1792
+
1793
+ def copyFolder(f1: str, f2: str) -> None:
1794
+ """Copy a folder, replacing the destination.
1795
+
1796
+ Args:
1797
+ f1: Path of the source folder.
1798
+ f2: Path of the destination folder.
1799
+ """
1800
+ clearFolder(f2)
1801
+ shutil.copytree(f1, f2)
1802
+
1803
+
1804
+ def mergeFolder(f1: str, f2: str) -> None:
1805
+ """Copy a folder into another, overwriting the files that exist in both.
1806
+
1807
+ Args:
1808
+ f1: Path of the source folder.
1809
+ f2: Path of the destination folder.
1810
+ """
1811
+ shutil.copytree(f1, f2, dirs_exist_ok=True)
1812
+
1813
+
1814
+ def copyFile(f1: str, f2: str) -> None:
1815
+ """Copy a file, replacing the destination.
1816
+
1817
+ Args:
1818
+ f1: Path of the source file.
1819
+ f2: Path of the destination file.
1820
+ """
1821
+ if os.path.isfile(f2):
1822
+ os.remove(f2)
1823
+ shutil.copyfile(f1, f2)
1824
+
1825
+
1826
+ def makeFolders(path: str) -> None:
1827
+ """Create a folder and its parents, ignoring the ones that exist.
1828
+
1829
+ Args:
1830
+ path: Path of the folder to create.
1831
+ """
1832
+ try:
1833
+ os.makedirs(path)
1834
+ except FileExistsError:
1835
+ pass
1836
+
1837
+
1838
+ def getFiles(path: str, extensions: str | list = []) -> list[str]:
1839
+ """List all the files in a folder, recursively.
1840
+
1841
+ Args:
1842
+ path: Path of the folder to scan.
1843
+ extensions: Extension or list of extensions to filter by.
1844
+
1845
+ Returns:
1846
+ The file paths relative to path, sorted and with forward slashes.
1847
+ """
1848
+ if isinstance(extensions, str) and extensions != "":
1849
+ extensions = [extensions]
1850
+ ret = []
1851
+ for (root, dirs, files) in os.walk(path):
1852
+ for file in sorted(files):
1853
+ file = os.path.join(root, file).replace(path, "").replace("\\", "/")
1854
+ if len(extensions) > 0 and os.path.splitext(file)[1] not in extensions:
1855
+ continue
1856
+ ret.append(file)
1857
+ return ret
1858
+
1859
+
1860
+ def getFolders(path: str) -> list[str]:
1861
+ """List all the subfolders in a folder, recursively.
1862
+
1863
+ Args:
1864
+ path: Path of the folder to scan.
1865
+
1866
+ Returns:
1867
+ The folder paths relative to path, sorted and with forward slashes.
1868
+ """
1869
+ ret = []
1870
+ for (root, dirs, files) in os.walk(path):
1871
+ for dir in sorted(dirs):
1872
+ dir = os.path.join(root, dir).replace(path, "").replace("\\", "/")
1873
+ ret.append(dir)
1874
+ return ret
1875
+
1876
+
1877
+ def bundledFile(name: str) -> str:
1878
+ """Get the path of a data file, supporting PyInstaller bundles.
1879
+
1880
+ Args:
1881
+ name: Name of the file.
1882
+
1883
+ Returns:
1884
+ The file name, or its path in the PyInstaller temporary folder.
1885
+ """
1886
+ if os.path.isfile(name):
1887
+ return name
1888
+ try:
1889
+ return os.path.join(sys._MEIPASS, name)
1890
+ except AttributeError:
1891
+ return name
1892
+
1893
+
1894
+ def bundledExecutable(name: str) -> str:
1895
+ """Get the path of an executable, supporting PyInstaller bundles.
1896
+
1897
+ The .exe extension is stripped when not running on Windows.
1898
+
1899
+ Args:
1900
+ name: Name of the executable, with the .exe extension.
1901
+
1902
+ Returns:
1903
+ The executable path, possibly in the PyInstaller temporary folder.
1904
+ """
1905
+ if os.name != "nt":
1906
+ name = name.replace(".exe", "")
1907
+ if os.path.isfile(name):
1908
+ if os.name != "nt":
1909
+ name = "./" + name
1910
+ return name
1911
+ try:
1912
+ return os.path.join(sys._MEIPASS, name)
1913
+ except AttributeError:
1914
+ return name
1915
+
1916
+
1917
+ def execute(cmd: str, show: bool = True) -> None:
1918
+ """Run a command, logging its output.
1919
+
1920
+ Args:
1921
+ cmd: Command line to run.
1922
+ show: Whether to log the output at info level instead of debug.
1923
+ """
1924
+ result = ""
1925
+ try:
1926
+ if os.name != "nt":
1927
+ result = str(subprocess.check_output(shlex.split(cmd)))
1928
+ else:
1929
+ startupinfo = subprocess.STARTUPINFO()
1930
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
1931
+ result = str(subprocess.check_output(cmd, startupinfo=startupinfo))
1932
+ except FileNotFoundError:
1933
+ logError("Command too long:", len(cmd), cmd)
1934
+ if result != "":
1935
+ logError(result)
1936
+ return
1937
+ except subprocess.CalledProcessError:
1938
+ logError("Command error", cmd)
1939
+ if result != "":
1940
+ logError(result)
1941
+ return
1942
+ if result != "":
1943
+ if show:
1944
+ logMessage(result)
1945
+ else:
1946
+ logDebug(result)
1947
+
1948
+
1949
+ def crcFile(f: str) -> int:
1950
+ """Calculate the CRC32 checksum of a file.
1951
+
1952
+ Args:
1953
+ f: Path of the file.
1954
+
1955
+ Returns:
1956
+ The CRC32 checksum.
1957
+ """
1958
+ buffersize = 0x10000
1959
+ crc = 0
1960
+ with open(f, "rb") as crcf:
1961
+ buffer = crcf.read(buffersize)
1962
+ while len(buffer) > 0:
1963
+ crc = zlib.crc32(buffer, crc)
1964
+ buffer = crcf.read(buffersize)
1965
+ return crc & 0xffffffff
1966
+
1967
+
1968
+ def crc16(data: bytes) -> int:
1969
+ """Calculate the CRC16 checksum of some data, in the MODBUS variant.
1970
+
1971
+ Args:
1972
+ data: Data to checksum.
1973
+
1974
+ Returns:
1975
+ The CRC16 checksum.
1976
+ """
1977
+ crc = 0xffff
1978
+ for i in range(len(data)):
1979
+ crc ^= data[i]
1980
+ for j in range(8):
1981
+ carry = crc & 1
1982
+ crc >>= 1
1983
+ if carry:
1984
+ crc ^= 0xa001
1985
+ return crc & 0xffff
1986
+
1987
+
1988
+ def xdeltaPatch(patchfile: str, infile: str, outfile: str) -> None:
1989
+ """Create an xdelta patch, using pyxdelta or calling the xdelta executable externally.
1990
+
1991
+ Args:
1992
+ patchfile: Path of the patch file to create.
1993
+ infile: Path of the original file.
1994
+ outfile: Path of the modified file.
1995
+ """
1996
+ logMessage("Creating xdelta patch", patchfile, "...")
1997
+ try:
1998
+ import pyxdelta
1999
+ pyxdelta.run(infile, outfile, patchfile)
2000
+ except ImportError:
2001
+ xdelta = bundledExecutable("xdelta.exe")
2002
+ if not os.path.isfile(xdelta):
2003
+ logError("xdelta not found")
2004
+ return
2005
+ execute(xdelta + " -f -e -s \"{rom}\" \"{rompatch}\" \"{patch}\"".format(rom=infile, rompatch=outfile, patch=patchfile), False)
2006
+ logMessage("Done!")
2007
+
2008
+
2009
+ def ipsPatch(patchfile: str, infile: str, outfile: str) -> None:
2010
+ """Create an ips patch, using ips_util.
2011
+
2012
+ Args:
2013
+ patchfile: Path of the patch file to create.
2014
+ infile: Path of the original file.
2015
+ outfile: Path of the modified file.
2016
+ """
2017
+ try:
2018
+ from ips_util import Patch
2019
+ except ImportError:
2020
+ logError("ips_util not found")
2021
+ return
2022
+ logMessage("Creating ips patch", patchfile, "...")
2023
+ with Stream(infile, "rb") as f:
2024
+ indata = f.read()
2025
+ with Stream(outfile, "rb") as f:
2026
+ outdata = f.read()
2027
+ result = Patch.create(indata, outdata)
2028
+ with Stream(patchfile, "wb") as f:
2029
+ f.write(result.encode())
2030
+ logMessage("Done!")
2031
+
2032
+
2033
+ def armipsPatch(file: str, defines: dict = {}, labels: dict = {}) -> None:
2034
+ """Apply an armips patch, using pyarmips or calling the armips executable externally.
2035
+
2036
+ Args:
2037
+ file: Path of the asm file.
2038
+ defines: Dictionary of symbol -> value, passed as -equ.
2039
+ labels: Dictionary of label -> value, passed as -definelabel.
2040
+ """
2041
+ logMessage("Applying armips patch ...")
2042
+ try:
2043
+ import pyarmips
2044
+ pyarmips.run(file)
2045
+ except ImportError:
2046
+ armips = bundledExecutable("armips.exe")
2047
+ if not os.path.isfile(armips):
2048
+ logError("armips not found")
2049
+ return
2050
+ params = ""
2051
+ for define in defines:
2052
+ params += " -equ " + define + " " + str(defines[define])
2053
+ for label in labels:
2054
+ params += " -definelabel " + label + " " + str(labels[label])
2055
+ execute(armips + " {binpatch}{params}".format(binpatch=file, params=params), False)
2056
+ logMessage("Done!")
2057
+
2058
+
2059
+ def deltaToFrame(delta, fps: int = 30) -> int:
2060
+ """Convert a time delta to a frame count.
2061
+
2062
+ Args:
2063
+ delta: datetime.timedelta to convert.
2064
+ fps: Frames per second.
2065
+
2066
+ Returns:
2067
+ The number of frames, rounded up.
2068
+ """
2069
+ return int((delta.seconds * fps) + math.ceil(delta.microseconds / (1000 * 1000 / fps)))
2070
+
2071
+
2072
+ # Generic texture
2073
+ def readPalette(p: int) -> tuple:
2074
+ """Convert a 15-bit BGR color to an opaque RGBA tuple.
2075
+
2076
+ Args:
2077
+ p: 15-bit color value.
2078
+
2079
+ Returns:
2080
+ The (r, g, b, 255) tuple.
2081
+ """
2082
+ return (((p >> 0) & 0x1f) << 3, ((p >> 5) & 0x1f) << 3, ((p >> 10) & 0x1f) << 3, 0xff)
2083
+
2084
+
2085
+ def getColorDistance(c1: tuple, c2: tuple, checkalpha: bool = False) -> float:
2086
+ """Calculate the Euclidean distance between two colors.
2087
+
2088
+ Args:
2089
+ c1: First color, as an RGB or RGBA tuple.
2090
+ c2: Second color, as an RGB or RGBA tuple.
2091
+ checkalpha: Whether to include the alpha channel.
2092
+
2093
+ Returns:
2094
+ The distance between the colors.
2095
+ """
2096
+ if len(c1) == 4:
2097
+ (r1, g1, b1, a1) = c1
2098
+ else:
2099
+ (r1, g1, b1) = c1
2100
+ a1 = 255
2101
+ if len(c2) == 4:
2102
+ (r2, g2, b2, a2) = c2
2103
+ else:
2104
+ (r2, g2, b2) = c2
2105
+ a2 = 255
2106
+ sum = (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2
2107
+ if checkalpha:
2108
+ sum += (a1 - a2) ** 2
2109
+ return math.sqrt(sum)
2110
+
2111
+
2112
+ def sumColors(c1: tuple, c2: tuple, a: int = 1, b: int = 1, c: int = 2) -> tuple:
2113
+ """Calculate the weighted sum (c1 * a + c2 * b) / c of two colors.
2114
+
2115
+ Args:
2116
+ c1: First color, as an RGBA tuple. The result keeps its alpha.
2117
+ c2: Second color, as an RGBA tuple.
2118
+ a: Weight of the first color.
2119
+ b: Weight of the second color.
2120
+ c: Divisor of the weighted sum.
2121
+
2122
+ Returns:
2123
+ The resulting RGBA tuple.
2124
+ """
2125
+ (r1, g1, b1, a1) = c1
2126
+ (r2, g2, b2, a2) = c2
2127
+ return ((r1 * a + r2 * b) // c, (g1 * a + g2 * b) // c, (b1 * a + b2 * b) // c, a1)
2128
+
2129
+
2130
+ # https://github.com/marco-calautti/Rainbow/blob/master/Rainbow.ImgLib/ImgLib/Common/ImageUtils.cs
2131
+ cc38 = [
2132
+ 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff
2133
+ ]
2134
+ cc48 = [
2135
+ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff
2136
+ ]
2137
+ cc58 = [
2138
+ 0x00, 0x08, 0x10, 0x19, 0x21, 0x29, 0x31, 0x3a, 0x42, 0x4a, 0x52, 0x5a, 0x63, 0x6b, 0x73, 0x7b,
2139
+ 0x84, 0x8c, 0x94, 0x9c, 0xa5, 0xad, 0xb5, 0xbd, 0xc5, 0xce, 0xd6, 0xde, 0xe6, 0xef, 0xf7, 0xff
2140
+ ]
2141
+
2142
+
2143
+ # https://github.com/marco-calautti/Rainbow/blob/master/Rainbow.ImgLib/ImgLib/Encoding/Implementation/ColorCodecRGB5A3.cs
2144
+ def readRGB5A3(color: int) -> tuple:
2145
+ """Convert an RGB5A3 color to an RGBA tuple.
2146
+
2147
+ If the high bit is set the color is an opaque RGB555, otherwise it has
2148
+ a 3-bit alpha and 4-bit color channels.
2149
+
2150
+ Args:
2151
+ color: 16-bit color value.
2152
+
2153
+ Returns:
2154
+ The (r, g, b, a) tuple.
2155
+ """
2156
+ r, g, b, a = (0, 0, 0, 0)
2157
+ if color & 0x8000 != 0:
2158
+ a = 255
2159
+ r = cc58[(color >> 10) & 0x1f]
2160
+ g = cc58[(color >> 5) & 0x1f]
2161
+ b = cc58[(color) & 0x1f]
2162
+ else:
2163
+ a = cc38[(color >> 12) & 0x7]
2164
+ r = cc48[(color >> 8) & 0xf]
2165
+ g = cc48[(color >> 4) & 0xf]
2166
+ b = cc48[(color) & 0xf]
2167
+ return (r, g, b, a)
2168
+
2169
+
2170
+ def readRGB5A1(color: int) -> tuple:
2171
+ """Convert an RGB5A1 color to an RGBA tuple.
2172
+
2173
+ Args:
2174
+ color: 16-bit color value.
2175
+
2176
+ Returns:
2177
+ The (r, g, b, a) tuple, with a either 0 or 255.
2178
+ """
2179
+ r = cc58[color & 0x1f]
2180
+ g = cc58[color >> 5 & 0x1f]
2181
+ b = cc58[color >> 10 & 0x1f]
2182
+ a = 0 if (color >> 15 & 0x1) == 0 else 255
2183
+ return (r, g, b, a)
2184
+
2185
+
2186
+ def getPaletteIndex(palette: list, color: tuple, fixtransp: bool = False, starti: int = 0, palsize: int = -1, checkalpha: bool = False, zerotransp: bool = True, backwards: bool = False, logcolor: bool = False) -> int:
2187
+ """Get the index of the palette color that best matches a color.
2188
+
2189
+ Args:
2190
+ palette: List of RGBA color tuples.
2191
+ color: RGBA color tuple to search for.
2192
+ fixtransp: Whether to skip the first palette entry when matching.
2193
+ starti: Index of the first palette entry to consider.
2194
+ palsize: Number of palette entries to consider, or -1 for all.
2195
+ checkalpha: Whether to compare the alpha channel too.
2196
+ zerotransp: Whether to return 0 for fully transparent colors.
2197
+ backwards: Whether to search the palette backwards.
2198
+ logcolor: Whether to log colors that aren't matched exactly.
2199
+
2200
+ Returns:
2201
+ The index of the exact color or of the closest one, relative to starti.
2202
+ """
2203
+ if zerotransp and color[3] == 0:
2204
+ return 0
2205
+ if palsize == -1:
2206
+ palsize = len(palette)
2207
+ zeroalpha = -1
2208
+ palrange = range(starti, starti + palsize)
2209
+ if backwards:
2210
+ palrange = reversed(palrange)
2211
+ for i in palrange:
2212
+ if fixtransp and i == starti:
2213
+ continue
2214
+ if palette[i][0] == color[0] and palette[i][1] == color[1] and palette[i][2] == color[2] and (not checkalpha or palette[i][3] == color[3]):
2215
+ return i - starti
2216
+ if checkalpha and palette[i][3] == 0:
2217
+ zeroalpha = i - starti
2218
+ if palette[starti][0] == color[0] and palette[starti][1] == color[1] and palette[starti][2] == color[2] and (not checkalpha or palette[starti][3] == color[3]):
2219
+ return 0
2220
+ if checkalpha and color[3] == 0 and zeroalpha != -1:
2221
+ return zeroalpha
2222
+ mindist = 0xffffffff
2223
+ disti = 0
2224
+ palrange = range(starti + 1, starti + palsize)
2225
+ if backwards:
2226
+ palrange = reversed(palrange)
2227
+ for i in palrange:
2228
+ distance = getColorDistance(color, palette[i], checkalpha)
2229
+ if distance < mindist:
2230
+ mindist = distance
2231
+ disti = i - starti
2232
+ if logcolor:
2233
+ logDebug("Color", color, "not found, closest color:", palette[disti])
2234
+ return disti
2235
+
2236
+
2237
+ def findBestPalette(palettes: list, colors: list) -> int:
2238
+ """Get the index of the palette that best matches a list of colors.
2239
+
2240
+ Args:
2241
+ palettes: List of palettes, each a list of RGBA color tuples.
2242
+ colors: List of RGBA color tuples to match.
2243
+
2244
+ Returns:
2245
+ The index of the palette with the smallest total distance.
2246
+ """
2247
+ if len(palettes) == 1:
2248
+ return 0
2249
+ mindist = 0xffffffff
2250
+ disti = 0
2251
+ for i in range(len(palettes)):
2252
+ distance = 0
2253
+ for color in colors:
2254
+ singledist = 0xffffffff
2255
+ for palcolor in palettes[i]:
2256
+ singledist = min(singledist, getColorDistance(color, palcolor))
2257
+ distance += singledist
2258
+ if distance < mindist:
2259
+ mindist = distance
2260
+ disti = i
2261
+ if mindist == 0:
2262
+ break
2263
+ return disti
2264
+
2265
+
2266
+ def drawPalette(pixels, palette: list, width: int, ystart: int = 0, transp: bool = True):
2267
+ """Draw a palette as 5x5 pixel swatches, 8 per row.
2268
+
2269
+ Args:
2270
+ pixels: PIL pixel access object to draw on.
2271
+ palette: List of RGBA color tuples.
2272
+ width: X position to start drawing at, usually the image width.
2273
+ ystart: Y position to start drawing at.
2274
+ transp: Whether to keep the alpha channel of the colors.
2275
+
2276
+ Returns:
2277
+ The pixels object.
2278
+ """
2279
+ for x in range(len(palette)):
2280
+ j = width + ((x % 8) * 5)
2281
+ i = ystart + ((x // 8) * 5)
2282
+ for j2 in range(5):
2283
+ for i2 in range(5):
2284
+ color = palette[x]
2285
+ if not transp:
2286
+ color = (color[0], color[1], color[2], 255)
2287
+ pixels[j + j2, i + i2] = color
2288
+ return pixels
2289
+
2290
+
2291
+ def flipTile(tile: list, hflip: bool, vflip: bool, tilewidth: int = 8, tileheight: int = 8) -> list:
2292
+ """Flip a tile horizontally and/or vertically.
2293
+
2294
+ Args:
2295
+ tile: Flat list of tile values, one row after the other.
2296
+ hflip: Whether to flip the tile horizontally.
2297
+ vflip: Whether to flip the tile vertically.
2298
+ tilewidth: Width of the tile.
2299
+ tileheight: Height of the tile.
2300
+
2301
+ Returns:
2302
+ The flipped tile, as a new list.
2303
+ """
2304
+ newtile = [0] * len(tile)
2305
+ xrange = range(0, tilewidth) if not hflip else range(tilewidth - 1, -1, -1)
2306
+ yrange = range(0, tileheight) if not vflip else range(tileheight - 1, -1, -1)
2307
+ i = 0
2308
+ for y in yrange:
2309
+ for x in xrange:
2310
+ newtile[i] = tile[y * tileheight + x]
2311
+ i += 1
2312
+ return newtile