PyCriCodecsEx 0.0.2__cp311-cp311-macosx_11_0_arm64.whl → 0.0.5__cp311-cp311-macosx_11_0_arm64.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.
- CriCodecsEx.cpython-311-darwin.so +0 -0
- PyCriCodecsEx/__init__.py +1 -1
- PyCriCodecsEx/acb.py +67 -29
- PyCriCodecsEx/adx.py +146 -4
- PyCriCodecsEx/awb.py +3 -3
- PyCriCodecsEx/chunk.py +0 -5
- PyCriCodecsEx/cpk.py +56 -50
- PyCriCodecsEx/hca.py +170 -27
- PyCriCodecsEx/usm.py +32 -266
- PyCriCodecsEx/utf.py +8 -24
- pycricodecsex-0.0.5.dist-info/METADATA +35 -0
- pycricodecsex-0.0.5.dist-info/RECORD +15 -0
- pycricodecsex-0.0.2.dist-info/METADATA +0 -86
- pycricodecsex-0.0.2.dist-info/RECORD +0 -15
- {pycricodecsex-0.0.2.dist-info → pycricodecsex-0.0.5.dist-info}/WHEEL +0 -0
- {pycricodecsex-0.0.2.dist-info → pycricodecsex-0.0.5.dist-info}/licenses/LICENSE +0 -0
- {pycricodecsex-0.0.2.dist-info → pycricodecsex-0.0.5.dist-info}/top_level.txt +0 -0
|
Binary file
|
PyCriCodecsEx/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.0.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
79
|
-
tlv = self.
|
|
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.
|
|
97
|
+
yield from self._waveform_of_synth(tlv_index)
|
|
86
98
|
case 0x03: # Sequence
|
|
87
|
-
yield from self.
|
|
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
|
|
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
|
|
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.
|
|
122
|
+
yield from self._waveform_of_synth(item_index)
|
|
111
123
|
case 0x03: # Sequence
|
|
112
|
-
yield from self.
|
|
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.
|
|
138
|
+
return list(self._waveform_of_synth(index))
|
|
124
139
|
case 0x03:
|
|
125
|
-
return list(self.
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
140
|
-
def __init__(self,
|
|
141
|
-
|
|
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[
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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
|
|
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
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
8
|
from tempfile import NamedTemporaryFile
|
|
9
9
|
import CriCodecsEx
|
|
10
10
|
|
|
11
|
-
def
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
|
169
|
+
yield PackedFile(self.stream, str(i), self.stream.tell(), toctableH['FileSize'][idx], compressed=True)
|
|
157
170
|
else:
|
|
158
|
-
yield
|
|
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
|
|
178
|
+
yield PackedFile(self.stream, str(i), self.stream.tell(), toctableL['FileSize'][idx], compressed=True)
|
|
166
179
|
else:
|
|
167
|
-
yield
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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,
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|