#! /usr/bin/env python3 import argparse import os import os.path import struct import sys from PIL import Image, ImageOps # type: ignore from typing import Any, List from bemani.format.dxt import DXTBuffer from bemani.protocol.binary import BinaryEncoding from bemani.protocol.lz77 import Lz77 # Coverage tracker to help find missing chunks. coverage: List[bool] 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 def print_coverage() -> None: global coverage # 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 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)") 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 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 "" def descramble_pman(package_data: bytes, offset: int, 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, _, _, _, numentries, _, data_offset = struct.unpack( "<4sIIIIII", package_data[offset:(offset + 28)], ) add_coverage(offset, 28) if magic != b"PMAN": raise Exception("Invalid magic value in PMAN structure!") names = [] if numentries > 0: # Jump to the offset, parse it out for i in range(numentries): file_offset = data_offset + (i * 12) # Really not sure on the first entry here, it looks # completely random, so it might be a CRC? _, entry_no, nameoffset = struct.unpack( " int: return struct.unpack("I", i))[0] def extract(filename: str, output_dir: str, *, write: bool, verbose: 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": 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("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) # 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) # This takes forever, so skip it if we're pretending. if write: print(f"Inflating {filename}...") 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)], ) # 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) if not write: print(f"Would extract {filename}...") else: # Now, see if we can extract this data. print(f"Extracting {filename}...") magic, _, _, _, width, height, fmt, _, flags2, flags1 = struct.unpack( "<4sIIIHHBBBB", raw_data[0:24], ) if magic != b"TDXT": raise Exception("Unexpected texture format!") img = None if fmt == 0x0E: # RGB image, no alpha. img = Image.frombytes( 'RGB', (width, height), raw_data[64:], 'raw', 'RGB', ) # 0x10 = Seems to be some sort of RGB with color swapping. # 0x15 = Looks like RGB but reversed (end and beginning bytes swapped). # 0x16 = DTX1 format, when I encounter this I'll hook it up. elif fmt == 0x1A: # DXT5 format. dxt = DXTBuffer(width, height) img = Image.frombuffer( 'RGBA', (width, height), dxt.DXT5Decompress(raw_data[64:]), 'raw', 'RGBA', 0, 1, ) img = ImageOps.flip(img).rotate(-90, expand=True) # 0x1E = I have no idea what format this is. # 0x1F = 16bpp, possibly grayscale? Maybe 555A or 565 color? elif fmt == 0x20: # RGBA format. img = Image.frombytes( 'RGBA', (width, height), raw_data[64:], 'raw', 'BGRA', ) else: raise Exception(f"Unsupported format {hex(fmt)} for texture {name}") # Actually place the file down. os.makedirs(path, exist_ok=True) with open(f"{filename}.raw", "wb") as bfp: bfp.write(raw_data) if img: with open(f"{filename}.png", "wb") as bfp: img.save(bfp, format='PNG') vprint(f"Bit 0x000001 - count: {length}, offset: {hex(offset)}") for name in names: vprint(f" {name}") else: vprint("Bit 0x000001 - NOT PRESENT") if feature_mask & 0x02: # Seems to be a structure that duplicates texture names? Maybe this is # used elsewhere to map sections to textures? The structure includes # the entry number that seems to correspond with the above table. offset = struct.unpack(" 0, we use the magic flag # from above in this case to optionally transform each thing we # extract. else: vprint("Bit 0x000100 - NOT PRESENT") if feature_mask & 0x200: # One unknown byte, treated as an offset. offset = struct.unpack(" 0... names = [] for x in range(length): interesting_offset = offset + (x * 12) if interesting_offset != 0: interesting_offset = struct.unpack( " 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) # 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. _, afp_header_length, afp_header = struct.unpack( " int: parser = argparse.ArgumentParser(description="BishiBashi graphic file unpacker.") parser.add_argument( "file", metavar="FILE", help="The file to extract", ) parser.add_argument( "dir", metavar="DIR", help="Directory to extract to", ) parser.add_argument( "-p", "--pretend", action="store_true", help="Pretend to extract instead of extracting.", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Display verbuse debugging output.", ) args = parser.parse_args() extract(args.file, args.dir, write=not args.pretend, verbose=args.verbose) return 0 if __name__ == "__main__": sys.exit(main())