#! /usr/bin/env python3 import argparse import io import json import os import os.path import sys import textwrap from PIL import Image, ImageDraw # type: ignore from typing import Any, Dict from bemani.format.afp import TXP2File, Shape, SWF, AFPRenderer, Color from bemani.format import IFS def main() -> int: parser = argparse.ArgumentParser(description="Konami AFP graphic file unpacker/repacker") subparsers = parser.add_subparsers(help='Action to take', dest='action') extract_parser = subparsers.add_parser('extract', help='Extract relevant textures from TXP2 container') extract_parser.add_argument( "file", metavar="FILE", help="The file to extract", ) extract_parser.add_argument( "dir", metavar="DIR", help="Directory to extract to", ) extract_parser.add_argument( "-p", "--pretend", action="store_true", help="Pretend to extract instead of extracting", ) extract_parser.add_argument( "-v", "--verbose", action="store_true", help="Display verbuse debugging output", ) extract_parser.add_argument( "-r", "--write-raw", action="store_true", help="Always write raw texture files", ) extract_parser.add_argument( "-m", "--write-mappings", action="store_true", help="Write mapping files to disk", ) extract_parser.add_argument( "-g", "--generate-mapping-overlays", action="store_true", help="Generate overlay images showing mappings", ) extract_parser.add_argument( "-s", "--split-textures", action="store_true", help="Split textures into individual sprites", ) extract_parser.add_argument( "-b", "--write-binaries", action="store_true", help="Write binary SWF files to disk", ) update_parser = subparsers.add_parser('update', help='Update relevant textures in a TXP2 container from a directory') update_parser.add_argument( "file", metavar="FILE", help="The file to update", ) update_parser.add_argument( "dir", metavar="DIR", help="Directory to update from", ) update_parser.add_argument( "-p", "--pretend", action="store_true", help="Pretend to update instead of updating", ) update_parser.add_argument( "-v", "--verbose", action="store_true", help="Display verbuse debugging output", ) print_parser = subparsers.add_parser('print', help='Print the TXP2 container contents as a JSON dictionary') print_parser.add_argument( "file", metavar="FILE", help="The file to print", ) print_parser.add_argument( "-d", "--decompile-bytecode", action="store_true", help="Attempt to decompile and print bytecode instead of printing the raw representation.", ) print_parser.add_argument( "-v", "--verbose", action="store_true", help="Display verbuse debugging output", ) parseafp_parser = subparsers.add_parser('parseafp', help='Parse a raw AFP/BSI file pair previously extracted from an IFS or TXP2 container') parseafp_parser.add_argument( "afp", metavar="AFPFILE", help="The AFP file to parse", ) parseafp_parser.add_argument( "bsi", metavar="BSIFILE", help="The BSI file to parse", ) parseafp_parser.add_argument( "-d", "--decompile-bytecode", action="store_true", help="Attempt to decompile and print bytecode instead of printing the raw representation.", ) parseafp_parser.add_argument( "-v", "--verbose", action="store_true", help="Display verbuse debugging output", ) parsegeo_parser = subparsers.add_parser('parsegeo', help='Parse a raw GEO file previously extracted from an IFS or TXP2 container') parsegeo_parser.add_argument( "geo", metavar="GEOFILE", help="The GEO file to parse", ) parsegeo_parser.add_argument( "-v", "--verbose", action="store_true", help="Display verbuse debugging output", ) render_parser = subparsers.add_parser('render', help='Render a particular animation out of a series of SWFs') render_parser.add_argument( "container", metavar="CONTAINER", type=str, nargs='+', help="A container file to use for loading SWF data. Can be either a TXP2 or IFS container.", ) render_parser.add_argument( "--path", metavar="PATH", type=str, required=True, help='A path to render, specified either as "moviename" or "moviename.exportedtag".', ) render_parser.add_argument( "--output", metavar="IMAGE", type=str, default="out.gif", help='The output file (ending either in .gif, .webp or .png) where the render should be saved.', ) render_parser.add_argument( "-v", "--verbose", action="store_true", help="Display verbuse debugging output", ) render_parser.add_argument( "--background-color", type=str, default=None, help="Set the background color of the animation, overriding a default if present in the SWF.", ) list_parser = subparsers.add_parser('list', help='List out the possible paths to render from a series of SWFs') list_parser.add_argument( "container", metavar="CONTAINER", type=str, nargs='+', help="A container file to use for loading SWF data. Can be either a TXP2 or IFS container.", ) list_parser.add_argument( "-v", "--verbose", action="store_true", help="Display verbuse debugging output", ) args = parser.parse_args() if args.action == "extract": if args.split_textures: if args.write_raw: raise Exception("Cannot write raw textures when splitting sprites!") if args.generate_mapping_overlays: raise Exception("Cannot generate mapping overlays when splitting sprites!") with open(args.file, "rb") as bfp: afpfile = TXP2File(bfp.read(), verbose=args.verbose) # Actually place the files down. os.makedirs(args.dir, exist_ok=True) if not args.split_textures: 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.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: if not args.split_textures: 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"Would write {filename} font information...") else: print(f"Writing {filename} font information...") with open(filename, "w") as sfp: sfp.write(str(afpfile.fontdata)) if args.write_binaries: for i, name in enumerate(afpfile.swfmap.entries): swf = afpfile.swfdata[i] filename = os.path.join(args.dir, name) if args.pretend: print(f"Would write {filename}.afp SWF data...") print(f"Would write {filename}.bsi SWF descramble data...") else: print(f"Writing {filename}.afp SWF data...") with open(f"{filename}.afp", "wb") as bfp: bfp.write(swf.data) print(f"Writing {filename}.bsi SWF descramble data...") with open(f"{filename}.bsi", "wb") as bfp: bfp.write(swf.descramble_info) for i, name in enumerate(afpfile.shapemap.entries): shape = afpfile.shapes[i] filename = os.path.join(args.dir, f"{name}.geo") if args.pretend: print(f"Would write {filename} shape data...") else: print(f"Writing {filename} shape data...") with open(filename, "wb") as bfp: bfp.write(shape.data) if args.generate_mapping_overlays: overlays: Dict[str, Any] = {} 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] if texturename not in overlays: for texture in afpfile.textures: if texture.name == texturename: overlays[texturename] = Image.new( 'RGBA', (texture.width, texture.height), (0, 0, 0, 0), ) break else: raise Exception(f"Couldn't find texture {texturename}") draw = ImageDraw.Draw(overlays[texturename]) draw.rectangle( ((region.left // 2, region.top // 2), (region.right // 2, region.bottom // 2)), fill=(0, 0, 0, 0), outline=(255, 0, 0, 255), width=1, ) draw.text( (region.left // 2, region.top // 2), name, fill=(255, 0, 255, 255), ) for name, img in overlays.items(): filename = os.path.join(args.dir, name) + "_overlay.png" if args.pretend: print(f"Would write {filename} overlay...") else: print(f"Writing {filename} overlay...") with open(filename, "wb") as bfp: img.save(bfp, format='PNG') if args.split_textures: textures: Dict[str, Any] = {} announced: Dict[str, bool] = {} 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] if texturename not in textures: for tex in afpfile.textures: if tex.name == texturename: textures[texturename] = tex break else: raise Exception("Could not find texture {texturename} to split!") if textures[texturename].img: # Grab the location in the image, save it out to a new file. filename = f"{texturename}_{name}.png" filename = os.path.join(args.dir, filename) if args.pretend: print(f"Would write {filename} sprite...") else: print(f"Writing {filename} sprite...") sprite = textures[texturename].img.crop( (region.left // 2, region.top // 2, region.right // 2, region.bottom // 2), ) with open(filename, "wb") as bfp: sprite.save(bfp, format='PNG') else: if not announced.get(texturename, False): print(f"Cannot extract sprites from {texturename} because it is not a supported format!") announced[texturename] = True if args.action == "update": # First, parse the file out with open(args.file, "rb") as bfp: afpfile = TXP2File(bfp.read(), verbose=args.verbose) # Now, find any PNG files that match texture names. for texture in afpfile.textures: filename = os.path.join(args.dir, texture.name) + ".png" if os.path.isfile(filename): print(f"Updating {texture.name} from {filename}...") with open(filename, "rb") as bfp: afpfile.update_texture(texture.name, bfp.read()) # Now, find any PNG files that match a specific sprite. for i, spritename 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] # Grab the location in the image to see if it exists. filename = f"{texturename}_{spritename}.png" filename = os.path.join(args.dir, filename) if os.path.isfile(filename): print(f"Updating {texturename} sprite piece {spritename} from {filename}...") with open(filename, "rb") as bfp: afpfile.update_sprite(texturename, spritename, bfp.read()) # Now, write out the updated file if args.pretend: print(f"Would write {args.file}...") afpfile.unparse() else: print(f"Writing {args.file}...") data = afpfile.unparse() with open(args.file, "wb") as bfp: bfp.write(data) if args.action == "print": # First, parse the file out with open(args.file, "rb") as bfp: afpfile = TXP2File(bfp.read(), verbose=args.verbose) # Now, print it print(json.dumps(afpfile.as_dict(decompile_bytecode=args.decompile_bytecode), sort_keys=True, indent=4)) if args.action == "parseafp": # First, load the AFP and BSI files with open(args.afp, "rb") as bafp: with open(args.bsi, "rb") as bbsi: swf = SWF("", bafp.read(), bbsi.read()) # Now, print it swf.parse(verbose=args.verbose) print(json.dumps(swf.as_dict(decompile_bytecode=args.decompile_bytecode), sort_keys=True, indent=4)) if args.action == "parsegeo": # First, load the AFP and BSI files with open(args.geo, "rb") as bfp: geo = Shape("", bfp.read()) # Now, print it geo.parse() if args.verbose: print(geo, file=sys.stderr) print(json.dumps(geo.as_dict(), sort_keys=True, indent=4)) if args.action in ["render", "list"]: # This is a complicated one, as we need to be able to specify multiple # directories of files as well as support IFS files and TXP2 files. renderer = AFPRenderer() # TODO: Allow specifying individual folders and such. for container in args.container: with open(container, "rb") as bfp: data = bfp.read() afpfile = None try: afpfile = TXP2File(data, verbose=args.verbose) except Exception: pass if afpfile is not None: if args.verbose: print(f"Loading files out of TXP2 container {container}...", file=sys.stderr) # First, load GE2D structures into the renderer. for i, name in enumerate(afpfile.shapemap.entries): shape = afpfile.shapes[i] renderer.add_shape(name, shape) if args.verbose: print(f"Added {name} to SWF shape library.", file=sys.stderr) # Now, split and load textures into the renderer. sheets: Dict[str, Any] = {} 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] if texturename not in sheets: for tex in afpfile.textures: if tex.name == texturename: sheets[texturename] = tex break else: raise Exception("Could not find texture {texturename} to split!") if sheets[texturename].img: sprite = sheets[texturename].img.crop( (region.left // 2, region.top // 2, region.right // 2, region.bottom // 2), ) renderer.add_texture(name, sprite) if args.verbose: print(f"Added {name} to SWF texture library.", file=sys.stderr) else: print(f"Cannot load {name} from {texturename} because it is not a supported format!") # Finally, load the SWF data itself into the renderer. for i, name in enumerate(afpfile.swfmap.entries): swf = afpfile.swfdata[i] renderer.add_swf(name, swf) if args.verbose: print(f"Added {name} to SWF library.", file=sys.stderr) continue ifsfile = None try: ifsfile = IFS(data, decode_textures=True) except Exception: pass if ifsfile is not None: if args.verbose: print(f"Loading files out of IFS container {container}...", file=sys.stderr) for fname in ifsfile.filenames: if fname.startswith(f"geo{os.sep}"): # Trim off directory. shapename = fname[(3 + len(os.sep)):] # Load file, register it. fdata = ifsfile.read_file(fname) shape = Shape(shapename, fdata) renderer.add_shape(shapename, shape) if args.verbose: print(f"Added {shapename} to SWF shape library.", file=sys.stderr) elif fname.startswith(f"tex{os.sep}") and fname.endswith(".png"): # Trim off directory, png extension. texname = fname[(3 + len(os.sep)):][:-4] # Load file, register it. fdata = ifsfile.read_file(fname) tex = Image.open(io.BytesIO(fdata)) renderer.add_texture(texname, tex) if args.verbose: print(f"Added {texname} to SWF texture library.", file=sys.stderr) elif fname.startswith(f"afp{os.sep}"): # Trim off directory, see if it has a corresponding bsi. afpname = fname[(3 + len(os.sep)):] bsipath = f"afp{os.sep}bsi{os.sep}{afpname}" if bsipath in ifsfile.filenames: afpdata = ifsfile.read_file(fname) bsidata = ifsfile.read_file(bsipath) flash = SWF(afpname, afpdata, bsidata) renderer.add_swf(afpname, flash) if args.verbose: print(f"Added {afpname} to SWF library.", file=sys.stderr) continue if args.action == "render": # Verify the correct params. if args.output.lower().endswith(".gif"): fmt = "GIF" elif args.output.lower().endswith(".webp"): fmt = "WEBP" elif args.output.lower().endswith(".png"): fmt = "PNG" else: raise Exception("Unrecognized file extension for output!") # Allow overriding background color. if args.background_color: colorvals = args.background_color.split(",") if len(colorvals) not in [3, 4]: raise Exception("Invalid color, specify a color as a comma-separated RGB or RGBA value!") if len(colorvals) == 3: colorvals.append("255") colorints = [int(c.strip()) for c in colorvals] for c in colorints: if c < 0 or c > 255: raise Exception("Color values should be between 0 and 255!") color = Color(*[c / 255.0 for c in colorints]) else: color = None # Render the gif/webp frames. duration, images = renderer.render_path(args.path, verbose=args.verbose, background_color=color) if len(images) == 0: raise Exception("Did not render any frames!") if fmt in ["GIF", "WEBP"]: # Write all the frames out in one file. with open(args.output, "wb") as bfp: images[0].save(bfp, format=fmt, save_all=True, append_images=images[1:], duration=duration, optimize=True) print(f"Wrote animation to {args.output}") else: # Write all the frames out in individual_files. filename = args.output[:-4] ext = args.output[-4:] for i, img in enumerate(images): fullname = f"{filename}-{i}{ext}" with open(fullname, "wb") as bfp: img.save(bfp, format=fmt) print(f"Wrote animation frame to {fullname}") elif args.action == "list": paths = renderer.list_paths(verbose=args.verbose) for path in paths: print(path) return 0 if __name__ == "__main__": sys.exit(main())