"""Support for reading the GARC generic container format used in the 3DS filesystem. Based on code by Zhorken: https://github.com/Zhorken/pokemon-x-y-icons and Kaphotics: https://github.com/kwsch/GARCTool """ from collections import Counter from io import BytesIO from pathlib import Path import struct import sys import construct as c from . import lzss3 from .base import _ContainerFile, Substream from .pc import PokemonContainerFile def count_bits(n): c = 0 while n: c += n & 1 n >>= 1 return c garc_header_struct = c.Struct( c.Const(b'CRAG'), 'header_size' / c.Int32ul, # 28 in XY, 36 in SUMO 'byte_order' / c.Const(c.Int16ul, 0xfeff), 'mystery1' / c.Int16ul, # 0x0400 in XY, 0x0600 in SUMO #c.Const(c.ULInt32('chunks_ct'), 4), 'chunks_ct' / c.Int32ul, 'data_offset' / c.Int32ul, 'garc_length' / c.Int32ul, 'last_length' / c.Int32ul, 'unknown_sumo_stuff' / c.Bytes(lambda ctx: ctx.header_size - 28), ) fato_header_struct = c.Struct( c.Const(b'OTAF'), 'header_size' / c.Int32ul, 'count' / c.Int16ul, c.Const(c.Int16ul, 0xffff), 'fatb_offsets' / c.Array(c.this.count, c.Int32ul), ) fatb_header_struct = c.Struct( c.Const(b'BTAF'), 'fatb_length' / c.Int32ul, 'count' / c.Int32ul, ) class GARCFile(_ContainerFile): def __init__(self, stream): self.stream = stream = Substream(stream) garc_header = garc_header_struct.parse_stream(self.stream) # FATO (file allocation table... offsets?) fato_header = fato_header_struct.parse_stream(self.stream) # FATB (file allocation table) fatb_header = fatb_header_struct.parse_stream(self.stream) fatb_start = garc_header.header_size + fato_header.header_size assert stream.tell() == fatb_start + 12 self.slices = [] for i, offset in enumerate(fato_header.fatb_offsets): stream.seek(fatb_start + offset + 12) slices = [] bits, = struct.unpack('>= 1 self.slices.append(GARCEntry(stream, slices)) # FIMB stream.seek(fatb_start + fatb_header.fatb_length) magic, fimb_header_length, fimb_length = struct.unpack( '<4s2L', stream.read(12)) assert magic == b'BMIF' assert fimb_header_length == 0xC class GARCEntry(object): def __init__(self, stream, slices): self.stream = stream self.slices = slices def __getitem__(self, i): start, length = self.slices[i] ss = self.stream.slice(start, length) if ss.peek(1) in b'\x10\x11': # XXX this sucks but there's no real way to know for sure whether # data is compressed or not. maybe just bake this into the caller # and let them deal with it, same way we do with text decoding? # TODO it would be nice if this could be done lazily for 'inspect' # purposes, since the first four bytes are enough to tell you the # size # FIXME make this work even for red herrings, maybe by finishing it # up and doing a trial decompression of the first x bytes #return CompressedStream(ss) try: data = lzss3.decompress_bytes(ss.read()) except Exception: ss.seek(0) else: return Substream(BytesIO(data)) return ss def __len__(self): return len(self.slices) class CompressedStream: def __init__(self, stream): self.stream = stream header = stream.read(4) stream.seek(0) assert header[0] in b'\x10\x11' self.length, = struct.unpack('> 13)) & 0xffff def _xy_outer_keygen(): key = 0x7c89 while True: yield _xy_inner_keygen(key) key = (key + 0x2983) & 0xffff def decrypt_xy_text(data): text_sections, lines, length, initial_key, section_data = struct.unpack_from( '= 16: text_length = int.from_bytes(header[4:8], 'little') header_length = int.from_bytes(header[12:16], 'little') if len(subfile) == text_length + header_length: return 'gen 6 text' return None def do_inspect(args): root = Path(args.path) if root.is_dir(): for path in sorted(root.glob('**/*')): if path.is_dir(): continue shortname = str(path.relative_to(root)) if len(shortname) > 12: shortname = '...' + shortname[-9:] stat = path.stat() print("{:>12s} {:>10d} ".format(shortname, stat.st_size), end='') if stat.st_size == 0: print("empty file") continue with path.open('rb') as f: try: garc = GARCFile(f) except Exception as exc: print("{}: {}".format(type(exc).__name__, exc)) continue total_subfiles = 0 magic_ctr = Counter() size_ctr = Counter() for i, topfile in enumerate(garc): for j, subfile in enumerate(topfile): total_subfiles += 1 size_ctr[len(subfile)] += 1 magic_ctr[detect_subfile_type(subfile)] += 1 print("{} subfiles".format(total_subfiles), end='') if total_subfiles > len(garc): print(" (some nested)") else: print() cutoff = max(total_subfiles // 10, 1) for magic, ct in magic_ctr.most_common(): if ct < cutoff: break print(" " * 24, "{:4d} x {:>9s}".format(ct, magic or 'unknown')) for size, ct in size_ctr.most_common(): if ct < cutoff: break print(" " * 24, "{:4d} x {:9d}".format(ct, size)) return with open(args.path, 'rb') as f: garc = GARCFile(f) for i, topfile in enumerate(garc): for j, subfile in enumerate(topfile): print("{:4d}/{:<4d} {:7d}B".format(i, j, len(subfile)), end='') magic = detect_subfile_type(subfile) if magic == 'PC': print(" -- appears to be a PC file (generic container)") pcfile = PokemonContainerFile(subfile) for k, entry in enumerate(pcfile): print(' ', repr(entry.read(50))) elif magic == 'gen 6 text': # TODO turn this into a generator so it doesn't have to # parse the whole thing? need length though texts = decrypt_xy_text(subfile.read()) print(" -- X/Y text, {} entries: {!r}".format(len(texts), texts[:5]), texts[-5:]) else: print('', repr(subfile.read(50))) def do_extract(args): with open(args.path, 'rb') as f: garc = GARCFile(f) # TODO shouldn't path really be a directory, so you can mass-extract everything? do i want to do that ever? # TODO actually respect mode, fileno, entryno for i, topfile in enumerate(garc): # TODO i guess this should be a list, or?? if args.fileno is not all and args.fileno != i: continue for j, subfile in enumerate(topfile): # TODO auto-detect extension, maybe? depending on mode? outfile = Path("{}-{}-{}".format(args.out, i, j)) with outfile.open('wb') as g: # TODO should use copyfileobj g.write(subfile.read()) print("wrote", outfile) def make_arg_parser(): from argparse import ArgumentParser p = ArgumentParser() sp = p.add_subparsers(metavar='command') inspect_p = sp.add_parser('inspect', help='examine a particular file') inspect_p.set_defaults(cb=do_inspect) inspect_p.add_argument('path', help='relative path to a game file') inspect_p.add_argument('mode', nargs='?', default='shorthex') inspect_p.add_argument('fileno', nargs='?', default=all) inspect_p.add_argument('entryno', nargs='?', default=all) extract_p = sp.add_parser('extract', help='extract contents of a file') extract_p.set_defaults(cb=do_extract) extract_p.add_argument('path', help='relative path to a game file') extract_p.add_argument('out', help='filename to use for extraction') extract_p.add_argument('mode', nargs='?', default='raw') extract_p.add_argument('fileno', nargs='?', default=all) extract_p.add_argument('entryno', nargs='?', default=all) return p if __name__ == '__main__': main(sys.argv[1:])