PyCriCodecsEx 0.0.5__cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.0.5"
PyCriCodecsEx/acb.py ADDED
@@ -0,0 +1,306 @@
1
+ # Credit:
2
+ # - github.com/vgmstream/vgmstream which is why this is possible at all
3
+ # - Original work by https://github.com/Youjose/PyCriCodecs
4
+ # See Research/ACBSchema.py for more details.
5
+
6
+ from typing import Generator, List, Tuple, BinaryIO
7
+ from PyCriCodecsEx.chunk import *
8
+ from PyCriCodecsEx.utf import UTF, UTFBuilder, UTFViewer
9
+ from PyCriCodecsEx.hca import HCACodec
10
+ from PyCriCodecsEx.adx import ADXCodec
11
+ from PyCriCodecsEx.awb import AWB, AWBBuilder
12
+ from dataclasses import dataclass
13
+ from copy import deepcopy
14
+
15
+ class CueNameTable(UTFViewer):
16
+ CueIndex: int
17
+ '''Index into CueTable'''
18
+ CueName: str
19
+ '''Name of the cue'''
20
+
21
+
22
+ class CueTable(UTFViewer):
23
+ CueId: int
24
+ '''Corresponds to the cue index found in CueNameTable'''
25
+ Length: int
26
+ '''Duration of the cue in milliseconds'''
27
+ ReferenceIndex: int
28
+ ReferenceType: int
29
+
30
+
31
+ class SequenceTable(UTFViewer):
32
+ NumTracks : int
33
+ TrackIndex: bytes
34
+ Type: int
35
+
36
+
37
+ class SynthTable(UTFViewer):
38
+ ReferenceItems: bytes
39
+
40
+
41
+ class TrackEventTable(UTFViewer):
42
+ Command: bytes
43
+
44
+
45
+ class TrackTable(UTFViewer):
46
+ EventIndex: int
47
+
48
+
49
+ class WaveformTable(UTFViewer):
50
+ EncodeType: int
51
+ MemoryAwbId: int
52
+ NumChannels: int
53
+ NumSamples: int
54
+ SamplingRate: int
55
+ Streaming: int
56
+
57
+
58
+ class ACBTable(UTFViewer):
59
+ '''ACB Table View'''
60
+
61
+ AcbGuid: bytes
62
+ '''GUID of the ACB. This SHOULD be different for each ACB file.'''
63
+ Name: str
64
+ '''Name of the ACB. This is usually the name of the sound bank.'''
65
+ Version: int
66
+ VersionString: str
67
+
68
+ AwbFile: bytes
69
+ CueNameTable: List[CueNameTable]
70
+ '''A list of cue names with their corresponding indices into CueTable'''
71
+ CueTable: List[CueTable]
72
+ '''A list of cues with their corresponding references'''
73
+
74
+ SequenceTable: List[SequenceTable]
75
+ SynthTable: List[SynthTable]
76
+ TrackEventTable: List[TrackEventTable]
77
+ TrackTable: List[TrackTable]
78
+ WaveformTable: List[WaveformTable]
79
+
80
+ @staticmethod
81
+ def _decode_tlv(data : bytes):
82
+ pos = 0
83
+ while pos < len(data):
84
+ tag = data[pos : pos + 2]
85
+ length = data[pos + 3]
86
+ value = data[pos + 4 : pos + 4 + length]
87
+ pos += 3 + length
88
+ yield (tag, value)
89
+
90
+ def _waveform_of_track(self, index: int):
91
+ tlv = self._decode_tlv(self.TrackEventTable[index])
92
+ def noteOn(data: bytes):
93
+ # Handle note on event
94
+ tlv_type, tlv_index = AcbTrackCommandNoteOnStruct.unpack(data[:AcbTrackCommandNoteOnStruct.size])
95
+ match tlv_type:
96
+ case 0x02: # Synth
97
+ yield from self._waveform_of_synth(tlv_index)
98
+ case 0x03: # Sequence
99
+ yield from self._waveform_of_sequence(tlv_index)
100
+ # Ignore others silently
101
+ for code, data in tlv:
102
+ match code:
103
+ case 2000:
104
+ yield from noteOn(data)
105
+ case 2003:
106
+ yield from noteOn(data)
107
+
108
+ def _waveform_of_sequence(self, index : int):
109
+ seq = self.SequenceTable[index]
110
+ for i in range(seq.NumTracks):
111
+ track_index = int.from_bytes(seq.TrackIndex[i*2:i*2+2], 'big')
112
+ yield self.WaveformTable[track_index]
113
+
114
+ def _waveform_of_synth(self, index: int):
115
+ item_type, item_index = AcbSynthReferenceStruct.unpack(self.SynthTable[index].ReferenceItems)
116
+ match item_type:
117
+ case 0x00: # No audio
118
+ return
119
+ case 0x01: # Waveform
120
+ yield self.WaveformTable[item_index]
121
+ case 0x02: # Yet another synth...
122
+ yield from self._waveform_of_synth(item_index)
123
+ case 0x03: # Sequence
124
+ yield from self._waveform_of_sequence(item_index)
125
+ case _:
126
+ raise NotImplementedError(f"Unknown synth reference type: {item_type} at index {index}")
127
+
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."""
132
+ cue = next(filter(lambda c: c.CueId == index, self.CueTable), None)
133
+ assert cue, "cue of index %d not found" % index
134
+ match cue.ReferenceType:
135
+ case 0x01:
136
+ return [self.WaveformTable[index]]
137
+ case 0x02:
138
+ return list(self._waveform_of_synth(index))
139
+ case 0x03:
140
+ return list(self._waveform_of_sequence(index))
141
+ case 0x08:
142
+ raise NotImplementedError("BlockSequence type not implemented yet")
143
+ case _:
144
+ raise NotImplementedError(f"Unknown cue reference type: {cue.ReferenceType}")
145
+
146
+ @dataclass(frozen=True)
147
+ class PackedCueItem:
148
+ '''Helper class for read-only cue information'''
149
+
150
+ CueId: int
151
+ '''Cue ID'''
152
+ CueName: str
153
+ '''Cue name'''
154
+ Length: float
155
+ '''Duration in seconds'''
156
+ Waveforms: list[int]
157
+ '''List of waveform IDs, corresponds to ACB.get_waveforms()'''
158
+
159
+ class ACB(UTF):
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)
168
+
169
+ @property
170
+ def payload(self) -> dict:
171
+ """Retrives the only UTF table dict within the ACB file."""
172
+ return self.dictarray[0]
173
+
174
+ @property
175
+ def view(self) -> ACBTable:
176
+ """Returns a view of the ACB file, with all known tables mapped to their respective classes.
177
+
178
+ * Use this to interact with the ACB payload instead of `payload` for helper functions, etc"""
179
+ return ACBTable(self.payload)
180
+
181
+ @property
182
+ def name(self) -> str:
183
+ """Returns the name of the ACB file."""
184
+ return self.view.Name
185
+
186
+ @property
187
+ def awb(self) -> AWB:
188
+ """Returns the AWB object associated with the ACB."""
189
+ return AWB(self.view.AwbFile)
190
+
191
+ def get_waveforms(self, **kwargs) -> List[HCACodec | ADXCodec | Tuple[AcbEncodeTypes, int, int, int, bytes]]:
192
+ """Returns a list of decoded waveforms.
193
+
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.
202
+ """
203
+ CODEC_TABLE = {
204
+ AcbEncodeTypes.ADX: ADXCodec,
205
+ AcbEncodeTypes.HCA: HCACodec,
206
+ AcbEncodeTypes.HCAMX: HCACodec,
207
+ }
208
+ awb = self.awb
209
+ wavs = []
210
+ for wav in self.view.WaveformTable:
211
+ encode = AcbEncodeTypes(wav.EncodeType)
212
+ codec = (CODEC_TABLE.get(encode, None))
213
+ if codec:
214
+ wavs.append(codec(awb.get_file_at(wav.MemoryAwbId), **kwargs))
215
+ else:
216
+ wavs.append((encode, wav.NumChannels, wav.NumSamples, wav.SamplingRate, awb.get_file_at(wav.MemoryAwbId)))
217
+ return wavs
218
+
219
+ def set_waveforms(self, value: List[HCACodec | ADXCodec | Tuple[AcbEncodeTypes, int, int, int, bytes]]):
220
+ """Sets the waveform data.
221
+
222
+ Input item may be a codec (if known), or a tuple of (Codec ID, Channel Count, Sample Count, Sample Rate, Raw data).
223
+
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.
225
+ """
226
+ WAVEFORM = self.view.WaveformTable[0]._payload.copy()
227
+ encoded = []
228
+ tables = self.view.WaveformTable
229
+ tables.clear()
230
+ for i, codec in enumerate(value):
231
+ if type(codec) == HCACodec:
232
+ encoded.append(codec.get_encoded())
233
+ tables.append(WaveformTable(WAVEFORM.copy()))
234
+ entry = tables[-1]
235
+ entry.EncodeType = AcbEncodeTypes.HCA.value
236
+ entry.NumChannels = codec.chnls
237
+ entry.NumSamples = codec.total_samples
238
+ entry.SamplingRate = codec.sampling_rate
239
+ elif type(codec) == ADXCodec:
240
+ encoded.append(codec.get_encoded())
241
+ tables.append(WaveformTable(WAVEFORM.copy()))
242
+ entry = tables[-1]
243
+ entry.EncodeType = AcbEncodeTypes.ADX.value
244
+ entry.NumChannels = codec.chnls
245
+ entry.NumSamples = codec.total_samples
246
+ entry.SamplingRate = codec.sampling_rate
247
+ elif isinstance(codec, tuple):
248
+ e_type, e_channels, e_samples, e_rate, e_data = codec
249
+ encoded.append(e_data)
250
+ tables.append(WaveformTable(WAVEFORM.copy()))
251
+ entry = tables[-1]
252
+ entry.EncodeType = e_type.value
253
+ entry.NumChannels = e_channels
254
+ entry.NumSamples = e_samples
255
+ entry.SamplingRate = e_rate
256
+ else:
257
+ raise TypeError(f"Unsupported codec type: {type(codec)}")
258
+ tables[-1].MemoryAwbId = i
259
+ awb = self.awb
260
+ self.view.AwbFile = AWBBuilder(encoded, awb.subkey, awb.version, align=awb.align).build()
261
+ pass
262
+
263
+ @property
264
+ def cues(self) -> Generator[PackedCueItem, None, None]:
265
+ """Returns a generator of **read-only** Cues.
266
+
267
+ Cues reference waveform bytes by their AWB IDs, which can be accessed via `waveforms`.
268
+ To modify cues, use the `view` property instead.
269
+ """
270
+ for name, cue in zip(self.view.CueNameTable, self.view.CueTable):
271
+ waveforms = self.view.waveform_of(cue.CueId)
272
+ yield PackedCueItem(cue.CueId, name.CueName, cue.Length / 1000.0, [waveform.MemoryAwbId for waveform in waveforms])
273
+
274
+ class ACBBuilder:
275
+ """Use this class to build ACB files from an existing ACB object."""
276
+ acb: ACB
277
+
278
+ def __init__(self, acb: ACB) -> None:
279
+ """Initializes the ACBBuilder with an existing ACB object.
280
+
281
+ Args:
282
+ acb (ACB): The ACB object to build from.
283
+
284
+ Building ACB from scratch isn't planned for now since:
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.
291
+
292
+ Maybe one day I'll get around to this. But otherwise starting from nothing is a WONTFIX for now.
293
+ """
294
+ self.acb = acb
295
+
296
+ def build(self) -> bytes:
297
+ """Builds an ACB binary blob from the current ACB object.
298
+
299
+ The object may be modified in place before building, which will be reflected in the output binary.
300
+ """
301
+ # Check whether all AWB indices are valid
302
+ assert all(
303
+ waveform.MemoryAwbId < self.acb.awb.numfiles for waveform in self.acb.view.WaveformTable
304
+ ), "one or more AWB indices are out of range"
305
+ binary = UTFBuilder(self.acb.dictarray, encoding=self.acb.encoding, table_name=self.acb.table_name)
306
+ return binary.bytes()
PyCriCodecsEx/adx.py ADDED
@@ -0,0 +1,158 @@
1
+ from typing import BinaryIO
2
+ from io import BytesIO
3
+ from PyCriCodecsEx.chunk import *
4
+ import CriCodecsEx
5
+ class ADX:
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
+
11
+ # Decodes ADX to WAV.
12
+ @staticmethod
13
+ def decode(data: bytes) -> bytes:
14
+ """ Decodes ADX to WAV. """
15
+ return CriCodecsEx.AdxDecode(bytes(data))
16
+
17
+ # Encodes WAV to ADX.
18
+ @staticmethod
19
+ def encode(data: bytes, BitDepth = 0x4, Blocksize = 0x12, Encoding = 3, AdxVersion = 0x4, Highpass_Frequency = 0x1F4, Filter = 0, force_not_looping = False) -> bytes:
20
+ """ Encodes WAV to ADX. """
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 ADDED
@@ -0,0 +1,165 @@
1
+ from io import BytesIO, FileIO
2
+ from typing import BinaryIO, Generator
3
+ from struct import iter_unpack, pack
4
+ from PyCriCodecsEx.chunk import *
5
+ from PyCriCodecsEx.hca import HCA
6
+
7
+ # for AFS2 only.
8
+ class AWB:
9
+ """ Use this class to return any AWB data with the getfiles function. """
10
+ stream: BinaryIO
11
+ numfiles: int
12
+ align: int
13
+ subkey: bytes
14
+ version: int
15
+ ids: list
16
+ ofs: list
17
+ filename: str
18
+ headersize: int
19
+ id_alignment: int
20
+
21
+ def __init__(self, stream : str | BinaryIO) -> None:
22
+ """Initializes the AWB object
23
+
24
+ Args:
25
+ stream (str | BinaryIO): Source file path or binary stream
26
+ """
27
+ if type(stream) == str:
28
+ self.stream = FileIO(stream)
29
+ self.filename = stream
30
+ else:
31
+ self.stream = BytesIO(stream)
32
+ self.filename = ""
33
+ self._readheader()
34
+
35
+ def _readheader(self):
36
+ # Reads header.
37
+ magic, self.version, offset_intsize, self.id_intsize, self.numfiles, self.align, self.subkey = AWBChunkHeader.unpack(
38
+ self.stream.read(AWBChunkHeader.size)
39
+ )
40
+ if magic != b'AFS2':
41
+ raise ValueError("Invalid AWB header.")
42
+
43
+ # Reads data in the header.
44
+ self.ids = list()
45
+ self.ofs = list()
46
+ for i in iter_unpack(f"<{self._stringtypes(self.id_intsize)}", self.stream.read(self.id_intsize*self.numfiles)):
47
+ self.ids.append(i[0])
48
+ for i in iter_unpack(f"<{self._stringtypes(offset_intsize)}", self.stream.read(offset_intsize*(self.numfiles+1))):
49
+ self.ofs.append(i[0] if i[0] % self.align == 0 else (i[0] + (self.align - (i[0] % self.align))))
50
+
51
+ # Seeks to files offset.
52
+ self.headersize = 16 + (offset_intsize*(self.numfiles+1)) + (self.id_intsize*self.numfiles)
53
+ if self.headersize % self.align != 0:
54
+ self.headersize = self.headersize + (self.align - (self.headersize % self.align))
55
+ self.stream.seek(self.headersize, 0)
56
+
57
+ def get_files(self) -> Generator[bytes, None, None]:
58
+ """Generator function to yield all data blobs from an AWB. """
59
+ self.stream.seek(self.headersize, 0)
60
+ for i in range(1, len(self.ofs)):
61
+ data = self.stream.read((self.ofs[i]-self.ofs[i-1]))
62
+ self.stream.seek(self.ofs[i], 0)
63
+ yield data
64
+
65
+ def get_file_at(self, index) -> bytes:
66
+ """Gets you a file at specific index. """
67
+ self.stream.seek(self.ofs[index], 0)
68
+ data = self.stream.read(self.ofs[index + 1]-self.ofs[index])
69
+ return data
70
+
71
+ def _stringtypes(self, intsize: int) -> str:
72
+ if intsize == 1:
73
+ return "B" # Probably impossible.
74
+ elif intsize == 2:
75
+ return "H"
76
+ elif intsize == 4:
77
+ return "I"
78
+ elif intsize == 8:
79
+ return "Q"
80
+ else:
81
+ raise ValueError("Unknown int size.")
82
+
83
+ class AWBBuilder:
84
+ """Use this class to build AWB files from a list of bytes."""
85
+ def __init__(self, infiles: list[bytes], subkey: int = 0, version: int = 2, id_intsize = 0x2, align: int = 0x20) -> None:
86
+ """Initializes the AWB builder.
87
+
88
+ Args:
89
+ infiles (list[bytes]): List of bytes to be included in the AWB file.
90
+ subkey (int, optional): AWB subkey. Defaults to 0.
91
+ version (int, optional): AWB version. Defaults to 2.
92
+ id_intsize (hexadecimal, optional): Integer size (in bytes) for string lengths. Defaults to 0x2.
93
+ align (int, optional): Alignment. Defaults to 0x20.
94
+ """
95
+ if version == 1 and subkey != 0:
96
+ raise ValueError("Cannot have a subkey with AWB version of 1.")
97
+ elif id_intsize not in [0x2, 0x4, 0x8]:
98
+ raise ValueError("id_intsize must be either 2, 4 or 8.")
99
+ self.infiles = infiles
100
+ self.version = version
101
+ self.align = align
102
+ self.subkey = subkey
103
+ self.id_intsize = id_intsize
104
+
105
+ def _stringtypes(self, intsize: int) -> str:
106
+ if intsize == 1:
107
+ return "B" # Probably impossible.
108
+ elif intsize == 2:
109
+ return "H"
110
+ elif intsize == 4:
111
+ return "I"
112
+ elif intsize == 8:
113
+ return "Q"
114
+ else:
115
+ raise ValueError("Unknown int size.")
116
+
117
+ def build(self) -> bytes:
118
+ """Builds the AWB file from the provided infiles bytes."""
119
+ size = 0
120
+ ofs = []
121
+ numfiles = 0
122
+ for file in self.infiles:
123
+ sz = len(file)
124
+ ofs.append(size+sz)
125
+ size += sz
126
+ numfiles += 1
127
+
128
+ if size > 0xFFFFFFFF:
129
+ intsize = 8 # Unsigned long long.
130
+ strtype = "<Q"
131
+ else:
132
+ intsize = 4 # Unsigned int, but could be a ushort, never saw it as one before though.
133
+ strtype = "<I"
134
+
135
+ header = AWBChunkHeader.pack(
136
+ b'AFS2', self.version, intsize, self.id_intsize, numfiles, self.align, self.subkey
137
+ )
138
+
139
+ id_strsize = f"<{self._stringtypes(self.id_intsize)}"
140
+ for i in range(numfiles):
141
+ header += pack(id_strsize, i)
142
+
143
+ headersize = len(header) + intsize * numfiles + intsize
144
+ aligned_header_size = headersize + (self.align - (headersize % self.align))
145
+ ofs2 = []
146
+ for idx, x in enumerate(ofs):
147
+ if (x+aligned_header_size) % self.align != 0 and idx != len(ofs) - 1:
148
+ ofs2.append((x+aligned_header_size) + (self.align - ((x+aligned_header_size) % self.align)))
149
+ else:
150
+ ofs2.append(x+aligned_header_size)
151
+ ofs = [headersize] + ofs2
152
+
153
+ for i in ofs:
154
+ header += pack(strtype, i)
155
+
156
+ if headersize % self.align != 0:
157
+ header = header.ljust(headersize + (self.align - (headersize % self.align)), b"\x00")
158
+ outfile = BytesIO()
159
+ outfile.write(header)
160
+ for idx, file in enumerate(self.infiles):
161
+ fl = file
162
+ if len(fl) % self.align != 0 and idx != len(self.infiles) - 1:
163
+ fl = fl.ljust(len(fl) + (self.align - (len(fl) % self.align)), b"\x00")
164
+ outfile.write(fl)
165
+ return outfile.getvalue()