diff --git a/bemani/utils/afputils.py b/bemani/utils/afputils.py index f3f0d91..7e6e66b 100644 --- a/bemani/utils/afputils.py +++ b/bemani/utils/afputils.py @@ -11,259 +11,333 @@ from typing import Any, List, Optional from bemani.format.dxt import DXTBuffer from bemani.protocol.binary import BinaryEncoding from bemani.protocol.lz77 import Lz77 +from bemani.protocol.node import Node -# Coverage tracker to help find missing chunks. -coverage: List[bool] +class PMAN: + def __init__(self, entries: List[str] = []) -> None: + self.entries = entries -def add_coverage(offset: int, length: int, unique: bool = True) -> None: - global coverage - for i in range(offset, offset + length): - if coverage[i] and unique: - raise Exception(f"Already covered {hex(offset)}!") - coverage[i] = True +class Texture: + def __init__( + self, + name: str, + width: int, + height: int, + fmt: int, + header_flags1: int, + header_flags2: int, + header_flags3: int, + unk_flags1: int, + unk_flags2: int, + unk_flags3: int, + unk_flags4: int, + rawdata: bytes, + imgdata: Any, + ) -> None: + self.name = name + self.width = width + self.height = height + self.fmt = fmt + self.header_flags1 = header_flags1 + self.header_flags2 = header_flags2 + self.header_flags3 = header_flags3 + self.unk_flags1 = unk_flags1 + self.unk_flags2 = unk_flags2 + self.unk_flags3 = unk_flags3 + self.unk_flags4 = unk_flags4 + self.raw = rawdata + self.img = imgdata -def print_coverage() -> None: - global coverage +class TextureRegion: + def __init__(self, textureno: int, left: int, top: int, right: int, bottom: int) -> None: + self.textureno = textureno + self.left = left + self.top = top + self.right = right + self.bottom = bottom - # First offset that is not coverd in a run. - start = None - for offset, covered in enumerate(coverage): - if covered: - if start is not None: - print(f"Uncovered: {hex(start)} - {hex(offset)} ({offset-start} bytes)") - start = None +class Animation: + def __init__( + self, + name: str, + data: bytes, + header: bytes = b"", + ) -> None: + self.name = name + self.data = data + self.header = header + + +class AFPFile: + def __init__(self, contents: bytes, verbose: bool = False) -> None: + # Initialize coverage. This is used to help find missed/hidden file + # sections that we aren't parsing correctly. + self.coverage: List[bool] = [False] * len(contents) + + # Original file data that we parse into structures. + self.data = contents + + # All of the crap! + self.endian: str = "<" + self.features: int = 0 + self.text_obfuscated: bool = False + self.legacy_lz: bool = False + self.modern_lz: bool = False + + # List of all textures in this file. This is unordered, textures should + # be looked up by name. + self.textures: List[Texture] = [] + + # Texture mapping, which allows other structures to refer to texture + # by number instead of name. + self.texturemap: PMAN = PMAN() + + # List of all regions found inside textures, mapped to their textures + # using texturenos that can be looked up using the texturemap above. + # This structure is ordered, and the regionno from the regionmap + # below can be used to look into this structure. + self.texture_to_region: List[TextureRegion] = [] + + # Region mapping, which allows other structures to refer to regions + # by number instead of name. + self.regionmap: PMAN = PMAN() + + # Animations(?) and their names found in this file. This is unordered, + # animations should be looked up by name. + self.animations: List[Animation] = [] + + # Animation(?) mapping, which allows other structures to refer to + # animations by number instead of name. + self.animmap: PMAN = PMAN() + + # Font information (mapping for various coepoints to their region in + # a particular font texture. + self.fontdata: Optional[Node] = None + + # Parse out the file structure. + self.__parse(verbose) + + def add_coverage(self, offset: int, length: int, unique: bool = True) -> None: + for i in range(offset, offset + length): + if self.coverage[i] and unique: + raise Exception(f"Already covered {hex(offset)}!") + self.coverage[i] = True + + def print_coverage(self) -> None: + # First offset that is not coverd in a run. + start = None + + for offset, covered in enumerate(self.coverage): + if covered: + if start is not None: + print(f"Uncovered: {hex(start)} - {hex(offset)} ({offset-start} bytes)") + start = None + else: + if start is None: + start = offset + if start is not None: + # Print final range + offset = len(self.coverage) + print(f"Uncovered: {hex(start)} - {hex(offset)} ({offset-start} bytes)") + + @staticmethod + def cap32(val: int) -> int: + return val & 0xFFFFFFFF + + @staticmethod + def poly(val: int) -> int: + if (val >> 31) & 1 != 0: + return 0x4C11DB7 else: - if start is None: - start = offset - if start is not None: - # Print final range - offset = len(coverage) - print(f"Uncovered: {hex(start)} - {hex(offset)} ({offset-start} bytes)") + return 0 + @staticmethod + def crc32(bytestream: bytes) -> int: + # Janky 6-bit CRC for ascii names in PMAN structures. + result = 0 + for byte in bytestream: + for i in range(6): + result = AFPFile.poly(result) ^ AFPFile.cap32((result << 1) | ((byte >> i) & 1)) + return result -def get_until_null(data: bytes, offset: int) -> bytes: - out = b"" - while data[offset] != 0: - out += data[offset:(offset + 1)] - offset += 1 - return out - - -def cap32(val: int) -> int: - return val & 0xFFFFFFFF - - -def poly(val: int) -> int: - if (val >> 31) & 1 != 0: - return 0x4C11DB7 - else: - return 0 - - -def crc32(bytestream: bytes) -> int: - # Janky 6-bit CRC for ascii names in PMAN structures. - result = 0 - for byte in bytestream: - for i in range(6): - result = poly(result) ^ cap32((result << 1) | ((byte >> i) & 1)) - return result - - -def descramble_text(text: bytes, obfuscated: bool) -> str: - if len(text): - if obfuscated and (text[0] - 0x20) > 0x7F: - # Gotta do a weird demangling where we swap the - # top bit. - return bytes(((x + 0x80) & 0xFF) for x in text).decode('ascii') + @staticmethod + def descramble_text(text: bytes, obfuscated: bool) -> str: + if len(text): + if obfuscated and (text[0] - 0x20) > 0x7F: + # Gotta do a weird demangling where we swap the + # top bit. + return bytes(((x + 0x80) & 0xFF) for x in text).decode('ascii') + else: + return text.decode('ascii') else: - return text.decode('ascii') - else: - return "" + return "" + def get_until_null(self, offset: int) -> bytes: + out = b"" + while self.data[offset] != 0: + out += self.data[offset:(offset + 1)] + offset += 1 + return out -def descramble_pman(package_data: bytes, offset: int, endian: str, obfuscated: bool) -> List[str]: - # Unclear what the first three unknowns are, but the fourth - # looks like it could possibly be two int16s indicating unknown? - magic, expect_zero, flags1, flags2, numentries, flags3, data_offset = struct.unpack( - f"{endian}4sIIIIII", - package_data[offset:(offset + 28)], - ) - add_coverage(offset, 28) + def descramble_pman(self, offset: int) -> PMAN: + # Unclear what the first three unknowns are, but the fourth + # looks like it could possibly be two int16s indicating unknown? + magic, expect_zero, flags1, flags2, numentries, flags3, data_offset = struct.unpack( + f"{self.endian}4sIIIIII", + self.data[offset:(offset + 28)], + ) + self.add_coverage(offset, 28) - # I have never seen the first unknown be anything other than zero, - # so lets lock that down. - if expect_zero != 0: - raise Exception("Got a non-zero value for expected zero location in PMAN!") + # I have never seen the first unknown be anything other than zero, + # so lets lock that down. + if expect_zero != 0: + raise Exception("Got a non-zero value for expected zero location in PMAN!") - if endian == "<" and magic != b"PMAN": - raise Exception("Invalid magic value in PMAN structure!") - if endian == ">" and magic != b"NAMP": - raise Exception("Invalid magic value in PMAN structure!") + if self.endian == "<" and magic != b"PMAN": + raise Exception("Invalid magic value in PMAN structure!") + if self.endian == ">" and magic != b"NAMP": + raise Exception("Invalid magic value in PMAN structure!") - names: List[Optional[str]] = [None] * numentries - if numentries > 0: - # Jump to the offset, parse it out - for i in range(numentries): - file_offset = data_offset + (i * 12) - name_crc, entry_no, nameoffset = struct.unpack( - f"{endian}III", - package_data[file_offset:(file_offset + 12)], - ) - add_coverage(file_offset, 12) - - if nameoffset == 0: - raise Exception("Expected name offset in PMAN data!") - - bytedata = get_until_null(package_data, nameoffset) - add_coverage(nameoffset, len(bytedata) + 1, unique=False) - name = descramble_text(bytedata, obfuscated) - names[entry_no] = name - - if name_crc != crc32(name.encode('ascii')): - raise Exception(f"Name CRC failed for {name}") - - for i, name in enumerate(names): - if name is None: - raise Exception(f"Didn't get mapping for entry {i + 1}") - - return names - - -def extract( - filename: str, - output_dir: str, *, - write: bool = True, - verbose: bool = False, - raw: bool = False, - xml: bool = False, -) -> None: - with open(filename, "rb") as fp: - data = fp.read() - - # Initialize coverage. This is used to help find missed/hidden file - # sections that we aren't parsing correctly. - global coverage - coverage = [False] * len(data) - - # Suppress debug text unless asked - if verbose: - vprint = print - else: - def vprint(*args: Any, **kwargs: Any) -> None: # type: ignore - pass - - # First, check the signature - add_coverage(0, 4) - if data[0:4] == b"2PXT": - endian = "<" - elif data[0:4] == b"TXP2": - endian = ">" - else: - raise Exception("Invalid graphic file format!") - - # Not sure what words 2 and 3 are, they seem to be some sort of - # version or date? - add_coverage(4, 8) - - # Now, grab the file length, verify that we have the right amount - # of data. - length = struct.unpack(f"{endian}I", data[12:16])[0] - add_coverage(12, 4) - if length != len(data): - raise Exception(f"Invalid graphic file length, expecting {length} bytes!") - - # I think that offset 16-20 are the file data offset, but I'm not sure? - header_length = struct.unpack(f"{endian}I", data[16:20])[0] - add_coverage(16, 4) - - # Now, the meat of the file format. Bytes 20-24 are a bitfield for - # what parts of the header exist in the file. We need to understand - # each bit so we know how to skip past each section. - feature_mask = struct.unpack(f"{endian}I", data[20:24])[0] - add_coverage(20, 4) - header_offset = 24 - - # Lots of magic happens if this bit is set. - text_obfuscated = bool(feature_mask & 0x20) - legacy_lz = bool(feature_mask & 0x04) - modern_lz = bool(feature_mask & 0x40000) - - # Get raw directory where we want to put files - path = os.path.abspath(output_dir) - - if feature_mask & 0x01: - # List of textures that exist in the file, with pointers to their data. - length, offset = struct.unpack(f"{endian}II", data[header_offset:(header_offset + 8)]) - add_coverage(header_offset, 8) - header_offset += 8 - - names = [] - for x in range(length): - interesting_offset = offset + (x * 12) - if interesting_offset != 0: - name_offset, texture_length, texture_offset = struct.unpack( - f"{endian}III", - data[interesting_offset:(interesting_offset + 12)], + names: List[Optional[str]] = [None] * numentries + if numentries > 0: + # Jump to the offset, parse it out + for i in range(numentries): + file_offset = data_offset + (i * 12) + name_crc, entry_no, nameoffset = struct.unpack( + f"{self.endian}III", + self.data[file_offset:(file_offset + 12)], ) - add_coverage(interesting_offset, 12) + self.add_coverage(file_offset, 12) - if name_offset != 0: - # Let's decode this until the first null. - bytedata = get_until_null(data, name_offset) - add_coverage(name_offset, len(bytedata) + 1, unique=False) - name = descramble_text(bytedata, text_obfuscated) - names.append(name) + if nameoffset == 0: + raise Exception("Expected name offset in PMAN data!") - if texture_offset != 0: - filename = os.path.join(path, name) + bytedata = self.get_until_null(nameoffset) + self.add_coverage(nameoffset, len(bytedata) + 1, unique=False) + name = AFPFile.descramble_text(bytedata, self.text_obfuscated) + names[entry_no] = name - if legacy_lz: - raise Exception("We don't support legacy lz mode!") - elif modern_lz: - # Get size, round up to nearest power of 4 - inflated_size, deflated_size = struct.unpack( - ">II", - data[texture_offset:(texture_offset + 8)], - ) - add_coverage(texture_offset, 8) - if deflated_size != (texture_length - 8): - raise Exception("We got an incorrect length for lz texture!") - inflated_size = (inflated_size + 3) & (~3) + if name_crc != AFPFile.crc32(name.encode('ascii')): + raise Exception(f"Name CRC failed for {name}") - # Get the data offset - lz_data_offset = texture_offset + 8 - lz_data = data[lz_data_offset:(lz_data_offset + deflated_size)] - add_coverage(lz_data_offset, deflated_size) + for i, name in enumerate(names): + if name is None: + raise Exception(f"Didn't get mapping for entry {i + 1}") - # This takes forever, so skip it if we're pretending. - if write: - print(f"Inflating {filename}...") + return PMAN( + entries=names + ) + + def __parse( + self, + verbose: bool = False, + ) -> None: + # Suppress debug text unless asked + if verbose: + vprint = print + else: + def vprint(*args: Any, **kwargs: Any) -> None: # type: ignore + pass + + # First, check the signature + self.add_coverage(0, 4) + if self.data[0:4] == b"2PXT": + self.endian = "<" + elif self.data[0:4] == b"TXP2": + self.endian = ">" + else: + raise Exception("Invalid graphic file format!") + + # Not sure what words 2 and 3 are, they seem to be some sort of + # version or date? + self.add_coverage(4, 8) + + # Now, grab the file length, verify that we have the right amount + # of data. + length = struct.unpack(f"{self.endian}I", self.data[12:16])[0] + self.add_coverage(12, 4) + if length != len(self.data): + raise Exception(f"Invalid graphic file length, expecting {length} bytes!") + + # I think that offset 16-20 are the file data offset, but I'm not sure? + header_length = struct.unpack(f"{self.endian}I", self.data[16:20])[0] + self.add_coverage(16, 4) + + # Now, the meat of the file format. Bytes 20-24 are a bitfield for + # what parts of the header exist in the file. We need to understand + # each bit so we know how to skip past each section. + feature_mask = struct.unpack(f"{self.endian}I", self.data[20:24])[0] + self.add_coverage(20, 4) + header_offset = 24 + + # Lots of magic happens if this bit is set. + self.text_obfuscated = bool(feature_mask & 0x20) + self.legacy_lz = bool(feature_mask & 0x04) + self.modern_lz = bool(feature_mask & 0x40000) + self.features = feature_mask + + if feature_mask & 0x01: + # List of textures that exist in the file, with pointers to their data. + length, offset = struct.unpack(f"{self.endian}II", self.data[header_offset:(header_offset + 8)]) + self.add_coverage(header_offset, 8) + header_offset += 8 + + texturenames = [] + for x in range(length): + interesting_offset = offset + (x * 12) + if interesting_offset != 0: + name_offset, texture_length, texture_offset = struct.unpack( + f"{self.endian}III", + self.data[interesting_offset:(interesting_offset + 12)], + ) + self.add_coverage(interesting_offset, 12) + + if name_offset != 0: + # Let's decode this until the first null. + bytedata = self.get_until_null(name_offset) + self.add_coverage(name_offset, len(bytedata) + 1, unique=False) + name = AFPFile.descramble_text(bytedata, self.text_obfuscated) + texturenames.append(name) + + if texture_offset != 0: + if self.legacy_lz: + raise Exception("We don't support legacy lz mode!") + elif self.modern_lz: + # Get size, round up to nearest power of 4 + inflated_size, deflated_size = struct.unpack( + ">II", + self.data[texture_offset:(texture_offset + 8)], + ) + self.add_coverage(texture_offset, 8) + if deflated_size != (texture_length - 8): + raise Exception("We got an incorrect length for lz texture!") + inflated_size = (inflated_size + 3) & (~3) + + # Get the data offset. + lz_data_offset = texture_offset + 8 + lz_data = self.data[lz_data_offset:(lz_data_offset + deflated_size)] + self.add_coverage(lz_data_offset, deflated_size) + + # This takes forever, so skip it if we're pretending. lz77 = Lz77() raw_data = lz77.decompress(lz_data) else: - raw_data = None - else: - inflated_size, deflated_size = struct.unpack( - ">II", - data[texture_offset:(texture_offset + 8)], - ) + inflated_size, deflated_size = struct.unpack( + ">II", + self.data[texture_offset:(texture_offset + 8)], + ) - # I'm guessing how raw textures work because I haven't seen them. - # I assume they're like the above, so lets put in some asertions. - if deflated_size != (texture_length - 8): - raise Exception("We got an incorrect length for raw texture!") - raw_data = data[(texture_offset + 8):(texture_offset + 8 + deflated_size)] - add_coverage(texture_offset, deflated_size + 8) + # I'm guessing how raw textures work because I haven't seen them. + # I assume they're like the above, so lets put in some asertions. + if deflated_size != (texture_length - 8): + raise Exception("We got an incorrect length for raw texture!") + raw_data = self.data[(texture_offset + 8):(texture_offset + 8 + deflated_size)] + self.add_coverage(texture_offset, deflated_size + 8) - if not write: - print(f"Would write {filename} texture data...") - else: - # Now, see if we can extract this data. - print(f"Writing {filename} texture data...") ( magic, header_flags1, @@ -275,7 +349,7 @@ def extract( expected_zero1, expected_zero2, ) = struct.unpack( - f"{endian}4sIIIHHIII", + f"{self.endian}4sIIIHHIII", raw_data[0:32], ) if length != len(raw_data): @@ -290,25 +364,25 @@ def extract( # round-trip it if we want to write files back out. I have no clue what it's for. # I've seen it be 1 only on files used for fonts so far, but I am not sure there # is any correlation there. - header_flags3 = struct.unpack(f"{endian}I", raw_data[44:48])[0] + header_flags3 = struct.unpack(f"{self.endian}I", raw_data[44:48])[0] if raw_data[48:64] != b'\0' * 16: raise Exception("Found unexpected non-zero value in texture header!") fmt = fmtflags & 0xFF # Extract flags that the game cares about. - # flags1 = (fmtflags >> 24) & 0xFF - # flags2 = (fmtflags >> 16) & 0xFF + flags1 = (fmtflags >> 24) & 0xFF + flags2 = (fmtflags >> 16) & 0xFF # These flags may have some significance, such as # the unk3/unk4 possibly indicating texture doubling? - # unk1 = 3 if (flags1 & 0xF == 1) else 1 - # unk2 = 3 if ((flags1 >> 4) & 0xF == 1) else 1 - # unk3 = 1 if (flags2 & 0xF == 1) else 2 - # unk4 = 1 if ((flags2 >> 4) & 0xF == 1) else 2 + unk1 = 3 if (flags1 & 0xF == 1) else 1 + unk2 = 3 if ((flags1 >> 4) & 0xF == 1) else 1 + unk3 = 1 if (flags2 & 0xF == 1) else 2 + unk4 = 1 if ((flags2 >> 4) & 0xF == 1) else 2 - if endian == "<" and magic != b"TDXT": + if self.endian == "<" and magic != b"TDXT": raise Exception("Unexpected texture format!") - if endian == ">" and magic != b"TXDT": + if self.endian == ">" and magic != b"TXDT": raise Exception("Unexpected texture format!") if fmt == 0x0B: @@ -316,7 +390,7 @@ def extract( newdata = [] for i in range(width * height): pixel = struct.unpack( - f"{endian}H", + f"{self.endian}H", raw_data[(64 + (i * 2)):(66 + (i * 2))], )[0] red = ((pixel >> 0) & 0x1F) << 3 @@ -348,7 +422,7 @@ def extract( img = Image.frombuffer( 'RGBA', (width, height), - dxt.DXT5Decompress(raw_data[64:], endian=endian), + dxt.DXT5Decompress(raw_data[64:], endian=self.endian), 'raw', 'RGBA', 0, @@ -360,7 +434,7 @@ def extract( newdata = [] for i in range(width * height): pixel = struct.unpack( - f"{endian}H", + f"{self.endian}H", raw_data[(64 + (i * 2)):(66 + (i * 2))], )[0] blue = ((pixel >> 0) & 0xF) << 4 @@ -379,422 +453,368 @@ def extract( 'RGBA', (width, height), raw_data[64:], 'raw', 'BGRA', ) else: - print(f"Unsupported format {hex(fmt)} for texture {name}") + vprint(f"Unsupported format {hex(fmt)} for texture {name}") img = None - # Actually place the file down. - os.makedirs(path, exist_ok=True) - if img: - with open(f"{filename}.png", "wb") as bfp: - img.save(bfp, format='PNG') - if not img or raw: - with open(f"{filename}.raw", "wb") as bfp: - bfp.write(raw_data) - if xml: - with open(f"{filename}.xml", "w") as sfp: - sfp.write(textwrap.dedent(f""" - - {width} - {height} - {hex(fmt)} - {filename}.raw - - """).strip()) + self.textures.append( + Texture( + name, + width, + height, + fmt, + header_flags1, + header_flags2, + header_flags3, + unk1, + unk2, + unk3, + unk4, + raw_data, + img, + ) + ) - vprint(f"Bit 0x000001 - count: {length}, offset: {hex(offset)}") - for name in names: - vprint(f" {name}") - else: - vprint("Bit 0x000001 - NOT PRESENT") - - # Mapping between texture index and the name of the texture. - texturemap = [] - if feature_mask & 0x02: - # Seems to be a structure that duplicates texture names? I am pretty - # sure this is used to map texture names to file indexes used elsewhere. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 - - vprint(f"Bit 0x000002 - offset: {hex(offset)}") - - if offset != 0: - texturemap = descramble_pman(data, offset, endian, text_obfuscated) - for i, name in enumerate(texturemap): - vprint(f" {i}: {name}") - else: - vprint("Bit 0x000002 - NOT PRESENT") - - if feature_mask & 0x04: - vprint("Bit 0x000004 - legacy lz mode on") - else: - vprint("Bit 0x000004 - legacy lz mode off") - - # Mapping between region index and the texture it goes to as well as the - # region of texture that this particular graphic makes up. - texture_to_region = [] - if feature_mask & 0x08: - # Mapping between individual graphics and their respective textures. - # This is 10 bytes per entry. Seems to need both 0x2 (texture index) - # and 0x10 (region index). - length, offset = struct.unpack(f"{endian}II", data[header_offset:(header_offset + 8)]) - add_coverage(header_offset, 8) - header_offset += 8 - - if offset != 0 and length > 0: - texture_to_region = [(0, (0, 0), (0, 0))] * length - - for i in range(length): - descriptor_offset = offset + (10 * i) - texture_no, left, top, right, bottom = struct.unpack( - f"{endian}HHHHH", - data[descriptor_offset:(descriptor_offset + 10)], - ) - add_coverage(descriptor_offset, 10) - - if texture_no < 0 or texture_no >= len(texturemap): - raise Exception(f"Out of bounds texture {texture_no}") - - # TODO: The offsets here seem to be off by a power of 2, there - # might be more flags in the above texture format that specify - # device scaling and such? - texture_to_region[i] = (texture_no, (left, top), (right, bottom)) - - vprint(f"Bit 0x000008 - count: {length}, offset: {hex(offset)}") - else: - vprint("Bit 0x000008 - NOT PRESENT") - - if feature_mask & 0x10: - # Names of the graphics regions, so we can look into the texture_to_region - # mapping above. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 - - vprint(f"Bit 0x000010 - offset: {hex(offset)}") - - if offset != 0: - names = descramble_pman(data, offset, endian, text_obfuscated) - for i, name in enumerate(names): - if i < 0 or i >= len(texture_to_region): - raise Exception(f"Out of bounds region {i}") - region = texture_to_region[i] - texture = texturemap[region[0]] - - filename = os.path.join(path, name) - if write: - # Actually place the file down. - os.makedirs(path, exist_ok=True) - - if xml: - print(f"Writing {filename}.xml graphic information...") - with open(f"{filename}.xml", "w") as sfp: - sfp.write(textwrap.dedent(f""" - - {region[1][0]} - {region[1][1]} - {region[2][0]} - {region[2][1]} - {texture} - - """).strip()) - else: - if xml: - print(f"Would write {filename}.xml graphic information...") - - vprint(f" {i}: {name}") - else: - vprint("Bit 0x000010 - NOT PRESENT") - - if feature_mask & 0x20: - vprint(f"Bit 0x000020 - text obfuscation on") - else: - vprint(f"Bit 0x000020 - text obfuscation off") - - if feature_mask & 0x40: - # Two unknown bytes, first is a length or a count. Secound is - # an optional offset to grab another set of bytes from. - length, offset = struct.unpack(f"{endian}II", data[header_offset:(header_offset + 8)]) - add_coverage(header_offset, 8) - header_offset += 8 - - # TODO: 0x40 has some weird offset calculations, gotta look into - # this further. - - vprint(f"Bit 0x000040 - count: {length}, offset: {hex(offset)}") - for name in names: - vprint(f" {name}") - else: - vprint("Bit 0x000040 - NOT PRESENT") - - if feature_mask & 0x80: - # One unknown byte, treated as an offset. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 - - vprint(f"Bit 0x000080 - offset: {hex(offset)}") - - if offset != 0: - names = descramble_pman(data, offset, endian, text_obfuscated) - for i, name in enumerate(names): - vprint(f" {i}: {name}") - else: - vprint("Bit 0x000080 - NOT PRESENT") - - if feature_mask & 0x100: - # Two unknown bytes, first is a length or a count. Secound is - # an optional offset to grab another set of bytes from. - length, offset = struct.unpack(f"{endian}II", data[header_offset:(header_offset + 8)]) - add_coverage(header_offset, 8) - header_offset += 8 - - vprint(f"Bit 0x000100 - count: {length}, offset: {hex(offset)}") - - # TODO: We do something if length is > 0, we use the magic flag - # from above in this case to optionally transform each thing we - # extract. This is possibly names of some other type of struture? - else: - vprint("Bit 0x000100 - NOT PRESENT") - - if feature_mask & 0x200: - # One unknown byte, treated as an offset. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 - - vprint(f"Bit 0x000200 - offset: {hex(offset)}") - - if offset != 0: - names = descramble_pman(data, offset, endian, text_obfuscated) - for i, name in enumerate(names): - vprint(f" {i}: {name}") - else: - vprint("Bit 0x000200 - NOT PRESENT") - - if feature_mask & 0x400: - # One unknown byte, treated as an offset. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 - - vprint(f"Bit 0x000400 - offset: {hex(offset)}") - else: - vprint("Bit 0x000400 - NOT PRESENT") - - if feature_mask & 0x800: - # This is the names of the animations as far as I can tell. - length, offset = struct.unpack(f"{endian}II", data[header_offset:(header_offset + 8)]) - add_coverage(header_offset, 8) - header_offset += 8 - - pp_19 = length - pp_20 = offset - - vprint(f"Bit 0x000800 - count: {length}, offset: {hex(offset)}") - - names = [] - for x in range(length): - interesting_offset = offset + (x * 12) - if interesting_offset != 0: - name_offset, anim_length, anim_offset = struct.unpack( - f"{endian}III", - data[interesting_offset:(interesting_offset + 12)], - ) - add_coverage(interesting_offset, 12) - if name_offset != 0: - # Let's decode this until the first null. - bytedata = get_until_null(data, name_offset) - add_coverage(name_offset, len(bytedata) + 1, unique=False) - name = descramble_text(bytedata, text_obfuscated) - names.append(name) - - for name in names: - vprint(f" {name}") - else: - vprint("Bit 0x000800 - NOT PRESENT") - pp_19 = 0 - pp_20 = 0 - - if feature_mask & 0x1000: - # Seems to be a secondary structure mirroring the above. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 - - vprint(f"Bit 0x001000 - offset: {hex(offset)}") - - if offset != 0: - names = descramble_pman(data, offset, endian, text_obfuscated) - for i, name in enumerate(names): - vprint(f" {i}: {name}") - else: - vprint("Bit 0x001000 - NOT PRESENT") - - if feature_mask & 0x2000: - # I am making a very preliminary guess that these are shapes used along - # with animations specified below. The names in these sections tend to - # have the word "shape" in them. - length, offset = struct.unpack(f"{endian}II", data[header_offset:(header_offset + 8)]) - add_coverage(header_offset, 8) - header_offset += 8 - - vprint(f"Bit 0x002000 - count: {length}, offset: {hex(offset)}") - - # TODO: We do a LOT of extra stuff with this one, if count > 0... - - names = [] - for x in range(length): - shape_base_offset = offset + (x * 12) - if shape_base_offset != 0: - name_offset, shape_length, shape_offset = struct.unpack( - f"{endian}III", - data[shape_base_offset:(shape_base_offset + 12)], - ) - add_coverage(shape_base_offset, 12) - add_coverage(shape_offset, shape_length) - - # TODO: At the shape offset is a "D2EG" structure of some sort. - # I have no idea what these do. I would have to look into it - # more if its important. - - if name_offset != 0: - # Let's decode this until the first null. - bytedata = get_until_null(data, name_offset) - add_coverage(name_offset, len(bytedata) + 1, unique=False) - name = descramble_text(bytedata, text_obfuscated) - names.append(name) - - for name in names: - vprint(f" {name}") - else: - vprint("Bit 0x002000 - NOT PRESENT") - - if feature_mask & 0x4000: - # Seems to be a secondary section mirroring the names from above. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 - - vprint(f"Bit 0x004000 - offset: {hex(offset)}") - - if offset != 0: - names = descramble_pman(data, offset, endian, text_obfuscated) - for i, name in enumerate(names): - vprint(f" {i}: {name}") - else: - vprint("Bit 0x004000 - NOT PRESENT") - - if feature_mask & 0x8000: - # One unknown byte, treated as an offset. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 - - vprint(f"Bit 0x008000 - offset: {hex(offset)}") - else: - vprint("Bit 0x008000 - NOT PRESENT") - - if feature_mask & 0x10000: - # Included font package, BINXRPC encoded. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 - - # I am not sure what the unknown byte is for. It always appears as - # all zeros in all files I've looked at. - expect_zero, length, binxrpc_offset = struct.unpack(f"{endian}III", data[offset:(offset + 12)]) - add_coverage(offset, 12) - - if expect_zero != 0: - # If we find non-zero versions of this, then that means updating the file is - # potentially unsafe as we could rewrite it incorrectly. So, let's assert! - raise Exception("Expected a zero in font package header!") - - if binxrpc_offset != 0: - benc = BinaryEncoding() - fontdata = benc.decode(data[binxrpc_offset:(binxrpc_offset + length)]) - add_coverage(binxrpc_offset, length) + vprint(f"Bit 0x000001 - count: {length}, offset: {hex(offset)}") + for name in texturenames: + vprint(f" {name}") else: - fontdata = None + vprint("Bit 0x000001 - NOT PRESENT") - vprint(f"Bit 0x010000 - offset: {hex(offset)}, binxrpc offset: {hex(binxrpc_offset)}") - if fontdata is not None: - filename = os.path.join(path, "fontinfo.xml") + # Mapping between texture index and the name of the texture. + if feature_mask & 0x02: + # Seems to be a structure that duplicates texture names? I am pretty + # sure this is used to map texture names to file indexes used elsewhere. + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 - if xml: - if write: - os.makedirs(path, exist_ok=True) - print(f"Writing {filename} font information...") - with open(filename, "w") as sfp: - sfp.write(str(fontdata)) - else: - print(f"Would write {filename} font information...") - else: - vprint("Bit 0x010000 - NOT PRESENT") + vprint(f"Bit 0x000002 - offset: {hex(offset)}") - if feature_mask & 0x20000: - # I am beginning to suspect that this is animation/level data. I have - # no idea what "afp" is. - offset = struct.unpack(f"{endian}I", data[header_offset:(header_offset + 4)])[0] - add_coverage(header_offset, 4) - header_offset += 4 + if offset != 0: + self.texturemap = self.descramble_pman(offset) + for i, name in enumerate(self.texturemap.entries): + vprint(f" {i}: {name}") + else: + vprint("Bit 0x000002 - NOT PRESENT") - vprint(f"Bit 0x020000 - offset: {hex(offset)}") + if feature_mask & 0x04: + vprint("Bit 0x000004 - legacy lz mode on") + else: + vprint("Bit 0x000004 - legacy lz mode off") - if offset > 0 and pp_19 > 0 and pp_20 > 0: - for x in range(pp_19): - structure_offset = offset + (x * 12) - anim_info_ptr = pp_20 + (x * 12) + # Mapping between region index and the texture it goes to as well as the + # region of texture that this particular graphic makes up. + if feature_mask & 0x08: + # Mapping between individual graphics and their respective textures. + # This is 10 bytes per entry. Seems to need both 0x2 (texture index) + # and 0x10 (region index). + length, offset = struct.unpack(f"{self.endian}II", self.data[header_offset:(header_offset + 8)]) + self.add_coverage(header_offset, 8) + header_offset += 8 - # First word is always zero, as observed. I am not ENTIRELY sure that - # the second field is length, but it lines up with everything else - # I've observed and seems to make sense. - expect_zero, afp_header_length, afp_header = struct.unpack( - f"{endian}III", - data[structure_offset:(structure_offset + 12)] - ) - add_coverage(structure_offset, 12) - add_coverage(afp_header, afp_header_length) + if offset != 0 and length > 0: + self.texture_to_region = [TextureRegion(0, 0, 0, 0, 0)] * length - if expect_zero != 0: - # If we find non-zero versions of this, then that means updating the file is - # potentially unsafe as we could rewrite it incorrectly. So, let's assert! - raise Exception("Expected a zero in font package header!") + for i in range(length): + descriptor_offset = offset + (10 * i) + texture_no, left, top, right, bottom = struct.unpack( + f"{self.endian}HHHHH", + self.data[descriptor_offset:(descriptor_offset + 10)], + ) + self.add_coverage(descriptor_offset, 10) - # This chunk of data is referred to by name, and then a chunk. - anim_name_offset, anim_afp_data_length, anim_afp_data_offset = struct.unpack( - f"{endian}III", - data[anim_info_ptr:(anim_info_ptr + 12)], - ) - add_coverage(anim_info_ptr, 12, unique=False) - add_coverage(anim_afp_data_offset, anim_afp_data_length) + if texture_no < 0 or texture_no >= len(self.texturemap.entries): + raise Exception(f"Out of bounds texture {texture_no}") - # Grab some debugging info to print, I am really not sure what to do with - # some of this data. - bytedata = get_until_null(data, anim_name_offset) - add_coverage(anim_name_offset, len(bytedata) + 1, unique=False) - name = descramble_text(bytedata, text_obfuscated) + # TODO: The offsets here seem to be off by a power of 2, there + # might be more flags in the above texture format that specify + # device scaling and such? + self.texture_to_region[i] = TextureRegion(texture_no, left, top, right, bottom) - vprint(" ", end="") - vprint(f"afp_header_length: {hex(afp_header_length)}, ", end="") - vprint(f"afp_header: {hex(afp_header)}, ", end="") - vprint(f"name: {name}, ", end="") - vprint(f"data: {hex(anim_afp_data_offset)}, ", end="") - vprint(f"length: {hex(anim_afp_data_length)}") - else: - vprint("Bit 0x020000 - NOT PRESENT") + vprint(f"Bit 0x000008 - count: {length}, offset: {hex(offset)}") + else: + vprint("Bit 0x000008 - NOT PRESENT") - if feature_mask & 0x40000: - vprint("Bit 0x040000 - modern lz mode on") - else: - vprint("Bit 0x040000 - modern lz mode off") + if feature_mask & 0x10: + # Names of the graphics regions, so we can look into the texture_to_region + # mapping above. + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 - if header_offset != header_length: - raise Exception("Failed to parse bitfield of header correctly!") + vprint(f"Bit 0x000010 - offset: {hex(offset)}") - if verbose: - print_coverage() + if offset != 0: + self.regionmap = self.descramble_pman(offset) + for i, name in enumerate(self.regionmap.entries): + vprint(f" {i}: {name}") + else: + vprint("Bit 0x000010 - NOT PRESENT") + + if feature_mask & 0x20: + vprint(f"Bit 0x000020 - text obfuscation on") + else: + vprint(f"Bit 0x000020 - text obfuscation off") + + if feature_mask & 0x40: + # Two unknown bytes, first is a length or a count. Secound is + # an optional offset to grab another set of bytes from. + length, offset = struct.unpack(f"{self.endian}II", self.data[header_offset:(header_offset + 8)]) + self.add_coverage(header_offset, 8) + header_offset += 8 + + vprint(f"Bit 0x000040 - count: {length}, offset: {hex(offset)}") + + # TODO: 0x40 has some weird offset calculations, gotta look into + # this further. Also, gotta actually parse this structure. + else: + vprint("Bit 0x000040 - NOT PRESENT") + + if feature_mask & 0x80: + # One unknown byte, treated as an offset. + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 + + vprint(f"Bit 0x000080 - offset: {hex(offset)}") + + # TODO: We don't save this PMAN structure, I have no idea what it's for, but if + # we find files with a nonzero value here and update textures, we're hosed. + if offset != 0: + pman = self.descramble_pman(offset) + for i, name in enumerate(pman.entries): + vprint(f" {i}: {name}") + else: + vprint("Bit 0x000080 - NOT PRESENT") + + if feature_mask & 0x100: + # Two unknown bytes, first is a length or a count. Secound is + # an optional offset to grab another set of bytes from. + length, offset = struct.unpack(f"{self.endian}II", self.data[header_offset:(header_offset + 8)]) + self.add_coverage(header_offset, 8) + header_offset += 8 + + vprint(f"Bit 0x000100 - count: {length}, offset: {hex(offset)}") + + # TODO: We do something if length is > 0, we use the magic flag + # from above in this case to optionally transform each thing we + # extract. This is possibly names of some other type of struture? + else: + vprint("Bit 0x000100 - NOT PRESENT") + + if feature_mask & 0x200: + # One unknown byte, treated as an offset. + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 + + vprint(f"Bit 0x000200 - offset: {hex(offset)}") + + # TODO: We don't save this PMAN structure, I have no idea what it's for, but if + # we find files with a nonzero value here and update textures, we're hosed. + if offset != 0: + pman = self.descramble_pman(offset) + for i, name in enumerate(pman.entries): + vprint(f" {i}: {name}") + else: + vprint("Bit 0x000200 - NOT PRESENT") + + if feature_mask & 0x400: + # One unknown byte, treated as an offset. + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 + + vprint(f"Bit 0x000400 - offset: {hex(offset)}") + else: + vprint("Bit 0x000400 - NOT PRESENT") + + if feature_mask & 0x800: + # This is the names of the animations as far as I can tell. + length, offset = struct.unpack(f"{self.endian}II", self.data[header_offset:(header_offset + 8)]) + self.add_coverage(header_offset, 8) + header_offset += 8 + + vprint(f"Bit 0x000800 - count: {length}, offset: {hex(offset)}") + + animnames = [] + for x in range(length): + interesting_offset = offset + (x * 12) + if interesting_offset != 0: + name_offset, anim_length, anim_offset = struct.unpack( + f"{self.endian}III", + self.data[interesting_offset:(interesting_offset + 12)], + ) + self.add_coverage(interesting_offset, 12) + if name_offset != 0: + # Let's decode this until the first null. + bytedata = self.get_until_null(name_offset) + self.add_coverage(name_offset, len(bytedata) + 1, unique=False) + name = AFPFile.descramble_text(bytedata, self.text_obfuscated) + animnames.append(name) + + if anim_offset != 0: + self.animations.append( + Animation( + name, + self.data[anim_offset:(anim_offset + anim_length)] + ) + ) + self.add_coverage(anim_offset, anim_length) + + for name in animnames: + vprint(f" {name}") + else: + vprint("Bit 0x000800 - NOT PRESENT") + + if feature_mask & 0x1000: + # Seems to be a secondary structure mirroring the above. + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 + + vprint(f"Bit 0x001000 - offset: {hex(offset)}") + + if offset != 0: + self.animmap = self.descramble_pman(offset) + for i, name in enumerate(self.animmap.entries): + vprint(f" {i}: {name}") + else: + vprint("Bit 0x001000 - NOT PRESENT") + + if feature_mask & 0x2000: + # I am making a very preliminary guess that these are shapes used along + # with animations specified below. The names in these sections tend to + # have the word "shape" in them. + length, offset = struct.unpack(f"{self.endian}II", self.data[header_offset:(header_offset + 8)]) + self.add_coverage(header_offset, 8) + header_offset += 8 + + vprint(f"Bit 0x002000 - count: {length}, offset: {hex(offset)}") + + # TODO: We do a LOT of extra stuff with this one, if count > 0... + + shapenames = [] + for x in range(length): + shape_base_offset = offset + (x * 12) + if shape_base_offset != 0: + name_offset, shape_length, shape_offset = struct.unpack( + f"{self.endian}III", + self.data[shape_base_offset:(shape_base_offset + 12)], + ) + self.add_coverage(shape_base_offset, 12) + self.add_coverage(shape_offset, shape_length) + + # TODO: At the shape offset is a "D2EG" structure of some sort. + # I have no idea what these do. I would have to look into it + # more if its important. + + if name_offset != 0: + # Let's decode this until the first null. + bytedata = self.get_until_null(name_offset) + self.add_coverage(name_offset, len(bytedata) + 1, unique=False) + name = AFPFile.descramble_text(bytedata, self.text_obfuscated) + shapenames.append(name) + + for name in shapenames: + vprint(f" {name}") + else: + vprint("Bit 0x002000 - NOT PRESENT") + + if feature_mask & 0x4000: + # Seems to be a secondary section mirroring the names from above. + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 + + vprint(f"Bit 0x004000 - offset: {hex(offset)}") + + if offset != 0: + pman = self.descramble_pman(offset) + for i, name in enumerate(pman.entries): + vprint(f" {i}: {name}") + else: + vprint("Bit 0x004000 - NOT PRESENT") + + if feature_mask & 0x8000: + # One unknown byte, treated as an offset. + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 + + vprint(f"Bit 0x008000 - offset: {hex(offset)}") + else: + vprint("Bit 0x008000 - NOT PRESENT") + + if feature_mask & 0x10000: + # Included font package, BINXRPC encoded. + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 + + # I am not sure what the unknown byte is for. It always appears as + # all zeros in all files I've looked at. + expect_zero, length, binxrpc_offset = struct.unpack(f"{self.endian}III", self.data[offset:(offset + 12)]) + self.add_coverage(offset, 12) + + if expect_zero != 0: + # If we find non-zero versions of this, then that means updating the file is + # potentially unsafe as we could rewrite it incorrectly. So, let's assert! + raise Exception("Expected a zero in font package header!") + + if binxrpc_offset != 0: + benc = BinaryEncoding() + self.fontdata = benc.decode(self.data[binxrpc_offset:(binxrpc_offset + length)]) + self.add_coverage(binxrpc_offset, length) + else: + self.fontdata = None + + vprint(f"Bit 0x010000 - offset: {hex(offset)}, binxrpc offset: {hex(binxrpc_offset)}") + else: + vprint("Bit 0x010000 - NOT PRESENT") + + if feature_mask & 0x20000: + # I am beginning to suspect that this is animation/level data. I have + # no idea what "afp" is. Games refer to these as "afp streams". + offset = struct.unpack(f"{self.endian}I", self.data[header_offset:(header_offset + 4)])[0] + self.add_coverage(header_offset, 4) + header_offset += 4 + + vprint(f"Bit 0x020000 - offset: {hex(offset)}") + + if offset > 0 and len(self.animations) > 0: + for i in range(len(self.animations)): + structure_offset = offset + (i * 12) + + # First word is always zero, as observed. I am not ENTIRELY sure that + # the second field is length, but it lines up with everything else + # I've observed and seems to make sense. + expect_zero, afp_header_length, afp_header = struct.unpack( + f"{self.endian}III", + self.data[structure_offset:(structure_offset + 12)] + ) + self.add_coverage(structure_offset, 12) + + if expect_zero != 0: + # If we find non-zero versions of this, then that means updating the file is + # potentially unsafe as we could rewrite it incorrectly. So, let's assert! + raise Exception("Expected a zero in font package header!") + + self.animations[i].header = self.data[afp_header:(afp_header + afp_header_length)] + self.add_coverage(afp_header, afp_header_length) + else: + vprint("Bit 0x020000 - NOT PRESENT") + + if feature_mask & 0x40000: + vprint("Bit 0x040000 - modern lz mode on") + else: + vprint("Bit 0x040000 - modern lz mode off") + + if header_offset != header_length: + raise Exception("Failed to parse bitfield of header correctly!") + + if verbose: + self.print_coverage() def main() -> int: @@ -834,14 +854,78 @@ def main() -> int: ) args = parser.parse_args() - extract( - args.file, - args.dir, - write=not args.pretend, - verbose=args.verbose, - raw=args.write_raw, - xml=args.write_mappings, - ) + with open(args.file, "rb") as bfp: + afpfile = AFPFile(bfp.read(), verbose=args.verbose) + + # Actually place the files down. + os.makedirs(args.dir, exist_ok=True) + + for texture in afpfile.textures: + filename = os.path.join(args.dir, texture.name) + + if texture.img: + if args.pretend: + print(f"Would write {filename}.png texture...") + else: + print(f"Writing {filename}.png texture...") + with open(f"{filename}.png", "wb") as bfp: + texture.img.save(bfp, format='PNG') + + if not texture.img or args.write_raw: + if args.pretend: + print(f"Would write {filename}.raw texture...") + else: + print(f"Writing {filename}.raw texture...") + with open(f"{filename}.raw", "wb") as bfp: + bfp.write(texture.raw) + + if args.xml: + if args.pretend: + print(f"Would write {filename}.xml texture info...") + else: + print(f"Writing {filename}.xml texture info...") + with open(f"{filename}.xml", "w") as sfp: + sfp.write(textwrap.dedent(f""" + + {texture.width} + {texture.height} + {hex(texture.fmt)} + {filename}.raw + + """).strip()) + + if args.write_mappings: + for i, name in enumerate(afpfile.regionmap.entries): + if i < 0 or i >= len(afpfile.texture_to_region): + raise Exception(f"Out of bounds region {i}") + region = afpfile.texture_to_region[i] + texturename = afpfile.texturemap.entries[region.textureno] + filename = os.path.join(args.dir, name) + + if args.pretend: + print(f"Would write {filename}.xml region information...") + else: + print(f"Writing {filename}.xml region information...") + with open(f"{filename}.xml", "w") as sfp: + sfp.write(textwrap.dedent(f""" + + {region.left} + {region.top} + {region.right} + {region.bottom} + {texturename} + + """).strip()) + + if afpfile.fontdata is not None: + filename = os.path.join(args.dir, "fontinfo.xml") + + if args.pretend: + print(f"Writing {filename} font information...") + else: + print(f"Writing {filename} font information...") + with open(filename, "w") as sfp: + sfp.write(str(afpfile.fontdata)) return 0