PyCriCodecsEx 0.0.2__cp311-cp311-win32.whl → 0.0.5__cp311-cp311-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.
Binary file
PyCriCodecsEx/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.0.2"
1
+ __version__ = "0.0.5"
PyCriCodecsEx/acb.py CHANGED
@@ -3,22 +3,27 @@
3
3
  # - Original work by https://github.com/Youjose/PyCriCodecs
4
4
  # See Research/ACBSchema.py for more details.
5
5
 
6
- from typing import Generator, List, Tuple
6
+ from typing import Generator, List, Tuple, BinaryIO
7
7
  from PyCriCodecsEx.chunk import *
8
8
  from PyCriCodecsEx.utf import UTF, UTFBuilder, UTFViewer
9
- from PyCriCodecsEx.usm import HCACodec, ADXCodec
9
+ from PyCriCodecsEx.hca import HCACodec
10
+ from PyCriCodecsEx.adx import ADXCodec
10
11
  from PyCriCodecsEx.awb import AWB, AWBBuilder
11
12
  from dataclasses import dataclass
12
13
  from copy import deepcopy
13
14
 
14
15
  class CueNameTable(UTFViewer):
15
16
  CueIndex: int
17
+ '''Index into CueTable'''
16
18
  CueName: str
19
+ '''Name of the cue'''
17
20
 
18
21
 
19
22
  class CueTable(UTFViewer):
20
23
  CueId: int
24
+ '''Corresponds to the cue index found in CueNameTable'''
21
25
  Length: int
26
+ '''Duration of the cue in milliseconds'''
22
27
  ReferenceIndex: int
23
28
  ReferenceType: int
24
29
 
@@ -51,14 +56,21 @@ class WaveformTable(UTFViewer):
51
56
 
52
57
 
53
58
  class ACBTable(UTFViewer):
59
+ '''ACB Table View'''
60
+
54
61
  AcbGuid: bytes
62
+ '''GUID of the ACB. This SHOULD be different for each ACB file.'''
55
63
  Name: str
56
- Version: int
64
+ '''Name of the ACB. This is usually the name of the sound bank.'''
65
+ Version: int
57
66
  VersionString: str
58
67
 
59
68
  AwbFile: bytes
60
69
  CueNameTable: List[CueNameTable]
70
+ '''A list of cue names with their corresponding indices into CueTable'''
61
71
  CueTable: List[CueTable]
72
+ '''A list of cues with their corresponding references'''
73
+
62
74
  SequenceTable: List[SequenceTable]
63
75
  SynthTable: List[SynthTable]
64
76
  TrackEventTable: List[TrackEventTable]
@@ -66,7 +78,7 @@ class ACBTable(UTFViewer):
66
78
  WaveformTable: List[WaveformTable]
67
79
 
68
80
  @staticmethod
69
- def decode_tlv(data : bytes):
81
+ def _decode_tlv(data : bytes):
70
82
  pos = 0
71
83
  while pos < len(data):
72
84
  tag = data[pos : pos + 2]
@@ -75,16 +87,16 @@ class ACBTable(UTFViewer):
75
87
  pos += 3 + length
76
88
  yield (tag, value)
77
89
 
78
- def waveform_of_track(self, index: int):
79
- tlv = self.decode_tlv(self.TrackEventTable[index])
90
+ def _waveform_of_track(self, index: int):
91
+ tlv = self._decode_tlv(self.TrackEventTable[index])
80
92
  def noteOn(data: bytes):
81
93
  # Handle note on event
82
94
  tlv_type, tlv_index = AcbTrackCommandNoteOnStruct.unpack(data[:AcbTrackCommandNoteOnStruct.size])
83
95
  match tlv_type:
84
96
  case 0x02: # Synth
85
- yield from self.waveform_of_synth(tlv_index)
97
+ yield from self._waveform_of_synth(tlv_index)
86
98
  case 0x03: # Sequence
87
- yield from self.waveform_of_sequence(tlv_index)
99
+ yield from self._waveform_of_sequence(tlv_index)
88
100
  # Ignore others silently
89
101
  for code, data in tlv:
90
102
  match code:
@@ -93,13 +105,13 @@ class ACBTable(UTFViewer):
93
105
  case 2003:
94
106
  yield from noteOn(data)
95
107
 
96
- def waveform_of_sequence(self, index : int):
108
+ def _waveform_of_sequence(self, index : int):
97
109
  seq = self.SequenceTable[index]
98
110
  for i in range(seq.NumTracks):
99
111
  track_index = int.from_bytes(seq.TrackIndex[i*2:i*2+2], 'big')
100
112
  yield self.WaveformTable[track_index]
101
113
 
102
- def waveform_of_synth(self, index: int):
114
+ def _waveform_of_synth(self, index: int):
103
115
  item_type, item_index = AcbSynthReferenceStruct.unpack(self.SynthTable[index].ReferenceItems)
104
116
  match item_type:
105
117
  case 0x00: # No audio
@@ -107,38 +119,52 @@ class ACBTable(UTFViewer):
107
119
  case 0x01: # Waveform
108
120
  yield self.WaveformTable[item_index]
109
121
  case 0x02: # Yet another synth...
110
- yield from self.waveform_of_synth(item_index)
122
+ yield from self._waveform_of_synth(item_index)
111
123
  case 0x03: # Sequence
112
- yield from self.waveform_of_sequence(item_index)
124
+ yield from self._waveform_of_sequence(item_index)
113
125
  case _:
114
126
  raise NotImplementedError(f"Unknown synth reference type: {item_type} at index {index}")
115
127
 
116
128
  def waveform_of(self, index : int) -> List["WaveformTable"]:
129
+ """Retrieves the waveform(s) associated with a cue.
130
+
131
+ Cues may reference multiple waveforms, which could also be reused."""
117
132
  cue = next(filter(lambda c: c.CueId == index, self.CueTable), None)
118
133
  assert cue, "cue of index %d not found" % index
119
134
  match cue.ReferenceType:
120
135
  case 0x01:
121
136
  return [self.WaveformTable[index]]
122
137
  case 0x02:
123
- return list(self.waveform_of_synth(index))
138
+ return list(self._waveform_of_synth(index))
124
139
  case 0x03:
125
- return list(self.waveform_of_sequence(index))
140
+ return list(self._waveform_of_sequence(index))
126
141
  case 0x08:
127
142
  raise NotImplementedError("BlockSequence type not implemented yet")
128
143
  case _:
129
144
  raise NotImplementedError(f"Unknown cue reference type: {cue.ReferenceType}")
130
145
 
131
146
  @dataclass(frozen=True)
132
- class CueItem:
147
+ class PackedCueItem:
148
+ '''Helper class for read-only cue information'''
149
+
133
150
  CueId: int
151
+ '''Cue ID'''
134
152
  CueName: str
153
+ '''Cue name'''
135
154
  Length: float
136
- Waveforms: list[int] # List of waveform IDs
155
+ '''Duration in seconds'''
156
+ Waveforms: list[int]
157
+ '''List of waveform IDs, corresponds to ACB.get_waveforms()'''
137
158
 
138
159
  class ACB(UTF):
139
- """An ACB is basically a giant @UTF table. Use this class to extract any ACB, and potentially modifiy it in place."""
140
- def __init__(self, filename) -> None:
141
- super().__init__(filename,recursive=True)
160
+ """Use this class to read, and modify ACB files in memory."""
161
+ def __init__(self, stream : str | BinaryIO) -> None:
162
+ """Loads an ACB file from the given stream.
163
+
164
+ Args:
165
+ stream (str | BinaryIO): The path to the ACB file or a BinaryIO stream containing the ACB data.
166
+ """
167
+ super().__init__(stream, recursive=True)
142
168
 
143
169
  @property
144
170
  def payload(self) -> dict:
@@ -162,10 +188,17 @@ class ACB(UTF):
162
188
  """Returns the AWB object associated with the ACB."""
163
189
  return AWB(self.view.AwbFile)
164
190
 
165
- def get_waveforms(self) -> List[HCACodec | ADXCodec | Tuple[AcbEncodeTypes, int, int, int, bytes]]:
191
+ def get_waveforms(self, **kwargs) -> List[HCACodec | ADXCodec | Tuple[AcbEncodeTypes, int, int, int, bytes]]:
166
192
  """Returns a list of decoded waveforms.
167
193
 
168
194
  Item may be a codec (if known), or a tuple of (Codec ID, Channel Count, Sample Count, Sample Rate, Raw data).
195
+
196
+ Additional keyword arguments are passed to the codec constructors. e.g. for encrypted HCA payloads,
197
+ you may do the following:
198
+ ```python
199
+ get_waveforms(key=..., subkey=...)
200
+ ```
201
+ See also the respective docs (ADXCodec, HCACodec) for more details.
169
202
  """
170
203
  CODEC_TABLE = {
171
204
  AcbEncodeTypes.ADX: ADXCodec,
@@ -178,7 +211,7 @@ class ACB(UTF):
178
211
  encode = AcbEncodeTypes(wav.EncodeType)
179
212
  codec = (CODEC_TABLE.get(encode, None))
180
213
  if codec:
181
- wavs.append(codec(awb.get_file_at(wav.MemoryAwbId)))
214
+ wavs.append(codec(awb.get_file_at(wav.MemoryAwbId), **kwargs))
182
215
  else:
183
216
  wavs.append((encode, wav.NumChannels, wav.NumSamples, wav.SamplingRate, awb.get_file_at(wav.MemoryAwbId)))
184
217
  return wavs
@@ -188,7 +221,7 @@ class ACB(UTF):
188
221
 
189
222
  Input item may be a codec (if known), or a tuple of (Codec ID, Channel Count, Sample Count, Sample Rate, Raw data).
190
223
 
191
- NOTE: Cue duration is not set. You need to change that manually.
224
+ NOTE: Cue duration is not set. You need to change that manually - this is usually unecessary as the player will just play until the end of the waveform.
192
225
  """
193
226
  WAVEFORM = self.view.WaveformTable[0]._payload.copy()
194
227
  encoded = []
@@ -228,7 +261,7 @@ class ACB(UTF):
228
261
  pass
229
262
 
230
263
  @property
231
- def cues(self) -> Generator[CueItem, None, None]:
264
+ def cues(self) -> Generator[PackedCueItem, None, None]:
232
265
  """Returns a generator of **read-only** Cues.
233
266
 
234
267
  Cues reference waveform bytes by their AWB IDs, which can be accessed via `waveforms`.
@@ -236,20 +269,25 @@ class ACB(UTF):
236
269
  """
237
270
  for name, cue in zip(self.view.CueNameTable, self.view.CueTable):
238
271
  waveforms = self.view.waveform_of(cue.CueId)
239
- yield CueItem(cue.CueId, name.CueName, cue.Length / 1000.0, [waveform.MemoryAwbId for waveform in waveforms])
272
+ yield PackedCueItem(cue.CueId, name.CueName, cue.Length / 1000.0, [waveform.MemoryAwbId for waveform in waveforms])
240
273
 
241
274
  class ACBBuilder:
275
+ """Use this class to build ACB files from an existing ACB object."""
242
276
  acb: ACB
243
277
 
244
278
  def __init__(self, acb: ACB) -> None:
245
279
  """Initializes the ACBBuilder with an existing ACB object.
246
280
 
281
+ Args:
282
+ acb (ACB): The ACB object to build from.
283
+
247
284
  Building ACB from scratch isn't planned for now since:
248
- * We don't know how SeqCommandTable TLVs work. This is the biggest issue.
249
- * Many fields are unknown or not well understood
250
- - Games may expect AcfReferenceTable, Asiac stuff etc to be present for their own assets in conjunction
251
- with their own ACF table. Missing these is not a fun debugging experience.
252
- * ACB tables differ a LOT from game to game (e.g. Lipsync info), contary to USM formats.
285
+
286
+ * We don't know how SeqCommandTable TLVs work. This is the biggest issue.
287
+ * Many fields are unknown or not well understood
288
+ - Games may expect AcfReferenceTable, Asiac stuff etc to be present for their own assets in conjunction
289
+ with their own ACF table. Missing these is not a fun debugging experience.
290
+ * ACB tables differ a LOT from game to game (e.g. Lipsync info), contary to USM formats.
253
291
 
254
292
  Maybe one day I'll get around to this. But otherwise starting from nothing is a WONTFIX for now.
255
293
  """
PyCriCodecsEx/adx.py CHANGED
@@ -1,8 +1,13 @@
1
+ from typing import BinaryIO
2
+ from io import BytesIO
3
+ from PyCriCodecsEx.chunk import *
1
4
  import CriCodecsEx
2
-
3
5
  class ADX:
4
- """ADX Module for decoding and encoding ADX files, pass the either `adx file` or `wav file` in bytes to either `decode` or `encode` respectively."""
5
-
6
+ """ADX class for decoding and encoding ADX files, pass the either `adx file` or `wav file` in bytes to either `decode` or `encode` respectively.
7
+
8
+ **NOTE:** Direct usage of this class is not recommended, use the `ADXCodec` wrapper instead.
9
+ """
10
+
6
11
  # Decodes ADX to WAV.
7
12
  @staticmethod
8
13
  def decode(data: bytes) -> bytes:
@@ -13,4 +18,141 @@ class ADX:
13
18
  @staticmethod
14
19
  def encode(data: bytes, BitDepth = 0x4, Blocksize = 0x12, Encoding = 3, AdxVersion = 0x4, Highpass_Frequency = 0x1F4, Filter = 0, force_not_looping = False) -> bytes:
15
20
  """ Encodes WAV to ADX. """
16
- return CriCodecsEx.AdxEncode(bytes(data), BitDepth, Blocksize, Encoding, Highpass_Frequency, Filter, AdxVersion, force_not_looping)
21
+ return CriCodecsEx.AdxEncode(bytes(data), BitDepth, Blocksize, Encoding, Highpass_Frequency, Filter, AdxVersion, force_not_looping)
22
+
23
+ class ADXCodec(ADX):
24
+ """Use this class for encoding and decoding ADX files, from and to WAV."""
25
+
26
+ CHUNK_INTERVAL = 99.9
27
+ BASE_FRAMERATE = 2997
28
+ # TODO: Move these to an enum
29
+ AUDIO_CODEC = 2
30
+ METADATA_COUNT = 0
31
+
32
+ filename : str
33
+ filesize : int
34
+
35
+ adx : bytes
36
+ header : bytes
37
+ sfaStream: BinaryIO
38
+
39
+ AdxDataOffset: int
40
+ AdxEncoding: int
41
+ AdxBlocksize: int
42
+ AdxSampleBitdepth: int
43
+ AdxChannelCount: int
44
+ AdxSamplingRate: int
45
+ AdxSampleCount: int
46
+ AdxHighpassFrequency: int
47
+ AdxVersion: int
48
+ AdxFlags: int
49
+
50
+ chnls: int
51
+ sampling_rate: int
52
+ total_samples: int
53
+ avbps: int
54
+
55
+ def __init__(self, stream: str | bytes, filename: str = "default.adx", bitdepth: int = 4, **kwargs):
56
+ """Initializes the ADX encoder/decoder
57
+
58
+ Args:
59
+ stream (str | bytes): Path to the ADX or WAV file, or a BinaryIO stream. WAV files will be automatically encoded with the given settings first.
60
+ filename (str, optional): Filename, used by USMBuilder. Defaults to "default.adx".
61
+ bitdepth (int, optional): Audio bit depth within [2,15]. Defaults to 4.
62
+ """
63
+ if type(stream) == str:
64
+ self.adx = open(stream, "rb").read()
65
+ else:
66
+ self.adx = stream
67
+ self.filename = filename
68
+ self.filesize = len(self.adx)
69
+ magic = self.adx[:4]
70
+ if magic == b"RIFF":
71
+ self.adx = self.encode(self.adx, bitdepth, force_not_looping=True)
72
+ self.sfaStream = BytesIO(self.adx)
73
+ header = AdxHeaderStruct.unpack(self.sfaStream.read(AdxHeaderStruct.size))
74
+ FourCC, self.AdxDataOffset, self.AdxEncoding, self.AdxBlocksize, self.AdxSampleBitdepth, self.AdxChannelCount, self.AdxSamplingRate, self.AdxSampleCount, self.AdxHighpassFrequency, self.AdxVersion, self.AdxFlags = header
75
+ assert FourCC == 0x8000, "either ADX or WAV is supported"
76
+ assert self.AdxVersion in {3,4}, "unsupported ADX version"
77
+ if self.AdxVersion == 4:
78
+ self.sfaStream.seek(4 + 4 * self.AdxChannelCount, 1) # Padding + Hist values, they always seem to be 0.
79
+ self.sfaStream.seek(0)
80
+ self.chnls = self.AdxChannelCount
81
+ self.sampling_rate = self.AdxSamplingRate
82
+ self.total_samples = self.AdxSampleCount
83
+ self.avbps = int(self.filesize * 8 * self.chnls) - self.filesize
84
+
85
+ def generate_SFA(self, index: int, builder):
86
+ # USMBuilder usage
87
+ current_interval = 0
88
+ stream_size = len(self.adx) - self.AdxBlocksize
89
+ chunk_size = int(self.AdxSamplingRate // (self.BASE_FRAMERATE / 100) // 32) * (self.AdxBlocksize * self.AdxChannelCount)
90
+ self.sfaStream.seek(0)
91
+ res = []
92
+ while self.sfaStream.tell() < stream_size:
93
+ if self.sfaStream.tell() > 0:
94
+ if self.sfaStream.tell() + chunk_size < stream_size:
95
+ datalen = chunk_size
96
+ else:
97
+ datalen = (stream_size - (self.AdxDataOffset + 4) - chunk_size) % chunk_size
98
+ else:
99
+ datalen = self.AdxDataOffset + 4
100
+ if not datalen:
101
+ break
102
+ padding = (0x20 - (datalen % 0x20) if datalen % 0x20 != 0 else 0)
103
+ SFA_chunk = USMChunkHeader.pack(
104
+ USMChunckHeaderType.SFA.value,
105
+ datalen + 0x18 + padding,
106
+ 0,
107
+ 0x18,
108
+ padding,
109
+ index,
110
+ 0,
111
+ 0,
112
+ 0,
113
+ round(current_interval),
114
+ self.BASE_FRAMERATE,
115
+ 0,
116
+ 0
117
+ )
118
+ chunk_data = self.sfaStream.read(datalen)
119
+ if builder.encrypt_audio:
120
+ SFA_chunk = builder.AudioMask(chunk_data)
121
+ SFA_chunk += chunk_data.ljust(datalen + padding, b"\x00")
122
+ current_interval += self.CHUNK_INTERVAL
123
+ res.append(SFA_chunk)
124
+ # ---
125
+ SFA_chunk = USMChunkHeader.pack(
126
+ USMChunckHeaderType.SFA.value,
127
+ 0x38,
128
+ 0,
129
+ 0x18,
130
+ 0,
131
+ index,
132
+ 0,
133
+ 0,
134
+ 2,
135
+ 0,
136
+ 30,
137
+ 0,
138
+ 0
139
+ )
140
+ SFA_chunk += b"#CONTENTS END ===============\x00"
141
+ res[-1] += SFA_chunk
142
+ return res
143
+
144
+ def get_metadata(self):
145
+ return None
146
+
147
+ def get_encoded(self) -> bytes:
148
+ """Gets the encoded ADX audio data."""
149
+ return self.adx
150
+
151
+ def save(self, filepath: str | BinaryIO):
152
+ """Saves the decoded WAV audio to filepath or a writable stream"""
153
+ if type(filepath) == str:
154
+ with open(filepath, "wb") as f:
155
+ f.write(self.decode(self.adx))
156
+ else:
157
+ filepath.write(self.decode(self.adx))
158
+
PyCriCodecsEx/awb.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from io import BytesIO, FileIO
2
- import os
3
- from typing import BinaryIO
2
+ from typing import BinaryIO, Generator
4
3
  from struct import iter_unpack, pack
5
4
  from PyCriCodecsEx.chunk import *
6
5
  from PyCriCodecsEx.hca import HCA
@@ -55,7 +54,7 @@ class AWB:
55
54
  self.headersize = self.headersize + (self.align - (self.headersize % self.align))
56
55
  self.stream.seek(self.headersize, 0)
57
56
 
58
- def get_files(self):
57
+ def get_files(self) -> Generator[bytes, None, None]:
59
58
  """Generator function to yield all data blobs from an AWB. """
60
59
  self.stream.seek(self.headersize, 0)
61
60
  for i in range(1, len(self.ofs)):
@@ -82,6 +81,7 @@ class AWB:
82
81
  raise ValueError("Unknown int size.")
83
82
 
84
83
  class AWBBuilder:
84
+ """Use this class to build AWB files from a list of bytes."""
85
85
  def __init__(self, infiles: list[bytes], subkey: int = 0, version: int = 2, id_intsize = 0x2, align: int = 0x20) -> None:
86
86
  """Initializes the AWB builder.
87
87
 
PyCriCodecsEx/chunk.py CHANGED
@@ -46,11 +46,6 @@ class HCAType(Enum):
46
46
  HCA = b"HCA\x00" # Header.
47
47
  EHCA = b"\xC8\xC3\xC1\x00" # Encrypted HCA header.
48
48
 
49
- class VideoType(Enum):
50
- IVF = b"DKIF" # Header.
51
- # H264 = b"" # Header.
52
- # MPEG = b"" # Header.
53
-
54
49
  # I saw some devs swap the unsigned/signed indexes. So I am not sure what's correct or not.
55
50
  # In my own experience, swapping those results in an incorrect signed values (should be unsigned) in ACB's/CPK's.
56
51
  # If someone were to change this, they must change 'stringtypes' function in UTF/UTFBuilder classes.
PyCriCodecsEx/cpk.py CHANGED
@@ -1,20 +1,27 @@
1
1
  import os
2
- from typing import BinaryIO
2
+ from typing import BinaryIO, Generator
3
3
  from io import BytesIO, FileIO
4
4
  from PyCriCodecsEx.chunk import *
5
5
  from PyCriCodecsEx.utf import UTF, UTFBuilder
6
6
  from dataclasses import dataclass
7
- from concurrent.futures import ProcessPoolExecutor, as_completed
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
8
  from tempfile import NamedTemporaryFile
9
9
  import CriCodecsEx
10
10
 
11
- def _worker_do_compression(src : str, dst: str):
11
+ def _crilayla_compress_to_file(src : str, dst: str):
12
12
  with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
13
13
  data = fsrc.read()
14
- compressed = CriCodecsEx.CriLaylaCompress(data)
15
- fdst.write(compressed)
14
+ try:
15
+ compressed = CriCodecsEx.CriLaylaCompress(data)
16
+ fdst.write(compressed)
17
+ except:
18
+ # Fallback for failed compression
19
+ # Again. FIXME.
20
+ fdst.write(data)
21
+
16
22
  @dataclass
17
- class _PackFile():
23
+ class PackedFile():
24
+ """Helper class for packed files within a CPK."""
18
25
  stream: BinaryIO
19
26
  path: str
20
27
  offset: int
@@ -22,6 +29,7 @@ class _PackFile():
22
29
  compressed : bool = False
23
30
 
24
31
  def get_bytes(self) -> bytes:
32
+ """Get the raw bytes of the packed file, decompressing if necessary."""
25
33
  self.stream.seek(self.offset)
26
34
  data = self.stream.read(self.size)
27
35
  if self.compressed:
@@ -29,6 +37,7 @@ class _PackFile():
29
37
  return data
30
38
 
31
39
  def save(self, path : str):
40
+ """Save the packed file to a specified path."""
32
41
  with open(path, "wb") as f:
33
42
  f.write(self.get_bytes())
34
43
  class _TOC():
@@ -48,6 +57,7 @@ class _TOC():
48
57
  self.table = UTF(self.stream.read()).table
49
58
 
50
59
  class CPK:
60
+ """Use this class to load CPK file table-of-content, and read files from them on-demand."""
51
61
  magic: bytes
52
62
  encflag: int
53
63
  packet_size: int
@@ -114,6 +124,9 @@ class CPK:
114
124
 
115
125
  @property
116
126
  def mode(self):
127
+ """Get the current mode of the CPK archive. [0,1,2,3]
128
+
129
+ See also CPKBuilder"""
117
130
  TOC, ITOC, GTOC = 'TOC' in self.tables, 'ITOC' in self.tables, 'GTOC' in self.tables
118
131
  if TOC and ITOC and GTOC:
119
132
  return 3
@@ -126,8 +139,8 @@ class CPK:
126
139
  raise ValueError("Unknown CPK mode.")
127
140
 
128
141
  @property
129
- def files(self):
130
- """Retrieves a list of all files in the CPK archive."""
142
+ def files(self) -> Generator[PackedFile, None, None]:
143
+ """Creates a generator for all files in the CPK archive as PackedFile."""
131
144
  if "TOC" in self.tables:
132
145
  toctable = self.tables['TOC']
133
146
  rel_off = 0x800
@@ -138,10 +151,10 @@ class CPK:
138
151
  filename = filename[:250] + "_" + str(i) # 250 because i might be 4 digits long.
139
152
  if toctable['ExtractSize'][i] > toctable['FileSize'][i]:
140
153
  self.stream.seek(rel_off+toctable["FileOffset"][i], 0)
141
- yield _PackFile(self.stream, os.path.join(dirname,filename), self.stream.tell(), toctable['FileSize'][i], compressed=True)
154
+ yield PackedFile(self.stream, os.path.join(dirname,filename), self.stream.tell(), toctable['FileSize'][i], compressed=True)
142
155
  else:
143
156
  self.stream.seek(rel_off+toctable["FileOffset"][i], 0)
144
- yield _PackFile(self.stream, os.path.join(dirname,filename), self.stream.tell(), toctable['FileSize'][i])
157
+ yield PackedFile(self.stream, os.path.join(dirname,filename), self.stream.tell(), toctable['FileSize'][i])
145
158
  elif "ITOC" in self.tables:
146
159
  toctableL = self.tables["ITOC"]['DataL'][0]
147
160
  toctableH = self.tables["ITOC"]['DataH'][0]
@@ -153,18 +166,18 @@ class CPK:
153
166
  if i in toctableH['ID']:
154
167
  idx = toctableH['ID'].index(i)
155
168
  if toctableH['ExtractSize'][idx] > toctableH['FileSize'][idx]:
156
- yield _PackFile(self.stream, str(i), self.stream.tell(), toctableH['FileSize'][idx], compressed=True)
169
+ yield PackedFile(self.stream, str(i), self.stream.tell(), toctableH['FileSize'][idx], compressed=True)
157
170
  else:
158
- yield _PackFile(self.stream, str(i), self.stream.tell(), toctableH['FileSize'][idx])
171
+ yield PackedFile(self.stream, str(i), self.stream.tell(), toctableH['FileSize'][idx])
159
172
  if toctableH['FileSize'][idx] % align != 0:
160
173
  seek_size = (align - toctableH['FileSize'][idx] % align)
161
174
  self.stream.seek(seek_size, 1)
162
175
  elif i in toctableL['ID']:
163
176
  idx = toctableL['ID'].index(i)
164
177
  if toctableL['ExtractSize'][idx] > toctableL['FileSize'][idx]:
165
- yield _PackFile(self.stream, str(i), self.stream.tell(), toctableL['FileSize'][idx], compressed=True)
178
+ yield PackedFile(self.stream, str(i), self.stream.tell(), toctableL['FileSize'][idx], compressed=True)
166
179
  else:
167
- yield _PackFile(self.stream, str(i), self.stream.tell(), toctableL['FileSize'][idx])
180
+ yield PackedFile(self.stream, str(i), self.stream.tell(), toctableL['FileSize'][idx])
168
181
  if toctableL['FileSize'][idx] % align != 0:
169
182
  seek_size = (align - toctableL['FileSize'][idx] % align)
170
183
  self.stream.seek(seek_size, 1)
@@ -189,7 +202,7 @@ class CPKBuilder:
189
202
  ContentSize: int
190
203
  EnabledDataSize: int
191
204
  EnabledPackedSize: int
192
- outfile: str
205
+ outfile: BinaryIO
193
206
  init_toc_len: int # This is a bit of a redundancy, but some CPK's need it.
194
207
 
195
208
  in_files : list[tuple[str, str, bool]] # (source path, dest filename, compress or not)
@@ -246,24 +259,22 @@ class CPKBuilder:
246
259
  compress (bool, optional): Whether to compress the file. Defaults to False.
247
260
 
248
261
  NOTE:
249
- - In ITOC-related mode, the insertion order determines the final integer ID of the files.
250
- - Compression can be VERY slow with high entropy files (e.g. encoded media). Use at discretion.
251
- """
262
+ - In ITOC-related mode, the insertion order determines the final integer ID of the files.
263
+ """
252
264
  if not dst and self.mode != 0:
253
265
  raise ValueError("Destination filename must be specified in non-ITOC mode.")
254
266
 
255
267
  self.in_files.append((src, dst, compress))
256
268
 
257
- def _writetofile(self, header) -> None:
258
- with open(self.outfile, "wb") as out:
259
- out.write(header)
260
- for i, ((path, _), (filename, file_size, pack_size)) in enumerate(zip(self.os_files, self.files)):
261
- src = open(path, 'rb').read()
262
- out.write(src)
263
- out.write(bytes(0x800 - pack_size % 0x800))
264
- self.progress_cb("Write %s" % os.path.basename(filename), i + 1, len(self.files))
269
+ def _writetofile(self, header) -> None:
270
+ self.outfile.write(header)
271
+ for i, ((path, _), (filename, file_size, pack_size)) in enumerate(zip(self.os_files, self.files)):
272
+ src = open(path, 'rb').read()
273
+ self.outfile.write(src)
274
+ self.outfile.write(bytes(0x800 - pack_size % 0x800))
275
+ self.progress_cb("Write %s" % os.path.basename(filename), i + 1, len(self.files))
265
276
 
266
- def _populate_files(self, parallel : bool):
277
+ def _populate_files(self, threads : int = 1):
267
278
  self.files = []
268
279
  for src, dst, compress in self.in_files:
269
280
  if compress:
@@ -271,23 +282,15 @@ class CPKBuilder:
271
282
  self.os_files.append((tmp.name, True))
272
283
  else:
273
284
  self.os_files.append((src, False))
274
- if parallel:
275
- with ProcessPoolExecutor() as exec:
276
- futures = []
277
- for (src, _, _), (dst, compress) in zip(self.in_files,self.os_files):
278
- if compress:
279
- futures.append(exec.submit(_worker_do_compression, src, dst))
280
- for i, fut in as_completed(futures):
281
- try:
282
- fut.result()
283
- except:
284
- pass
285
- self.progress_cb("Compress %s" % os.path.basename(src), i + 1, len(futures))
286
- else:
287
- for i, ((src, _, _), (dst, compress)) in enumerate(zip(self.in_files,self.os_files)):
288
- if compress:
289
- _worker_do_compression(src, dst)
290
- self.progress_cb("Compress %s" % os.path.basename(src), i + 1, len(self.in_files))
285
+ with ThreadPoolExecutor(max_workers=threads) as exec:
286
+ futures = []
287
+ for (src, _, _), (dst, compress) in zip(self.in_files,self.os_files):
288
+ if compress:
289
+ _crilayla_compress_to_file(src, dst)
290
+ # futures.append(exec.submit(_crilayla_compress_to_file, src, dst))
291
+ for i, fut in enumerate(as_completed(futures)):
292
+ fut.result()
293
+ self.progress_cb("Compress %s" % os.path.basename(src), i + 1, len(futures))
291
294
  for (src, filename, _) , (dst, _) in zip(self.in_files,self.os_files):
292
295
  file_size = os.stat(src).st_size
293
296
  pack_size = os.stat(dst).st_size
@@ -304,21 +307,22 @@ class CPKBuilder:
304
307
  pass
305
308
  self.os_files = []
306
309
 
307
- def save(self, outfile : str, parallel : bool = False):
310
+ def save(self, outfile : str | BinaryIO, threads : int = 1):
308
311
  """Build and save the bundle into a file
309
312
 
310
313
 
311
314
  Args:
312
- outfile (str): The output file path.
313
- parallel (bool, optional): Whether to use parallel processing for file compression (if at all used). Defaults to False.
315
+ outfile (str | BinaryIO): The output file path or a writable binary stream.
316
+ threads (int, optional): The number of threads to use for file compression. Defaults to 1.
314
317
 
315
318
  NOTE:
316
319
  - Temporary files may be created during the process if compression is used.
317
- - parallel uses multiprocessing. Make sure your main function is guarded with `if __name__ == '__main__'` clause.
318
320
  """
319
321
  assert self.in_files, "cannot save empty bundle"
320
322
  self.outfile = outfile
321
- self._populate_files(parallel)
323
+ if type(outfile) == str:
324
+ self.outfile = open(outfile, "wb")
325
+ self._populate_files(threads)
322
326
  if self.encrypt:
323
327
  encflag = 0
324
328
  else:
@@ -363,7 +367,9 @@ class CPKBuilder:
363
367
  data = self.CPKdata.ljust(len(self.CPKdata) + (0x800 - len(self.CPKdata) % 0x800) - 6, b'\x00') + bytearray(b"(c)CRI") + self.ITOCdata
364
368
  self._writetofile(data)
365
369
  self._cleanup_files()
366
-
370
+ if type(outfile) == str:
371
+ self.outfile.close()
372
+
367
373
  def _generate_GTOC(self) -> bytearray:
368
374
  # NOTE: Practically useless
369
375
  # I have no idea why are those numbers here.